Record Sanctuary sources through beta.060 and Git validation
Build Sanctuary / build (push) Canceled after 0s

This commit is contained in:
koka
2026-09-15 09:29:11 +02:00
parent 1490fb2a80
commit e13f3cc1b8
1372 changed files with 92055 additions and 212 deletions
+21
View File
@@ -0,0 +1,21 @@
# Demeure — provenance du port beta.006
Auteur historique : KOKA99CAB. Licence : GPL-3.0-or-later. Sources locales `Structures/demeure`, citées par `26.2/inventory/README.md`, ciblant 26.1.2. Aucun JAR historique repris. Modèle des influences et états adapté ; événements et sauvegarde réécrits pour 26.3-pre-2, jours UTC réels, limites et séparation Atlas. AtomicJson et le hook de placement proviennent de Sanctuary (même licence).
Empreintes SHA-256 des sources de référence :
- `src/main/java/fr/koka99cab/demeure/DemeureMod.java` : `fef440779af58ca21a26dc79fd71676d27f79370239fa9b24ee691beb78becdd`
- `src/main/java/fr/koka99cab/demeure/command/DemeureCommands.java` : `ecd8fea6b7811cfa3b682ea566af748c5338a5c702970badc5a183480eda5706`
- `src/main/java/fr/koka99cab/demeure/network/DemeureFootprintPayload.java` : `621b9912560a27b1ef8c785be4d077ab5e153394c333833419d8b0a1e6c8b021`
- `src/main/java/fr/koka99cab/demeure/network/DemeureFootprintSync.java` : `790ac0c26b13f23e7619d1fffaf389d16ef44e9c3180552c6b1f307487646bea`
- `src/main/java/fr/koka99cab/demeure/server/DemeureEvents.java` : `983e1e0f417ca6ac71978ad4d6250067480425d7cfa6b5b14cdc9abbf7038a62`
- `src/main/java/fr/koka99cab/demeure/server/DemeureGesture.java` : `c50ce2b5a5897453ee7fe4b7cf3db9f0d5343522666acd7b69b5f2399678f8d7`
- `src/main/java/fr/koka99cab/demeure/server/DemeureTracker.java` : `bc362a3815a955fd576a481305a8006089242a3c4855be3607d549114373ea3c`
- `src/main/java/fr/koka99cab/demeure/world/DemeureApi.java` : `4cf5f353ff4d1149ec8ccceae5af315a500ce3d977572eeecc51c511058f73dd`
- `src/main/java/fr/koka99cab/demeure/world/DemeureChunkFootprint.java` : `fc85e880218522f4e535fc13e7b3895dca8425040d9028dd5d3d4232a1d39673`
- `src/main/java/fr/koka99cab/demeure/world/DemeureChunkView.java` : `516cf5f349e3364ca2dfb845838127b760efb62ca9eefb408bbdf78c32214879`
- `src/main/java/fr/koka99cab/demeure/world/DemeureInfluence.java` : `f9aa33bcf974b041f2ccd3edf1cb3975f2fc81d8b2bef6adb259c46461737c01`
- `src/main/java/fr/koka99cab/demeure/world/DemeureInfluenceView.java` : `13315b2400fc82f33fd7f636f3e4505e480b27d782f7b039b1e3f187784c71e3`
- `src/main/java/fr/koka99cab/demeure/world/DemeureSavedData.java` : `ce842b4062266d52b5778de315e18526ee3370056cab358ead6dc5e0630ef1c6`
- `src/main/java/fr/koka99cab/demeure/world/DemeureSubject.java` : `501fa36c7544da51ec625d999bed0d98c8caf1382e62cfb5500da5f3b2fa820f`
- `src/main/java/fr/koka99cab/demeure/world/DemeureTerritoryState.java` : `0753ae22d166ccd7283914349c327c9b92899e13ce82c3e2bce0ef5ffcd142a5`
+32
View File
@@ -0,0 +1,32 @@
# Demeure
Mod Fabric autonome pour Minecraft 26.3-pre-2, porté depuis les sources
historiques référencées par Sanctuary 26.2. Il mesure les empreintes du vivant
et l'attachement aux lieux ; il n'attribue ni propriété ni protection.
Sanctuary l'embarque dans son JAR et utilise son API pour la surcouche de
l'Atlas. Demeure ne dépend pas de Sanctuary. L'hôte peut restreindre les joueurs
éligibles avec `DemeureService.setEligibility` ; le pack limite ainsi les
traces aux mondes dont la progression est active et saine.
Les gestes sont relevés après placement ou destruction réussis, lors d'un
sommeil ou d'une mort, et par échantillonnage de l'activité. Les animaux,
villageois et monstres proches sont regroupés par catégorie, jusqu'à
128 entités chargées par habitant actif et par minute, dans un rayon de
32 blocs (boîte englobante). Le mode spectateur ne contribue pas.
Chaque chunk conserve des influences, une présence naturelle de départ,
un budget par sujet et jour UTC, et une décroissance après quatorze jours
d'absence. La consultation est indépendante des règles de protection et
ne crée jamais de chunk. Aucun point d'expérience n'est distribué.
Sauvegarde : `data/demeure/footprints-v1.json`, schéma 1 et graine du monde,
écriture atomique, sauvegarde périodique, déconnexion et arrêt du serveur.
La corruption ou une modification externe bloque l'écriture et produit
une erreur serveur ; le fichier existant est conservé. Les limites sont
explicitées dans [le contrat beta.006](../../docs/demeure-beta006.md).
Les données de l'ancien Demeure ne sont pas importées automatiquement.
`./gradlew :demeure:check :demeure:build` depuis la racine vérifie le modèle
et construit le module. Les tests natifs des événements et de l'Atlas vivent
dans Sanctuary. Licence GPL-3.0-or-later ; [provenance](PROVENANCE.md).
+27
View File
@@ -0,0 +1,27 @@
plugins { id 'net.fabricmc.fabric-loom' }
group = 'fr.koka.demeure'
version = rootProject.mod_version
base { archivesName = 'demeure' }
dependencies {
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}"
}
java { toolchain.languageVersion = JavaLanguageVersion.of(25); withSourcesJar() }
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8'; options.release = 25 }
processResources {
def values = [version: project.version, minecraft: rootProject.minecraft_version.replace('-pre-', '-pre.'), loader: rootProject.loader_version]
inputs.properties(values)
filesMatching('fabric.mod.json') { expand(values) }
}
tasks.named('jar') {
from(rootProject.file('LICENSE')) { rename { 'LICENSE_demeure' } }
from('PROVENANCE.md')
}
tasks.named('test') { failOnNoDiscoveredTests = false }
tasks.register('demeureSmoke', JavaExec) {
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.demeure.DemeureSmoke'
}
tasks.named('check') { dependsOn('demeureSmoke') }
@@ -0,0 +1,78 @@
package fr.koka.demeure;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import com.google.gson.Strictness;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import java.io.IOException;
import java.io.StringReader;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.Arrays;
/** Refuses corrupt, oversized or externally replaced state; never resets evidence on failure. */
public final class AtomicJson {
private AtomicJson() { }
public static JsonElement parse(String text) {
try {
var reader = new JsonReader(new StringReader(text));
reader.setStrictness(Strictness.STRICT);
scan(reader, 0);
if (reader.peek() != JsonToken.END_DOCUMENT) throw new IllegalArgumentException("Trailing JSON");
reader = new JsonReader(new StringReader(text));
reader.setStrictness(Strictness.STRICT);
JsonElement value = JsonParser.parseReader(reader);
if (reader.peek() != JsonToken.END_DOCUMENT) throw new IllegalArgumentException("Trailing JSON");
return value;
} catch (IOException error) { throw new IllegalArgumentException(error); }
}
public static void fields(JsonElement value, String... expected) {
if (!value.isJsonObject() || !value.getAsJsonObject().keySet().equals(java.util.Set.of(expected)))
throw new IllegalArgumentException("Missing or unknown JSON fields");
}
private static void scan(JsonReader reader, int depth) throws IOException {
if (depth > 32) throw new IllegalArgumentException("JSON nesting too deep");
switch (reader.peek()) {
case BEGIN_OBJECT -> {
reader.beginObject();
var keys = new java.util.HashSet<String>();
while (reader.hasNext()) {
if (!keys.add(reader.nextName())) throw new IllegalArgumentException("Duplicate JSON field");
scan(reader, depth + 1);
}
reader.endObject();
}
case BEGIN_ARRAY -> { reader.beginArray(); while (reader.hasNext()) scan(reader, depth + 1); reader.endArray(); }
default -> reader.skipValue();
}
}
public static byte[] read(Path file, int limit) throws IOException {
if (Files.notExists(file, LinkOption.NOFOLLOW_LINKS)) return null;
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.size(file) > limit)
throw new IOException("Invalid or oversized file: " + file);
byte[] bytes;
try (var stream = Files.newInputStream(file)) { bytes = stream.readNBytes(limit + 1); }
if (bytes.length > limit) throw new IOException("Oversized file: " + file);
return bytes;
}
public static byte[] write(Path file, byte[] previous, String text, int limit) throws IOException {
if (!Arrays.equals(previous, read(file, limit))) throw new IOException("File changed externally: " + file);
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
if (bytes.length > limit) throw new IOException("Oversized state: " + file);
Files.createDirectories(file.getParent());
Path temporary = Files.createTempFile(file.getParent(), ".demeure-", ".tmp");
try {
try (var channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
var buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) channel.write(buffer);
channel.force(true);
}
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
if (!Arrays.equals(bytes, read(file, limit))) throw new IOException("Read-back mismatch: " + file);
return bytes;
} finally { Files.deleteIfExists(temporary); }
}
}
@@ -0,0 +1,123 @@
package fr.koka.demeure;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.*;
import java.util.function.Predicate;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.entity.event.v1.EntitySleepEvents;
import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.core.BlockPos;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.animal.Animal;
import net.minecraft.world.entity.monster.Monster;
import net.minecraft.world.entity.npc.villager.AbstractVillager;
import net.minecraft.world.level.block.BedBlock;
import net.minecraft.world.level.block.CropBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.LevelResource;
import org.slf4j.LoggerFactory;
/** Autonomous server domain. Hosts may narrow activation; no client request can add influence. */
public final class DemeureService implements ModInitializer {
private static final org.slf4j.Logger LOG=LoggerFactory.getLogger("demeure");
private static final Map<MinecraftServer,Session> SESSIONS=new WeakHashMap<>();
private static Predicate<ServerPlayer> eligibility=player -> true;
private static final class Session {
FootprintStore store;boolean failed,capacityWarned;int lastSave;
final Map<UUID,Trace> traces=new HashMap<>();
}
private static final class Trace {
FootprintStore.Key chunk;BlockPos pos;int activeTick,minuteTick;
}
public static void setEligibility(Predicate<ServerPlayer> predicate) {eligibility=Objects.requireNonNull(predicate);}
public static long day() {return LocalDate.now(ZoneOffset.UTC).toEpochDay();}
public static boolean eligible(ServerPlayer player) {return player.isAlive() && !player.isSpectator() && eligibility.test(player);}
public static FootprintStore.Key key(ServerPlayer player,BlockPos pos) {
return new FootprintStore.Key(player.level().dimension().identifier().toString(),Math.floorDiv(pos.getX(),16),Math.floorDiv(pos.getZ(),16));
}
private static FootprintStore.Subject subject(ServerPlayer player) {
return new FootprintStore.Subject("player",player.getUUID().toString(),player.getGameProfile().name());
}
private static Session session(MinecraftServer server) {
return SESSIONS.computeIfAbsent(server,s -> {
var session=new Session();session.lastSave=s.getTickCount();
try {session.store=new FootprintStore(s.getWorldPath(LevelResource.ROOT).resolve("data/demeure/footprints-v1.json"),s.overworld().getSeed());}
catch (Exception error) {session.failed=true;LOG.error("Demeure unavailable; original file preserved",error);}
return session;
});
}
public static FootprintStore.View view(MinecraftServer server,FootprintStore.Key key) {
var session=session(server);return session.failed ? null : session.store.view(key,day());
}
public static boolean available(MinecraftServer server) {return !session(server).failed;}
public static double record(ServerPlayer player,BlockPos pos,double amount) {
if (!eligible(player)) return 0;
return recordUnchecked(player,pos,amount);
}
private static double recordUnchecked(ServerPlayer player,BlockPos pos,double amount) {
var session=session(player.level().getServer());if (session.failed) return 0;
var trace=session.traces.computeIfAbsent(player.getUUID(),id -> new Trace());trace.activeTick=player.level().getServer().getTickCount();
return session.store.touch(key(player,pos),subject(player),amount,day());
}
public static void placed(ServerPlayer player,BlockPos pos,BlockState state) {
record(player,pos,state.getBlock() instanceof BedBlock ? .5 : .05);
}
@Override public void onInitialize() {
PlayerBlockBreakEvents.AFTER.register((level,player,pos,state,entity) -> {
if (player instanceof ServerPlayer sp) record(sp,pos,state.getBlock() instanceof CropBlock crop && crop.isMaxAge(state) ? .3 : .05);
});
EntitySleepEvents.START_SLEEPING.register((entity,pos) -> {if (entity instanceof ServerPlayer sp) record(sp,pos,2);});
ServerLivingEntityEvents.AFTER_DEATH.register((entity,source) -> {
if (entity instanceof ServerPlayer sp && !sp.isSpectator() && eligibility.test(sp)) recordUnchecked(sp,sp.blockPosition(),1);
});
ServerTickEvents.END_SERVER_TICK.register(DemeureService::tick);
ServerPlayConnectionEvents.DISCONNECT.register((handler,server) -> {
var session=SESSIONS.get(server);if (session!=null) {session.traces.remove(handler.player.getUUID());save(session);}
});
ServerLifecycleEvents.SERVER_STOPPING.register(server -> {var session=SESSIONS.remove(server);if (session!=null) save(session);});
}
private static void tick(MinecraftServer server) {
if (server.getTickCount()%20!=0) return;
int tick=server.getTickCount();var seen=new HashSet<UUID>();
for (var player:server.getPlayerList().getPlayers()) {
if (!eligible(player)) continue;
var session=session(server);if (session.failed) continue;
var trace=session.traces.computeIfAbsent(player.getUUID(),id -> new Trace());
var pos=player.blockPosition();var key=key(player,pos);
if (!key.equals(trace.chunk)) {record(player,pos,.01);trace.chunk=key;}
if (!pos.equals(trace.pos)) {trace.activeTick=tick;trace.pos=pos.immutable();}
if (tick-trace.minuteTick>=1200) {
boolean active=tick-trace.activeTick<1200;
if (active) session.store.touch(key,subject(player),.1,day());
trace.minuteTick=tick;
if (!active) continue;
// A bounded sample of loaded living neighbours; never searches or loads chunks.
int budget=128;
for (var entity:player.level().getEntitiesOfClass(LivingEntity.class,player.getBoundingBox().inflate(32))) {
if (budget--<=0) break;
if (!entity.isAlive() || !seen.add(entity.getUUID())) continue;
String kind=entity instanceof Animal ? "animals" : entity instanceof AbstractVillager ? "villagers" : entity instanceof Monster ? "monsters" : null;
if (kind==null) continue;
var at=entity.blockPosition();
session.store.touch(key(player,at),new FootprintStore.Subject(kind,kind,""),kind.equals("animals") ? .025 : kind.equals("villagers") ? .035 : .04,day());
}
}
}
var session=SESSIONS.get(server);
if (session!=null && !session.failed) {
if (!session.capacityWarned && session.store.atCapacity()) {session.capacityWarned=true;LOG.warn("Demeure capacity reached; existing footprints retained");}
if (tick-session.lastSave>=1200) {save(session);session.lastSave=tick;}
}
}
private static void save(Session session) {
if (session.failed) return;
try {session.store.save();} catch (Exception error) {session.failed=true;LOG.error("Demeure save refused; original file preserved",error);}
}
}
@@ -0,0 +1,117 @@
package fr.koka.demeure;
import com.google.gson.Gson;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.*;
/** Adapted from KOKA99CAB's Demeure influence model. No ownership or protection is inferred. */
public final class FootprintStore {
public static final int MAX_CHUNKS=16384,MAX_SUBJECTS=31;
private static final int MAX_BYTES=64*1024*1024;
private static final Gson JSON=new Gson();
public record Key(String dimension,int x,int z) {
public Key {
if (dimension==null || dimension.length()>128 || !dimension.matches("[a-z0-9_.-]+:[a-z0-9_./-]+")
|| Math.abs((long)x)>1875000 || Math.abs((long)z)>1875000) throw new IllegalArgumentException("Demeure location");
}
}
public record Subject(String kind,String id,String label) {
public Subject {
if (!Set.of("player","animals","villagers","monsters","nature").contains(kind)
|| id==null || id.isBlank() || id.length()>64 || label==null || label.length()>64
|| label.codePoints().anyMatch(c -> Character.isISOControl(c) || c==0xA7)) throw new IllegalArgumentException("Demeure subject");
if (kind.equals("player")) UUID.fromString(id);
}
}
public record Influence(Subject subject,double score,long lastDay,int activeDays,double daily) {
public Influence {
if (subject==null || !Double.isFinite(score) || score<0 || score>1000000 || lastDay<0
|| activeDays<1 || activeDays>16 || !Double.isFinite(daily) || daily<0 || daily>8.000001)
throw new IllegalArgumentException("Demeure influence");
}
public double base(long day) {return score*Math.pow(.985,Math.max(0,day-lastDay-14));}
public double effective(long day) {return base(day)*(1+Math.min(1.2,(activeDays-1)*.08));}
}
public record Share(Subject subject,double score,int percent) { }
public record View(String state,List<Share> influences) {public View {influences=List.copyOf(influences);}}
private record Chunk(Key key,List<Influence> influences) { }
private record Document(int schema,long seed,List<Chunk> chunks) { }
private final Path path;
private final long seed;
private final Map<Key,LinkedHashMap<String,Influence>> chunks=new LinkedHashMap<>();
private byte[] previous;
private boolean dirty,atCapacity;
public FootprintStore(Path path,long seed) throws IOException {
this.path=path;this.seed=seed;previous=AtomicJson.read(path,MAX_BYTES);
if (previous==null) return;
var tree=AtomicJson.parse(new String(previous,StandardCharsets.UTF_8));
AtomicJson.fields(tree,"schema","seed","chunks");
for (var chunk:tree.getAsJsonObject().getAsJsonArray("chunks")) {
AtomicJson.fields(chunk,"key","influences");
AtomicJson.fields(chunk.getAsJsonObject().get("key"),"dimension","x","z");
for (var inf:chunk.getAsJsonObject().getAsJsonArray("influences")) {
AtomicJson.fields(inf,"subject","score","lastDay","activeDays","daily");
AtomicJson.fields(inf.getAsJsonObject().get("subject"),"kind","id","label");
}
}
var doc=JSON.fromJson(tree,Document.class);
if (doc.schema!=1 || doc.seed!=seed || doc.chunks==null || doc.chunks.size()>MAX_CHUNKS) throw new IOException("Demeure schema/seed");
for (var chunk:doc.chunks) {
if (chunk.key==null || chunk.influences==null || chunk.influences.size()>MAX_SUBJECTS) throw new IOException("Demeure chunk");
var entries=new LinkedHashMap<String,Influence>();
for (var inf:chunk.influences) {
if (inf==null || inf.subject.kind.equals("nature") || entries.put(subjectKey(inf.subject),inf)!=null) throw new IOException("Demeure duplicate subject");
}
if (chunks.put(chunk.key,entries)!=null) throw new IOException("Demeure duplicate chunk");
}
}
private static String subjectKey(Subject subject) {return subject.kind+":"+subject.id;}
/** Returns the accepted contribution. Caps survive reconnects and restarts. */
public double touch(Key key,Subject subject,double amount,long day) {
if (day<0 || !Double.isFinite(amount) || amount<=0 || subject.kind.equals("nature")) throw new IllegalArgumentException("Demeure gesture");
var entries=chunks.get(key);
if (entries==null) {
if (chunks.size()>=MAX_CHUNKS) {atCapacity=true;return 0;}
entries=new LinkedHashMap<>();chunks.put(key,entries);
}
String id=subjectKey(subject);var old=entries.get(id);
if (old==null && entries.size()>=MAX_SUBJECTS) {atCapacity=true;return 0;}
day=old==null ? day : Math.max(day,old.lastDay);
double daily=old==null || day>old.lastDay ? 0 : old.daily;
double accepted=Math.min(amount,Math.max(0,8-daily));
if (accepted<=0) return 0;
int days=old==null ? 1 : Math.min(16,old.activeDays+(day>old.lastDay ? 1 : 0));
double score=Math.min(1000000,(old==null ? 0 : old.base(day))+accepted);
entries.put(id,new Influence(subject,score,day,days,daily+accepted));dirty=true;
return accepted;
}
public View view(Key key,long day) {
var entries=chunks.get(key);if (entries==null) return null;
var raw=new ArrayList<Share>();
raw.add(new Share(new Subject("nature","nature",""),.35,0));
for (var inf:entries.values()) raw.add(new Share(inf.subject,inf.effective(day),0));
raw.sort(Comparator.comparingDouble(Share::score).reversed().thenComparing(s -> subjectKey(s.subject)));
double total=raw.stream().mapToDouble(Share::score).sum();
var shares=raw.stream().map(s -> new Share(s.subject,s.score,(int)Math.round(s.score*100/total))).toList();
var top=shares.getFirst();double first=top.score*100/total;
double second=shares.size()>1 ? shares.get(1).score*100/total : 0;
boolean player=shares.stream().anyMatch(s -> s.subject.kind.equals("player") && s.score*100/total>=20);
long last=entries.values().stream().filter(i -> !i.subject.kind.equals("animals")).mapToLong(Influence::lastDay).max().orElse(day);
String state=total<.8 ? "neutral"
: day-last>14 && shares.stream().anyMatch(s -> !Set.of("nature","animals").contains(s.subject.kind) && s.score*100/total>=20) ? "abandoned"
: top.subject.kind.equals("monsters") && !player ? "abandoned"
: Set.of("nature","animals").contains(top.subject.kind) && !player ? "wild"
: second>=20 && first-second<=10 ? "contested"
: second>=20 ? "shared" : first>=50 ? "dominated" : "inhabited";
return new View(state,shares);
}
public int size() {return chunks.size();}
public boolean atCapacity() {return atCapacity || chunks.size()>=MAX_CHUNKS;}
public void save() throws IOException {
if (!dirty) return;
var entries=chunks.entrySet().stream().map(e -> new Chunk(e.getKey(),new ArrayList<>(e.getValue().values()))).toList();
previous=AtomicJson.write(path,previous,JSON.toJson(new Document(1,seed,entries)),MAX_BYTES);dirty=false;
}
}
@@ -0,0 +1,29 @@
package fr.koka.demeure.mixin;
import com.llamalad7.mixinextras.injector.wrapoperation.Operation;
import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation;
import fr.koka.demeure.DemeureService;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
/** Count the confirmed block state, including wall variants and merged slabs. */
@Mixin(BlockItem.class)
public abstract class PlacementMixin {
@WrapOperation(method = "place", at = @At(value = "INVOKE", target =
"Lnet/minecraft/world/level/block/Block;setPlacedBy(Lnet/minecraft/world/level/Level;Lnet/minecraft/core/BlockPos;Lnet/minecraft/world/level/block/state/BlockState;Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/world/item/ItemStack;)V"))
private void demeure$placed(Block block, Level level, BlockPos pos,
BlockState state, LivingEntity placer, ItemStack stack, Operation<Void> original) {
original.call(block, level, pos, state, placer, stack);
if (placer instanceof ServerPlayer player) {
DemeureService.placed(player,pos,state);
}
}
}
@@ -0,0 +1 @@
{"required":true,"package":"fr.koka.demeure.mixin","compatibilityLevel":"JAVA_25","mixins":["PlacementMixin"],"injectors":{"defaultRequire":1}}
@@ -0,0 +1,13 @@
{
"schemaVersion": 1,
"id": "demeure",
"version": "${version}",
"name": "Demeure",
"description": "Living footprints, activity and attachment to places. Sanctuary adaptation.",
"authors": ["KOKA99CAB", "Koka"],
"license": "GPL-3.0-or-later",
"environment": "*",
"entrypoints": {"main": ["fr.koka.demeure.DemeureService"]},
"mixins": ["demeure.mixins.json"],
"depends": {"fabricloader": ">=${loader}", "minecraft": "${minecraft}", "java": ">=25", "fabric-api": "*"}
}
@@ -0,0 +1,46 @@
package fr.koka.demeure;
import java.nio.file.Files;
import java.util.UUID;
public final class DemeureSmoke {
public static void main(String[] args) throws Exception {
var dir=Files.createTempDirectory("demeure-smoke");var path=dir.resolve("footprints.json");
var store=new FootprintStore(path,42);
var key=new FootprintStore.Key("minecraft:overworld",-2,3);
var player=new FootprintStore.Subject("player",UUID.randomUUID().toString(),"Inhabitant");
require(store.view(key,20000)==null,"Reading an unknown chunk does not invent habitation");
require(store.touch(key,player,6,20000)==6 && store.touch(key,player,5,20000)==2,"Daily accepted budget is eight");
require(store.touch(key,player,1,19999)==0,"Clock rollback cannot reset budget");
store.save();store=new FootprintStore(path,42);
require(store.touch(key,player,1,20000)==0,"Restart does not reset budget");
var original=store.view(key,20000);
require(original.state().equals("dominated"),"Strong inhabitant influence is represented without a claim");
double decayed=score(store.view(key,20100),player);
require(decayed<8 && score(store.view(key,20014),player)==8,"Fourteen real days of grace then decay");
store.touch(key,player,.1,20100);
require(Math.abs(score(store.view(key,20100),player)-(decayed+.1)*1.08)<1e-8,"Returning materializes decay instead of resurrecting stale influence");
require(score(original,player)==8,"Snapshots are immutable");
store.save();require(new FootprintStore(path,42).view(key,20100).equals(store.view(key,20100)),"Exact persistence round trip");
require(store.view(new FootprintStore.Key("minecraft:the_nether",-2,3),20100)==null,"Dimensions are independent");
var other=new FootprintStore.Subject("player",UUID.randomUUID().toString(),"Neighbour");
var shared=new FootprintStore.Key("minecraft:overworld",1,1);
store.touch(shared,player,2,20100);store.touch(shared,other,2,20100);
require(store.view(shared,20100).state().equals("contested"),"Close equal influences remain contested, never owned");
require(store.view(shared,20115).state().equals("abandoned"),"Absence is evaluated on real days");
var animals=new FootprintStore.Key("minecraft:overworld",2,2);
store.touch(animals,new FootprintStore.Subject("animals","animals",""),2,20100);
require(store.view(animals,20100).state().equals("wild"),"Animal presence gives a wild footprint");
for (int i=0;i<40;i++) store.touch(shared,new FootprintStore.Subject("player",new UUID(0,i+1).toString(),"Visitor"),.1,20100);
require(store.atCapacity() && store.view(shared,20100).influences().size()==32,"Subject cap preserves existing contributors and nature");
var bytes=Files.readAllBytes(path);Files.writeString(path,"{broken");
var held=store;failure(held::save);failure(() -> new FootprintStore(path,42));
require(Files.readString(path).equals("{broken"),"External corruption is preserved");
Files.write(path,bytes);failure(() -> new FootprintStore(path,43));
System.out.println("Demeure smoke passed: real-day budget, rollback, decay, persistence, dimensions, territories and capacity.");
}
private static double score(FootprintStore.View view,FootprintStore.Subject subject) {return view.influences().stream().filter(s -> s.subject().equals(subject)).findFirst().orElseThrow().score();}
private static void require(boolean value,String message) {if (!value) throw new AssertionError(message);}
private static void failure(Checked action) throws Exception {boolean failed=false;try {action.run();}catch (Exception expected) {failed=true;}require(failed,"Invalid state must be refused");}
private interface Checked {void run() throws Exception;}
}
+45
View File
@@ -0,0 +1,45 @@
# Just Enough Items — Sanctuary preview
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.
Licence MIT, copyright 20142015 mezz. `LICENSE.txt` est inclus sans modification.
Le fork adapte les entrées SDL, les pipelines RenderPearl, les recettes devenues
un registre, les tags dingrédients et les recettes dalchimie natives. Les valeurs
contextuelles de combustible/compost ont une page descriptive ; une valeur numérique
nest exposée que si le composant est explicitement constant.
Linterface de pont `mezz.jei.api.sanctuary.CatalogueAccess` laisse Sanctuary gérer
laccès au catalogue, la visibilité des recettes, les favoris et linventaire étendu.
Les recettes apprises sont consultables avant la découverte de leur poste de travail.
Les recettes cachées restent cachées lorsquun favori tente de les rouvrir directement.
Le vieux rectangle cliquable de linventaire 26.2 est retiré ; R/U et Blocodex restent
les points dentrée. Les autres interfaces et le transfert réseau restent ceux de JEI.
## Reconstruction
`./gradlew :jei:build` exécute `scripts/prepare_jei.py` à la configuration lorsque
lempreinte source/patch change. Le script télécharge le dépôt officiel seulement si
le cache ignoré manque, extrait le commit fixé et applique `patches/26.3.patch` dans
`build/jei-fork`. Aucun source tiers téléchargé ni JAR nest versionné.
La première reconstruction nécessite Internet, Git, patch et Python 3.
`./gradlew :jei:sourcesJar` produit aussi les sources Java effectivement compilées.
Le mod JEI est imbriqué dans le JAR Sanctuary avec deux dépendances :
- `net.mezzdev:baked-substring-index:0.1.0`, MIT, James Mitchell.
https://github.com/mezz/baked-substring-index — licence dans `licenses/baked-substring-index.txt`.
- `net.mezzdev:suffixtree:1.1.0`, Apache-2.0, Alessandro Bahgat Shehata.
https://github.com/mezz/generalized-suffix-tree — licence dans `licenses/suffixtree.txt`.
Amecs 26.2 sert uniquement à compiler le point dintégration optionnel historique ;
il nest ni embarqué ni annoncé compatible 26.3. Le client vérifié utilise les touches
natives sans Amecs. Les fichiers de licences des dépendances sont ajoutés au JAR JEI.
Ce fork expérimental ne revendique pas une validation upstream de cette préversion.
La révision Sanctuary.2 retire le bouton de configuration du catalogue lorsque
Sanctuary le demande (dessin, infobulle et interactions), et adapte les libellés
FR/EN des favoris, de lhistorique et des raccourcis. Les attributions, identifiants
JEI, formats de réglages et mécanismes de recettes restent conservés.
+36
View File
@@ -0,0 +1,36 @@
plugins { id 'net.fabricmc.fabric-loom' }
version='30.32.0-sanctuary.2'
group='mezz.jei'
base { archivesName='jei-sanctuary-26.3-pre-2' }
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')
sourceSets.main.java.setSrcDirs(['Common/src/main/java','Common/src/api/java','Fabric/src/main/java','Fabric/src/api/java','Library/src/main/java','Gui/src/main/java'].collect{new File(upstream,it)})
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'
implementation 'org.jetbrains:annotations:26.0.2'
implementation 'org.jspecify:jspecify:1.0.0'
implementation 'net.mezzdev:baked-substring-index:0.1.0'
include 'net.mezzdev:baked-substring-index:0.1.0'
implementation 'net.mezzdev:suffixtree:1.1.0'
include 'net.mezzdev:suffixtree:1.1.0'
compileOnly 'de.siphalor.amecs.amecs-key-modifiers:amecs-key-modifiers-mc26.2.0:1.0.2'
}
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']
inputs.properties(values)
filesMatching('fabric.mod.json') { expand(values) }
duplicatesStrategy=DuplicatesStrategy.EXCLUDE
}
tasks.withType(Jar).configureEach {
from(new File(upstream,'LICENSE.txt'))
from('PROVENANCE.md')
from('licenses') { into 'licenses' }
}
java { withSourcesJar() }
@@ -0,0 +1,16 @@
MIT License
Copyright (c) 2026 James Mitchell
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2012 Alessandro Bahgat Shehata
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
plugins { id 'net.fabricmc.fabric-loom' }
group='fr.koka.sanctuarytest'
version=rootProject.mod_version
base { archivesName='sanctuary-test' }
dependencies {
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 project(':sanctuary')
}
java { toolchain.languageVersion=JavaLanguageVersion.of(25); withSourcesJar() }
tasks.withType(JavaCompile).configureEach { options.encoding='UTF-8'; options.release=25 }
processResources {
def values=[version:project.version,minecraft:rootProject.minecraft_version.replace('-pre-','-pre.'),loader:rootProject.loader_version]
inputs.properties(values)
filesMatching('fabric.mod.json') { expand(values) }
}
loom { runs { client { runDir 'run/quick-test' } } }
tasks.named('test') { failOnNoDiscoveredTests=false }
tasks.named('jar') { from(rootProject.file('LICENSE')) { rename { 'LICENSE_sanctuary_test' } } }
@@ -0,0 +1,24 @@
package fr.koka.sanctuarytest;
import fr.koka.sanctuary.client.IntroClient;
import java.util.*;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationNetworking;
import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen;
public final class QuickTestClient implements ClientModInitializer {
private final Set<Screen> initialized=Collections.newSetFromMap(new WeakHashMap<>());
public void onInitializeClient(){
ClientConfigurationNetworking.registerGlobalReceiver(QuickTestMod.Mode.TYPE,(payload,context)->
context.client().execute(()->IntroClient.setQuickTestIntro(payload.quick())));
ScreenEvents.AFTER_INIT.register((minecraft,screen,width,height)->{
if(screen instanceof CreateWorldScreen creation&&initialized.add(screen)){
var state=creation.getUiState();
state.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat"))
.findFirst().ifPresent(state::setWorldType);
}
});
}
}
@@ -0,0 +1,25 @@
package fr.koka.sanctuarytest;
import fr.koka.sanctuary.testing.QuickTestWorld;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.networking.v1.*;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public final class QuickTestMod implements ModInitializer {
public record Mode(boolean quick) implements CustomPacketPayload {
public static final Type<Mode> TYPE=new Type<>(Identifier.fromNamespaceAndPath("sanctuary_test","mode"));
public static final StreamCodec<FriendlyByteBuf,Mode> CODEC=CustomPacketPayload.codec((p,b)->b.writeBoolean(p.quick),b->new Mode(b.readBoolean()));
public Type<Mode> type(){return TYPE;}
}
public void onInitialize(){
QuickTestWorld.enable();
PayloadTypeRegistry.clientboundConfiguration().register(Mode.TYPE,Mode.CODEC);
ServerConfigurationConnectionEvents.CONFIGURE.register((handler,server)->{
if(ServerConfigurationNetworking.canSend(handler,Mode.TYPE))
ServerConfigurationNetworking.send(handler,new Mode(QuickTestWorld.active(server)));
});
}
}
@@ -0,0 +1,3 @@
{
"generator.sanctuary_test.flat": "Sanctuary — Quick Test"
}
@@ -0,0 +1,3 @@
{
"generator.sanctuary_test.flat": "Sanctuary — Test rapide"
}
@@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"sanctuary_test:flat"
]
}
@@ -0,0 +1,55 @@
{
"dimensions": {
"minecraft:overworld": {
"type": "minecraft:overworld",
"generator": {
"type": "minecraft:flat",
"settings": {
"biome": "minecraft:plains",
"features": false,
"lakes": false,
"layers": [
{
"block": "minecraft:bedrock",
"height": 1
},
{
"block": "minecraft:stone",
"height": 60
},
{
"block": "minecraft:dirt",
"height": 2
},
{
"block": "minecraft:grass_block",
"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"
}
}
}
}
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"id": "sanctuary_test",
"version": "${version}",
"name": "Sanctuary — Quick Test",
"description": "Optional development profile: flat worlds, real Sanctuary systems, automatically skipped introduction.",
"authors": ["KOKA99CAB"],
"license": "GPL-3.0-or-later",
"environment": "*",
"entrypoints": {
"main": ["fr.koka.sanctuarytest.QuickTestMod"],
"client": ["fr.koka.sanctuarytest.QuickTestClient"]
},
"depends": {"fabricloader": ">=${loader}", "minecraft": "${minecraft}", "java": ">=25", "fabric-api": "*", "sanctuary": "${version}"}
}
+288 -1
View File
@@ -25,6 +25,11 @@ if (clientRenderTests) {
java.srcDir('src/clientGametest/java')
}
tasks.named('runClientGameTest') {
systemProperty('sanctuary.test.noVsync', providers.gradleProperty('sanctuaryClientNoVsync').getOrElse('false'))
systemProperty('sanctuary.test.quick', providers.gradleProperty('sanctuaryQuickTests').getOrElse('false'))
systemProperty('sanctuary.test.introVisualOnly', providers.gradleProperty('sanctuaryIntroVisualOnly').getOrElse('false'))
systemProperty('sanctuary.test.atlasBaseline', providers.gradleProperty('sanctuaryAtlasBaseline').getOrElse('false'))
systemProperty('sanctuary.test.inventoryClient', providers.gradleProperty('sanctuaryInventoryClientTests').getOrElse('false'))
systemProperty('sanctuary.test.highland304', providers.gradleProperty('sanctuaryHighland304Visual').getOrElse('false'))
}
}
@@ -90,9 +95,139 @@ 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 = [aerial: 'Aerial24WorldGameTests', transit: 'Transit29WorldGameTests', village: 'Village306GameTests', drained: 'Drained307GameTests']
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']
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)
inputs.property('introClient', introClient)
if (introClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Intro031ClientChecks') }
def progressionClient = providers.gradleProperty('sanctuaryProgressionClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('progressionClient', progressionClient)
def mapPerfClient = providers.gradleProperty('sanctuaryMapPerfClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('mapPerfClient', mapPerfClient)
if (mapPerfClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Atlas026ClientChecks') }
def lightsClient = providers.gradleProperty('sanctuaryLightsClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('lightsClient', lightsClient)
if (lightsClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Dynamic030ClientChecks') }
def skillsClient = providers.gradleProperty('sanctuarySkillsClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('skillsClient', skillsClient)
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 mobHead060Client = providers.gradleProperty('sanctuaryMobHead060ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('mobHead060Client', mobHead060Client)
if (mobHead060Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.MobHead060ClientChecks') }
def atlas059Client = providers.gradleProperty('sanctuaryAtlas059ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('atlas059Client', atlas059Client)
if (atlas059Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Atlas059ClientChecks') }
def atlas058Client = providers.gradleProperty('sanctuaryAtlas058ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('atlas058Client', atlas058Client)
if (atlas058Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Atlas058ClientChecks') }
def weather057Client = providers.gradleProperty('sanctuaryWeather057ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('weather057Client', weather057Client)
if (weather057Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Weather057ClientChecks') }
inputs.property('sky056Client', sky056Client)
if (sky056Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Sky056ClientChecks') }
def strengthCarryClient = providers.gradleProperty('sanctuaryStrengthCarryClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('strengthCarryClient', strengthCarryClient)
if (strengthCarryClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Strength055ClientChecks') }
def combatClient = providers.gradleProperty('sanctuaryCombatClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('combatClient', combatClient)
if (combatClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Combat054ClientChecks') }
inputs.property('fogClient', fogClient)
if (fogClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Fog053ClientChecks') }
def starterNameClient = providers.gradleProperty('sanctuaryStarterNameClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('starterNameClient', starterNameClient)
if (starterNameClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Starter052ClientChecks') }
def head050Client = providers.gradleProperty('sanctuaryHead050ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('head050Client', head050Client)
if (head050Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Head050ClientChecks') }
def boatsHeadClient = providers.gradleProperty('sanctuaryBoatsHeadClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('boatsHeadClient', boatsHeadClient)
if (boatsHeadClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.BoatsHead049ClientChecks') }
def poultryClient = providers.gradleProperty('sanctuaryPoultryClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('poultryClient', poultryClient)
if (poultryClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Poultry048ClientChecks') }
def starInspectionClient = providers.gradleProperty('sanctuaryStarInspectionClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('starInspectionClient', starInspectionClient)
if (starInspectionClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.StarInspection047ClientChecks') }
def skyFlightClient = providers.gradleProperty('sanctuarySkyFlightClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('skyFlightClient', skyFlightClient)
if (skyFlightClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.SkyFlight046ClientChecks') }
def titleClient = providers.gradleProperty('sanctuaryTitleClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('titleClient', titleClient)
if (titleClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Title045ClientChecks') }
def headBlocksClient = providers.gradleProperty('sanctuaryHeadBlocksClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('headBlocksClient', headBlocksClient)
if (headBlocksClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.HeadBlocks043ClientChecks') }
def lanCarryClient = providers.gradleProperty('sanctuaryLanCarryClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('lanCarryClient', lanCarryClient)
if (lanCarryClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.LanCarry042ClientChecks') }
def operatorClient = providers.gradleProperty('sanctuaryOperatorClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('operatorClient', operatorClient)
if (operatorClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Operator040ClientChecks') }
def foodKnowledgeClient = providers.gradleProperty('sanctuaryFoodKnowledgeClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('foodKnowledgeClient', foodKnowledgeClient)
if (foodKnowledgeClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.FoodPanorama039ClientChecks') }
def gravesClient = providers.gradleProperty('sanctuaryGravesClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('gravesClient', gravesClient)
if (gravesClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.GravesFood038ClientChecks') }
def realtimeClient = providers.gradleProperty('sanctuaryRealtimeClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('realtimeClient', realtimeClient)
if (realtimeClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Realtime037ClientChecks') }
def blueprintClient = providers.gradleProperty('sanctuaryBlueprintClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('blueprintClient', blueprintClient)
if (blueprintClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Blueprint036ClientChecks') }
def correctionsClient = providers.gradleProperty('sanctuaryCorrectionsClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('correctionsClient', correctionsClient)
if (correctionsClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Corrections036ClientChecks') }
def collectionsClient = providers.gradleProperty('sanctuaryCollectionsClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('collectionsClient', collectionsClient)
if (collectionsClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Collections035ClientChecks') }
def notificationsClient = providers.gradleProperty('sanctuaryNotificationsClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('notificationsClient', notificationsClient)
if (notificationsClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Notifications034ClientChecks') }
def menusClient = providers.gradleProperty('sanctuaryMenusClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('menusClient', menusClient)
if (menusClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Menus033ClientChecks') }
def recipesClient = providers.gradleProperty('sanctuaryRecipesClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('recipesClient', recipesClient)
if (recipesClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Recipes024ClientChecks') }
def cosmeticsClient = providers.gradleProperty('sanctuaryHeadCosmeticsClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('cosmeticsClient', cosmeticsClient)
if (cosmeticsClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.HeadCosmetic021ClientChecks') }
def familiarClient = providers.gradleProperty('sanctuaryFamiliarClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('familiarClient', familiarClient)
if (familiarClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Familiar020ClientChecks') }
def refonteClient = providers.gradleProperty('sanctuaryRefonteClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('refonteClient', refonteClient)
if (refonteClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Companion032ClientChecks') }
def companionsClient = providers.gradleProperty('sanctuaryCompanionsClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('companionsClient', companionsClient)
if (companionsClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Companion019ClientChecks') }
def accessoriesClient = providers.gradleProperty('sanctuaryAccessoriesClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('accessoriesClient', accessoriesClient)
if (accessoriesClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Accessory018ClientChecks') }
def movementClient = providers.gradleProperty('sanctuaryMovementClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('movementClient', movementClient)
if (movementClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Movement017ClientChecks') }
def tutorialClient = providers.gradleProperty('sanctuaryTutorialClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('tutorialClient', tutorialClient)
if (tutorialClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Tutorial016ClientChecks') }
def sortingClient = providers.gradleProperty('sanctuarySortingClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sortingClient', sortingClient)
if (sortingClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Inventory014ClientChecks') }
def inlineClient = providers.gradleProperty('sanctuaryInlineClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('inlineClient', inlineClient)
if (inlineClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Progression013ClientChecks') }
def inventoryClient = providers.gradleProperty('sanctuaryInventoryClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('inventoryClient', inventoryClient)
def cycleClient = providers.gradleProperty('sanctuaryCycleClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('cycleClient', cycleClient)
if (cycleClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Cycle011ClientChecks') }
if (inventoryClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Inventory010ClientChecks') }
if (progressionClient) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.gametest.Progression003ClientChecks') }
inputs.property('villageOnly', villageOnly)
def atlasOnly = providers.gradleProperty('sanctuaryAtlasOnly').map { it.toBoolean() }.getOrElse(false)
@@ -153,6 +288,10 @@ tasks.named('processGametestResources') {
}
dependencies {
implementation project(':jei')
include project(':jei')
implementation project(':demeure')
include project(':demeure')
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}"
@@ -609,6 +748,14 @@ tasks.register('expansionOcean23Smoke', JavaExec) {
}
tasks.named('check') { dependsOn('expansionOcean23Smoke') }
tasks.register('ancientExpeditions001Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.expansion.AncientExpeditions001Smoke'
}
tasks.named('check') { dependsOn('ancientExpeditions001Smoke') }
[
secrets23Smoke: 'fr.koka.sanctuary.worldgen.mineral.Secrets23Smoke',
structureCensusSmoke: 'fr.koka.sanctuary.knowledge.structures.StructureCensusSmoke',
@@ -703,3 +850,143 @@ tasks.register('balancedRelief303Smoke', JavaExec) {
mainClass = 'fr.koka.sanctuary.worldgen.BalancedRelief303Smoke'
}
tasks.named('check') { dependsOn('balancedRelief303Smoke') }
// PROG-01: pure contract checks plus native server/player tests.
tasks.register('progression003Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.progression.ProgressionSmoke'
}
tasks.named('check') { dependsOn('progression003Smoke') }
// MAP-01: durable observation and progression migration contracts.
tasks.register('atlas005Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.atlas.AtlasSmoke'
}
tasks.named('check') { dependsOn('atlas005Smoke') }
// CYCLE-01 / FACTION-01: durable charge and reset contract.
tasks.register('cycle011Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.cycle.CycleSmoke'
}
tasks.named('check') { dependsOn('cycle011Smoke') }
// First tutorial actions: bounded rewards and independent durable claims.
tasks.register('tutorial016Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.tutorial.TutorialSmoke'
}
tasks.named('check') { dependsOn('tutorial016Smoke') }
tasks.register('movement017Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.progression.Movement017Smoke'
}
tasks.named('check') { dependsOn('movement017Smoke') }
tasks.register('inventoryDrag018Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.client.InventoryDragPathSmoke'
}
tasks.named('check') { dependsOn('inventoryDrag018Smoke') }
tasks.register('companion019Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.companion.Companion019Smoke'
}
tasks.named('check') { dependsOn('companion019Smoke') }
// Visual-light spatial index, dirty budget and dropped-item stress checks.
tasks.register('dynamic030Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.lighting.Dynamic030Smoke'
}
tasks.named('check') { dependsOn('dynamic030Smoke') }
// INTRO-01: reversible UUID encoding, accelerating score and durable first arrival.
tasks.register('intro031Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.progression.Intro031Smoke'
}
tasks.named('check') { dependsOn('intro031Smoke') }
if (providers.gradleProperty('sanctuaryQuickTests').map { it.toBoolean() }.getOrElse(false)) {
dependencies { gametestRuntimeOnly project(':sanctuary-test') }
}
tasks.register('notifications034Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.notifications.NotificationFeedSmoke'
}
tasks.named('check') { dependsOn('notifications034Smoke') }
tasks.register('weather057Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.weather.Weather057Smoke'
}
tasks.named('check') { dependsOn('weather057Smoke') }
tasks.register('collections035Catalogue', Exec) {
group = 'verification'
workingDir rootProject.projectDir
commandLine 'python3', 'scripts/compile_collections.py'
}
tasks.named('check') { dependsOn('collections035Catalogue') }
tasks.register('notifications036Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.notifications.NotificationLayoutSmoke'
}
tasks.named('check') { dependsOn('notifications036Smoke') }
tasks.register('realtime037Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.realtime.Realtime037Smoke'
}
tasks.named('check') { dependsOn('realtime037Smoke') }
tasks.register('poultry048Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.boats.FlightBalance048Smoke'
}
tasks.named('check') { dependsOn('poultry048Smoke') }
// Personal map markers: durable discoveries, independent identities and bounded data.
tasks.register('atlas059Smoke', JavaExec) {
group = 'verification'
dependsOn('testClasses')
classpath = sourceSets.test.runtimeClasspath
mainClass = 'fr.koka.sanctuary.atlas.AtlasMarker059Smoke'
}
tasks.named('check') { dependsOn('atlas059Smoke') }
@@ -0,0 +1,138 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.progression.*;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
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.networking.v1.ClientConfigurationNetworking;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
public final class Intro031ClientChecks implements FabricClientGameTest {
private static final KeyEvent ENTER=new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);
public void runTest(ClientGameTestContext c) {
UUID testUuid=UUID.fromString("01234567-89ab-cdef-fedc-ba9876543210");
Screen 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 clock=new AtomicLong();var completed=new AtomicInteger();
c.runOnClient(m -> m.gui.setScreen(new IntroScreen(testUuid,skipped -> completed.incrementAndGet(),clock::get)));
c.waitTick();c.takeScreenshot("sanctuary-beta031-intro-00-black");
c.runOnClient(m -> m.gui.screen().onClose());
require(completed.get()==0,"Opening cannot be skipped accidentally");
for (int seconds:new int[]{3,7,13,20,23,25,27,29}) {
clock.set(seconds*1_000_000_000L);c.waitTicks(2);
c.takeScreenshot("sanctuary-beta031-intro-"+seconds);
}
clock.set(24_800_000_000L);
c.runOnClient(m -> { m.options.guiScale().set(0);m.resizeGui(); });
c.waitTick();c.takeScreenshot("sanctuary-beta031-intro-small");
c.runOnClient(m -> {
m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
});
c.waitTick();c.takeScreenshot("sanctuary-beta031-intro-fr");
clock.set(29_600_000_000L);c.waitTicks(3);
require(completed.get()==1,"Completion exactly once despite extra ticks");
c.runOnClient(m -> m.gui.screen().onClose());require(completed.get()==1,"Skip cannot double-send completion");
c.runOnClient(m -> {m.gui.setScreen(previous);m.options.guiScale().set(scale);m.resizeGui();});
// Removing a screen (disconnect/replacement) must clean up without accepting the connection.
c.runOnClient(m -> m.gui.setScreen(new IntroScreen(testUuid,skipped -> completed.incrementAndGet())));
c.waitTicks(2);c.runOnClient(m -> m.gui.setScreen(previous));
require(completed.get()==1,"Unexpected removal never acknowledges arrival");
if(Boolean.getBoolean("sanctuary.test.introVisualOnly")) {
c.runOnClient(m -> { m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager()); });
System.out.println("INTRO031_VISUAL_PASS: spectral glyphs, circular spiral, expanding portal, white frame, resize");
return;
}
var armed=new AtomicBoolean(true);var submitted=new AtomicBoolean();var observed=new AtomicBoolean();
var sawWorldUnderFlash=new AtomicBoolean();
var count=new AtomicInteger();var introRef=new java.util.concurrent.atomic.AtomicReference<IntroScreen>();
ClientTickEvents.END_CLIENT_TICK.register(m -> {
if(!armed.get())return;
if(m.player!=null && IntroSpawnFlash.active()) sawWorldUnderFlash.set(true);
if(m.gui.screen() instanceof HelloWorldScreen screen && submitted.compareAndSet(false,true)) {
require(m.player==null && m.getSingleplayerServer().getPlayerCount()==0,"Form before spawn");
// Early acknowledgements cannot bypass character creation.
ClientConfigurationNetworking.send(new ProgressionPayloads.IntroFinished());
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);
}
if(m.gui.screen() instanceof IntroScreen screen && !screen.isFinished()) {
if(introRef.getAndSet(screen)!=screen)count.incrementAndGet();
require(m.player==null && m.level==null,"No client world or player during the cinematic");
require(m.getSingleplayerServer().getPlayerCount()==0,"No server player during the cinematic");
if(observed.compareAndSet(false,true)) {
require(screen.uuid().equals(m.getGameProfile().id()),"Server UUID reaches the real screen");
m.getSingleplayerServer().execute(() -> {
var identity=ProgressionService.session(m.getSingleplayerServer()).inhabitants.get(screen.uuid());
require(identity!=null && !identity.arrived(),"Validated identity precedes hello announcement");
});
// A repeated form packet while waiting must not reopen the form or duplicate the identity.
ClientConfigurationNetworking.send(new ProgressionPayloads.Create("duplicate",-1));
}
}
});
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave save;
try {
try(var world=c.worldBuilder().adjustSettings(settings -> {
settings.setName("Sanctuary intro031 "+UUID.randomUUID());settings.setSeed("42");
settings.setWorldType(settings.getNormalPresetList().stream()
.filter(e -> e.preset()!=null && e.preset().getRegisteredName().equals("sanctuary:sanctuary"))
.findFirst().orElseThrow());
settings.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
}).create()) {
save=world.getWorldSave();world.getConnection().waitForChunksDownload(2400);
c.waitFor(m -> m.player!=null && m.gui.screen()==null && ProgressionClient.active(),1200);
require(observed.get() && count.get()==1,"Real first arrival plays one complete cinematic");
require(sawWorldUnderFlash.get(),"White exposure bridges the real spawn");
c.waitFor(m -> !IntroSpawnFlash.active(),400);
c.takeScreenshot("sanctuary-beta031-spawn-after-white");
world.getServer().runOnServer(server -> {
var player=server.getPlayerList().getPlayers().getFirst();
require(ProgressionService.session(server).inhabitants.get(player.getUUID()).arrived(),"Join consumes first arrival");
require(!ProgressionService.session(server).inhabitants.get(player.getUUID()).bio().equals("duplicate"),"Repeated form ignored");
server.saveEverything(true,true,true);
});
}
try(var world=save.open()) {
world.getConnection().waitForChunksDownload(2400);
c.waitFor(m -> m.player!=null && ProgressionClient.active(),1200);
require(count.get()==1,"Reconnection bypasses cinematic");
c.waitFor(m -> m.gui.screen()==null,1200);
c.runOnClient(m -> require(!(m.gui.screen() instanceof IntroScreen),"Existing inhabitant goes directly to play"));
armed.set(false);
c.runOnClient(m -> {
m.gui.setScreen(new net.minecraft.client.gui.screens.ChatScreen("/sanctuary intro",false));
m.gui.screen().keyPressed(ENTER);
});
c.waitForScreen(IntroScreen.class);
c.runOnClient(m -> {
require(((IntroScreen)m.gui.screen()).uuid().equals(m.player.getUUID()),"Chat command replays the current UUID");
require(!m.gui.screen().isPauseScreen(),"The audio score also advances in replay");
});
c.waitFor(m -> m.gui.screen()==null,1200);
c.waitFor(m -> !IntroSpawnFlash.active(),400);
c.runOnClient(m -> require(ProgressionClient.active(),"Preview returns to play without changing identity"));
}
} finally {
armed.set(false);
c.runOnClient(m -> {
m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
m.options.guiScale().set(scale);m.resizeGui();
});
}
System.out.println("INTRO031_CLIENT_PASS: renders, resize, finish, removal, pre-spawn handshake, duplicates, reconnect");
}
private static void require(boolean value,String message) { if(!value)throw new AssertionError(message); }
}
@@ -0,0 +1,148 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor;
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.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.inventory.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.entity.Pose;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.Vec3;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
public final class Accessory018ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var armed=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m -> {
if(armed.get() && m.gui.screen() instanceof HelloWorldScreen 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());armed.set(false);
}
});
int scale=c.computeOnClient(m -> m.options.guiScale().get());var camera=c.computeOnClient(m -> m.options.getCameraType());
String language=c.computeOnClient(m -> m.getLanguageManager().getSelected());
var postureKey=c.computeOnClient(m -> InputConstants.getKey(KeyMapping.get("sanctuary.progression.lying_key").saveString()));
var forward=c.computeOnClient(m -> InputConstants.getKey(m.options.keyUp.saveString()));
int letterW=c.computeOnClient(m -> KeyMapping.get("sanctuary.progression.lying_key").getDefaultKey().getValue());
try(var world=c.worldBuilder().adjustSettings(s -> {
s.setName("Sanctuary beta018 accessories "+UUID.randomUUID());
s.setWorldType(s.getNormalPresetList().stream().filter(e -> e.preset()!=null && e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());
s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
}).create()) {
world.getConnection().waitForChunksDownload(2400);
c.waitFor(m -> m.player!=null && ProgressionClient.active() && m.gui.screen()==null,1200);
var server=world.getServer();
BlockPos floor=server.computeOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();var at=p.blockPosition().above(3);
for(int x=-10;x<=10;x++)for(int z=-10;z<=10;z++)for(int y=-1;y<5;y++)p.level().setBlockAndUpdate(at.offset(x,y,z),(y==-1 ? Blocks.STONE : Blocks.AIR).defaultBlockState());
p.teleportTo(at.getX()+.5,at.getY(),at.getZ()+.5);p.getInventory().clearContent();
p.getInventory().setItem(0,new ItemStack(Items.WOLF_SPAWN_EGG,3));p.getInventory().setItem(1,new ItemStack(AccessoryService.ZERO_CAPE));p.getInventory().setItem(2,new ItemStack(Items.PISTON,10));
p.inventoryMenu.broadcastChanges();return at;
});
c.waitFor(m -> m.player.onGround() && m.player.getInventory().getItem(0).is(Items.WOLF_SPAWN_EGG),200);
c.runOnClient(m -> {m.options.guiScale().set(2);m.resizeGui();m.options.keyUp.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_UP));KeyMapping.get("sanctuary.progression.lying_key").setKey(InputConstants.Type.KEYBOARD.getOrCreate(letterW));KeyMapping.resetMapping();m.gui.setScreen(new InventoryScreen(m.player));});c.waitTick();
c.runOnClient(m -> {
require(m.player.inventoryMenu.slots.size()==67,"Client receives three actual equipment slots after overflow");
require(SanctuaryInventoryClient.header().getString().equals(net.minecraft.network.chat.Component.translatable("container.inventory").getString()),"Inventory heading has no rows suffix");
for(int i=64;i<67;i++)require(m.player.inventoryMenu.getSlot(i).x==78 && m.player.inventoryMenu.getSlot(i).y==24+18*(66-i),"Visual order: head, cape, egg; storage indices are unchanged");
require(m.player.inventoryMenu.getSlot(45).x==78 && m.player.inventoryMenu.getSlot(45).y==78,"Offhand is fourth and last");
});c.takeScreenshot("sanctuary-beta018-empty-slots");
click(c,36,true);click(c,37,true);
c.waitFor(m -> AccessoryAccess.of(m.player).getItem(0).is(Items.WOLF_SPAWN_EGG) && AccessoryAccess.of(m.player).getItem(1).is(AccessoryService.ZERO_CAPE),200);
click(c,38,false);click(c,66,false);click(c,38,false);
c.waitFor(m -> AccessoryAccess.of(m.player).getItem(2).is(Items.PISTON) && m.player.inventoryMenu.getCarried().isEmpty(),200);
c.waitFor(m -> m.player.getAttachedOrElse(AccessoryService.APPEARANCE,AccessoryService.Appearance.EMPTY).head().is(Items.PISTON),200);
c.runOnClient(m -> {
var state=avatar(m);var head=((HeadCosmeticState.Access)state).sanctuary$headCosmetic();
require(!head.block.isEmpty() && head.pistonHead.isEmpty(),"Piston uses block model in resting state");
require(state.skin.cape()!=null && state.skin.cape().texturePath().toString().contains("zero_cape"),"Cape attachment selects native cape texture");
});c.takeScreenshot("sanctuary-beta018-equipped-slots");
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();p.setInvulnerableTime(0);p.hurtServer(p.level(),p.damageSources().generic(),1);});
c.waitFor(m -> m.player.hurtTime>0 && !((HeadCosmeticState.Access)avatar(m)).sanctuary$headCosmetic().pistonHead.isEmpty(),100);
for(Item cosmetic:List.of(Items.DIAMOND_SWORD,Items.PLAYER_HEAD,Items.CHEST,Items.PISTON)) {
server.runOnServer(s -> AccessoryAccess.of(s.getPlayerList().getPlayers().getFirst()).setItem(2,new ItemStack(cosmetic)));
c.waitFor(m -> m.player.getAttachedOrElse(AccessoryService.APPEARANCE,AccessoryService.Appearance.EMPTY).head().is(cosmetic),200);
c.runOnClient(m -> {var head=((HeadCosmeticState.Access)avatar(m)).sanctuary$headCosmetic();require(!head.item.isEmpty() || !head.block.isEmpty(),"Renderable cosmetic: "+cosmetic);});
}
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();var r=ProgressEffects.get(p);for(int i=0;i<5;i++)r=r.buy("inventory",1);p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);ProgressionService.send(p,"");});
c.waitFor(m -> ProgressionClient.progress.inventoryRows()==6,200);
for(String locale:List.of("en_us","fr_fr")) {
c.runOnClient(m -> {m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new InventoryScreen(m.player));});c.waitTick();
c.takeScreenshot("sanctuary-beta018-six-rows-"+locale);
}
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitForScreen(null);
c.waitFor(m -> m.level.entitiesForRendering().iterator().hasNext() && clientPet(m)!=null,200);
var before=server.computeOnServer(s -> FamiliarService.active(s.getPlayerList().getPlayers().getFirst()).position());
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(floor.getX()+6.5,floor.getY(),floor.getZ()+.5);});
c.waitTicks(30);
require(server.computeOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);return pet!=null && pet.position().distanceToSqr(before)>.1 && pet.distanceToSqr(p)<20;}),"Familiar really walks toward moving owner");
for(Item egg:List.of(Items.WOLF_SPAWN_EGG,Items.COW_SPAWN_EGG,Items.PARROT_SPAWN_EGG,Items.CREEPER_SPAWN_EGG,Items.COD_SPAWN_EGG,Items.GHAST_SPAWN_EGG)) {
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();AccessoryAccess.of(p).setItem(0,new ItemStack(egg));});
c.waitFor(m -> clientPet(m)!=null && clientPet(m).egg().is(egg),200);
c.runOnClient(m -> {var state=(FamiliarRenderer.State)m.getEntityRenderDispatcher().extractEntity(clientPet(m),0);require(state.renderer!=null && state.appearance!=null && state.size>0 && state.size<=.65,"Native appearance resolves for "+egg);});
}
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();AccessoryAccess.of(p).setItem(0,new ItemStack(Items.WOLF_SPAWN_EGG));});
c.waitFor(m -> clientPet(m)!=null && clientPet(m).egg().is(Items.WOLF_SPAWN_EGG),200);
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);pet.teleportNearOwner();});c.waitTicks(5);
int particles=c.computeOnClient(m -> particleCount(m));
c.runOnClient(m -> {var pet=clientPet(m);m.gameMode.interact(m.player,pet,new net.minecraft.world.phys.EntityHitResult(pet,pet.position().add(0,.4,0)),net.minecraft.world.InteractionHand.MAIN_HAND);});
c.waitFor(m -> particleCount(m)>particles,100);
c.runOnClient(m -> {m.options.setCameraType(CameraType.THIRD_PERSON_BACK);m.player.setXRot(15);});c.waitTicks(3);c.takeScreenshot("sanctuary-beta018-cape-and-companion");
c.getInput().pressKey(letterW);c.waitFor(m -> LyingService.posture(m.player)==1 && m.player.getPose()==Pose.SWIMMING,200);
c.runOnClient(m -> require(avatar(m).pose==Pose.SWIMMING && !ProgressionClient.progress.lying(),"Free W gesture uses mobile low pose without purchase"));
c.waitTicks(15);c.takeScreenshot("sanctuary-beta018-free-lying");c.getInput().pressKey(letterW);c.waitFor(m -> LyingService.posture(m.player)==0,200);
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();p.setExperienceLevels(4);ProgressionService.send(p,"");});
c.waitFor(m -> m.player.experienceLevel==4,200);
c.runOnClient(m -> {m.options.guiScale().set(1);m.resizeGui();ProgressionClient.open(null);});c.waitTick();
c.runOnClient(m -> {
require(widgets(m.gui.screen()).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).noneMatch(w -> key(w,"sanctuary.progression.lying")),"Free gesture is absent from paid aptitudes");
var name=widgets(m.gui.screen()).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w -> key(w,"sanctuary.progression.resting")).findFirst().orElseThrow();
widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).filter(b -> b.getX()==name.getX() && b.getY()>name.getY()).min(Comparator.comparingInt(Button::getY)).orElseThrow().onPress(enter());
});c.waitFor(m -> ProgressionClient.progress.resting() && m.player.experienceLevel==0,200);
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitForScreen(null);c.waitTicks(3);
c.getInput().holdKey(o -> o.keyShift);c.getInput().pressKey(letterW);c.getInput().releaseKey(o -> o.keyShift);
c.waitFor(m -> LyingService.posture(m.player)==2,200);
c.runOnClient(m -> {var state=avatar(m);require(state.pose==Pose.SLEEPING && state.bedOrientation!=null && state.swimAmount==0 && !m.player.isSleeping(),"Rest uses bed-sleep animation without initiating server sleep");});
c.waitTicks(15);c.takeScreenshot("sanctuary-beta018-rest-sleep-animation");
c.getInput().holdKey(o -> o.keyUp);c.waitFor(m -> LyingService.posture(m.player)==0,200);c.getInput().releaseKey(o -> o.keyUp);
InventoryControls018Checks.run(c,server);
GroupWork018Checks.run(c,server);
System.out.println("Accessory018 native client passed: exact slots, native clicks, synchronized cape/piston, 1/6 rows FR/EN, living follower, six mob renderers, free W and paid bed-sleep rest");
} finally {
armed.set(false);c.getInput().releaseKey(o -> o.keyUp);c.getInput().releaseKey(o -> o.keyShift);
c.runOnClient(m -> {m.options.keyUp.setKey(forward);KeyMapping.get("sanctuary.progression.lying_key").setKey(postureKey);KeyMapping.resetMapping();m.options.setCameraType(camera);m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(scale);m.resizeGui();});
}
}
private static int particleCount(Minecraft m) {String count=m.particleEngine.countParticles();return Integer.parseInt(count.substring(count.lastIndexOf(' ')+1));}
private static FamiliarEntity clientPet(Minecraft m) {for(var e:m.level.entitiesForRendering())if(e instanceof FamiliarEntity pet && !pet.isRemoved())return pet;return null;}
private static AvatarRenderState avatar(Minecraft m) {return (AvatarRenderState)m.getEntityRenderDispatcher().extractEntity(m.player,0);}
private static void click(ClientGameTestContext c,int index,boolean shift) {
c.runOnClient(m -> {var screen=(AbstractContainerScreen<?>)m.gui.screen();var a=(AbstractContainerScreenAccessor)screen;var slot=screen.getMenu().getSlot(index);var w=m.getWindow();
m.mouseHandler.onMove(w.handle(),(a.sanctuary$getLeftPos()+slot.x+8.0)*w.getScreenWidth()/w.getGuiScaledWidth(),(a.sanctuary$getTopPos()+slot.y+8.0)*w.getScreenHeight()/w.getGuiScaledHeight(),0,0);m.mouseHandler.handleAccumulatedMovement();
});c.waitTick();if(shift)c.getInput().holdKey(o -> o.keyShift);
c.runOnClient(m -> {var w=m.getWindow();m.mouseHandler.onButton(w.handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,shift ? InputConstants.MOD_SHIFT : 0),1);m.mouseHandler.onButton(w.handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,shift ? InputConstants.MOD_SHIFT : 0),0);});
if(shift)c.getInput().releaseKey(o -> o.keyShift);c.waitTicks(2);
}
private static Stream<GuiEventListener> widgets(GuiEventListener widget) {return Stream.concat(Stream.of(widget),widget instanceof ContainerEventHandler p ? p.children().stream().flatMap(Accessory018ClientChecks::widgets) : Stream.empty());}
private static boolean key(AbstractWidget widget,String key) {return widget.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key);}
private static Button button(Screen screen,String key) {return widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(b -> key(b,key)).findFirst().orElseThrow();}
private static KeyEvent enter() {return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean ok,String message) {if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,188 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.atlas.AtlasService;
import fr.koka.sanctuary.client.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.events.ContainerEventHandler;
import net.minecraft.client.gui.components.events.GuiEventListener;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.input.MouseButtonInfo;
import net.minecraft.client.server.IntegratedServer;
import net.minecraft.network.chat.contents.TranslatableContents;
/** Real packets, operator transitions and map interaction, inside the existing progression scenario. */
final class Atlas005ClientChecks {
static void run(ClientGameTestContext context,TestServerContext server) {
server.runOnServer(s -> {
((IntegratedServer)s).setWorldAllowCommands(false);
var player=s.getPlayerList().getPlayers().getFirst();
fr.koka.demeure.DemeureService.record(player,player.blockPosition(),2);
});
int[] transparencySample=server.computeOnServer(s -> {
var player=s.getPlayerList().getPlayers().getFirst();var chunk=player.chunkPosition();
int x=chunk.x()+4,z=chunk.z();
// Deterministic network fixture outside the passive survey radius; no world block is changed.
byte[] colors=new byte[256];java.util.Arrays.fill(colors,(byte)1);
colors[0]=0;colors[2]=net.minecraft.world.level.material.MapColor.GOLD.getPackedId(net.minecraft.world.level.material.MapColor.Brightness.NORMAL);
AtlasService.observations(player).observe(new fr.koka.sanctuary.atlas.AtlasStore.Tile(x,z,colors));
return new int[]{x*16,z*16};
});
context.runOnClient(c -> ProgressionClient.open(null,"history"));
context.waitForScreen(SanctuaryAtlasScreen.class);
context.waitFor(c -> c.gui.screen() instanceof SanctuaryAtlasScreen a && a.status().equals("locked"),400);
context.runOnClient(c -> require(((SanctuaryAtlasScreen)c.gui.screen()).observedTiles()==0,"Locked map sends no terrain to the real client"));
server.runOnServer(s -> require(AtlasService.observations(s.getPlayerList().getPlayers().getFirst()).size()>0,"Surveys predate the map purchase"));
context.takeScreenshot("sanctuary-beta007-map-locked");
context.runOnClient(c -> ProgressionClient.open(null,"progress"));
context.runOnClient(c -> descendants(c.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals("sanctuary.progression.unlock_cost"))
.findFirst().orElseThrow().onPress(new KeyEvent(InputConstants.KEY_RETURN,0,0)));
context.waitFor(c -> ProgressionClient.progress.atlas(),400);
context.runOnClient(c -> require(c.player.experienceLevel==1,"Real atlas purchase costs exactly four levels"));
context.runOnClient(c -> ProgressionClient.open(null,"history"));
context.waitFor(c -> c.gui.screen() instanceof SanctuaryAtlasScreen a && a.observedTiles()>0 && a.isComplete(),600);
context.waitTicks(3);context.takeScreenshot("sanctuary-beta007-map-inhabitant");
context.waitFor(c -> ((SanctuaryAtlasScreen)c.gui.screen()).footprintCount()>0,400);
context.runOnClient(c -> {
var map=(SanctuaryAtlasScreen)c.gui.screen();
require(map.knownPixels()<map.observedTiles()*256,"Real server packets contain partially explored chunks");
int sx=transparencySample[0],sz=transparencySample[1];
require(map.renderedPixelAt(sx,sz)==0 && map.renderedPixelAt(sx+1,sz)==0,
"Unknown and surveyed void are transparent in the actual viewport texture");
require((map.renderedPixelAt(sx+2,sz)>>>24)==255,"Surveyed terrain retains its opaque native map color");
double oldZoom=map.zoom();move(c,map.width/2.,map.height/2.);
c.mouseHandler.onScroll(c.getWindow().handle(),0,1);
require(map.zoom()>oldZoom,"Native mouse wheel zooms map");
drag(c,40,0,false);drag(c,-30,15,true);
map.recenter(); // A new request must not lock subsequent navigation.
drag(c,20,0,false);
});
context.waitTicks(12);
context.runOnClient(c -> {drag(c,-20,0,false);((SanctuaryAtlasScreen)c.gui.screen()).recenter();});
context.waitFor(c -> ((SanctuaryAtlasScreen)c.gui.screen()).isComplete(),600);
nativeClick(context,"sanctuary.atlas.grid_off");nativeClick(context,"sanctuary.atlas.grid_on");
context.runOnClient(c -> {
var map=(SanctuaryAtlasScreen)c.gui.screen();
dragFrom(c,2,map.height/2.,25,0); // Dark left edge outside the old frame.
dragFrom(c,map.width-2,map.height/2.,-25,0);
dragFrom(c,2,2,20,10); // Behind the header, away from widgets.
dragFrom(c,2,map.height-2,20,-10); // Behind the footer.
});
nativeClick(context,"sanctuary.atlas.recenter");
context.runOnClient(c -> {
var map=(SanctuaryAtlasScreen)c.gui.screen();
require(Math.abs(map.centerX()-c.player.getX())<.01,"Overlay button receives the click before the full-screen map");
var mode=button(c.gui.screen(),"sanctuary.atlas.inhabitant");
double oldX=map.centerX(),oldZoom=map.zoom();
move(c,mode.getX()+4,mode.getY()+4);
c.mouseHandler.onScroll(c.getWindow().handle(),0,1);
require(map.zoom()==oldZoom,"Wheel over a button does not zoom the terrain underneath");
press(c,true);move(c,mode.getX()+8,mode.getY()+8);press(c,false);
require(map.centerX()==oldX,"Disabled operator button cannot start map dragging");
});
context.runOnClient(c -> move(c,c.gui.screen().width/2.,c.gui.screen().height/2.+12));
context.waitTicks(3);context.takeScreenshot("sanctuary-beta007-demeure-fog");
int oldWidth=context.computeOnClient(c -> c.getWindow().getScreenWidth()),oldHeight=context.computeOnClient(c -> c.getWindow().getScreenHeight());
int oldScale=context.computeOnClient(c -> c.options.guiScale().get());
for (int[] size:new int[][]{{640,480,0},{1280,720,0},{1920,1080,1},{1920,1080,3}}) {
context.runOnClient(c -> {c.getWindow().setWindowed(size[0],size[1]);c.options.guiScale().set(size[2]);c.resizeGui();});
context.waitFor(c -> c.gui.screen() instanceof SanctuaryAtlasScreen a && a.isComplete() && a.observedTiles()>0,600);
context.waitTicks(3);
context.runOnClient(c -> {
Blocodex004ClientChecks.frame(c.gui.screen(),"history");
var widgets=c.gui.screen().children().stream().filter(Button.class::isInstance).map(Button.class::cast).toList();
for (int i=0;i<widgets.size();i++) for (int j=i+1;j<widgets.size();j++) {
var a=widgets.get(i);var b=widgets.get(j);
require(a.getRight()<=b.getX() || b.getRight()<=a.getX() || a.getBottom()<=b.getY() || b.getBottom()<=a.getY(),
"Floating controls never overlap across GUI scales");
}
dragFrom(c,2,c.gui.screen().height/2.,10,0);
((SanctuaryAtlasScreen)c.gui.screen()).recenter();
move(c,c.gui.screen().width-2,c.gui.screen().height-2);
});
context.waitTicks(14);
context.takeScreenshot("sanctuary-beta007-map-gui-"+size[0]+"-scale"+size[2]);
}
context.runOnClient(c -> {c.getWindow().setWindowed(oldWidth,oldHeight);c.options.guiScale().set(oldScale);c.resizeGui();});
context.waitTicks(5);
Blocodex004ClientChecks.language(context,"fr_fr");
context.waitTicks(3);context.takeScreenshot("sanctuary-beta007-carte-fr");
Blocodex004ClientChecks.language(context,"en_us");
int personal=server.computeOnServer(s -> AtlasService.observations(s.getPlayerList().getPlayers().getFirst()).size());
server.runOnServer(s -> {((IntegratedServer)s).setWorldAllowCommands(true);require(AtlasService.canOperate(s.getPlayerList().getPlayers().getFirst()),"Allow commands grants actual operator inspection");});
context.runOnClient(c -> ((SanctuaryAtlasScreen)c.gui.screen()).recenter());
context.waitFor(c -> descendants(c.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast)
.anyMatch(b -> b.active && b.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals("sanctuary.atlas.inhabitant")),400);
nativeClick(context,"sanctuary.atlas.inhabitant");
try {
context.waitFor(c -> c.gui.screen() instanceof SanctuaryAtlasScreen a && a.observedTiles()>personal && a.isComplete(),1600);
} catch (AssertionError error) {
context.takeScreenshot("sanctuary-beta007-operator-timeout");
context.runOnClient(c -> {if (!(c.gui.screen() instanceof SanctuaryAtlasScreen a)) {System.out.println("Unexpected screen: "+c.gui.screen());return;}System.out.println("Atlas timeout: status="+a.status()+" tiles="+a.observedTiles()+" complete="+a.isComplete()+" personal="+personal);});
server.runOnServer(s -> System.out.println("Atlas timeout server: loaded="+fr.koka.sanctuary.knowledge.census.LoadedChunks.full(s.overworld()).size()+" op="+AtlasService.canOperate(s.getPlayerList().getPlayers().getFirst())));
throw error;
}
context.waitTicks(3);context.takeScreenshot("sanctuary-beta007-map-operator");
server.runOnServer(s -> {
require(AtlasService.observations(s.getPlayerList().getPlayers().getFirst()).size()==personal,"Inspecting the loaded world does not add personal discoveries");
((IntegratedServer)s).setWorldAllowCommands(false);
});
context.runOnClient(c -> ((SanctuaryAtlasScreen)c.gui.screen()).recenter());
context.waitFor(c -> c.gui.screen() instanceof SanctuaryAtlasScreen a && a.status().equals("denied") && a.observedTiles()==0,400);
nativeClick(context,"sanctuary.atlas.operator");
context.waitFor(c -> c.gui.screen() instanceof SanctuaryAtlasScreen a && a.observedTiles()>0 && a.isComplete(),600);
context.runOnClient(c -> require(((SanctuaryAtlasScreen)c.gui.screen()).observedTiles()<=personal,"Returning from operator mode discards operator terrain"));
nativeClick(context,"gui.done");context.waitForScreen(null);
context.getInput().pressKey(InputConstants.KEY_B);context.waitForScreen(ProgressionScreen.class);
context.runOnClient(c -> ProgressionClient.open(null,"progress"));
System.out.println("Atlas beta007 native client passed: locked/pre-recorded, purchase, zoom/pan, GUI sizes, explicit operator mode, revocation and isolation.");
}
private static Button button(net.minecraft.client.gui.screens.Screen screen,String key) {
return descendants(screen).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 nativeClick(ClientGameTestContext context,String key) {
context.runOnClient(c -> {
var b=button(c.gui.screen(),key);require(b.active,"Native button is active: "+key);
move(c,b.getX()+b.getWidth()/2.,b.getY()+b.getHeight()/2.);press(c,true);press(c,false);
});
}
private static void press(net.minecraft.client.Minecraft c,boolean down) {
c.mouseHandler.onButton(c.getWindow().handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,0),down ? InputConstants.PRESS : InputConstants.RELEASE);
}
private static void dragFrom(net.minecraft.client.Minecraft c,double x,double y,double dx,double dy) {
var map=(SanctuaryAtlasScreen)c.gui.screen();move(c,x,y);move(c,x,y);press(c,true);
double before=map.centerX();move(c,x+dx,y+dy);press(c,false);
require(Math.abs(map.centerX()-(before-dx/map.zoom()))<.01,"Map can be dragged in the dark screen margins");
}
private static void move(net.minecraft.client.Minecraft c,double x,double y) {
var window=c.getWindow();double sx=x*window.getScreenWidth()/window.getGuiScaledWidth(),sy=y*window.getScreenHeight()/window.getGuiScaledHeight();
c.mouseHandler.onMove(window.handle(),sx,sy,0,0);c.mouseHandler.handleAccumulatedMovement();
}
private static void drag(net.minecraft.client.Minecraft c,double dx,double dy,boolean releaseOutside) {
var map=(SanctuaryAtlasScreen)c.gui.screen();double x=map.width/2.,y=map.height/2.;
move(c,x,y);move(c,x,y);
var button=new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,0);
c.mouseHandler.onButton(c.getWindow().handle(),button,InputConstants.PRESS);
double before=map.centerX();move(c,x+dx,y+dy);
require(Math.abs(map.centerX()-(before-dx/map.zoom()))<.01,"Native repeated drag tracks pointer displacement");
if (releaseOutside) move(c,1,1);
c.mouseHandler.onButton(c.getWindow().handle(),button,InputConstants.RELEASE);
double released=map.centerX();move(c,x,y);
require(Math.abs(map.centerX()-released)<.01,"Mouse release outside map ends the gesture");
}
private static void clickNested(ClientGameTestContext context,String key) {
context.runOnClient(c -> descendants(c.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.active && b.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key))
.findFirst().orElseThrow().onPress(new KeyEvent(InputConstants.KEY_RETURN,0,0)));
}
private static java.util.stream.Stream<GuiEventListener> descendants(ContainerEventHandler container) {
return container.children().stream().flatMap(child -> java.util.stream.Stream.concat(java.util.stream.Stream.of(child),
child instanceof ContainerEventHandler nested ? descendants(nested) : java.util.stream.Stream.empty()));
}
private static void require(boolean value,String message) {if (!value) throw new AssertionError(message);}
}
@@ -0,0 +1,84 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.atlas.*;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.progression.*;
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.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.input.MouseButtonInfo;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.network.protocol.game.ClientboundTrackedWaypointPacket;
import net.minecraft.world.level.material.MapColor;
import net.minecraft.world.waypoints.Waypoint;
/** Stable rendered terrain across real refresh packets, and native shared locator sprites. */
final class Atlas013ClientChecks {
static void run(ClientGameTestContext c,TestServerContext server) {
int[] sample=server.computeOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();p.setExperienceLevels(20);p.giveExperiencePoints(7);
require(ProgressionService.purchase(p,"atlas",ProgressEffects.get(p).revision()),"Unlock map after prestige");ProgressionService.send(p,"");
require(p.waypointIcon().color.orElseThrow()==(0xFF000000|ProgressionService.color(p)),"Server locator uses the inhabitant colour");
int x=p.chunkPosition().x()+4,z=p.chunkPosition().z();
byte[] colors=new byte[256];java.util.Arrays.fill(colors,MapColor.GOLD.getPackedId(MapColor.Brightness.NORMAL));
AtlasService.observations(p).observe(new AtlasStore.Tile(x,z,colors));return new int[]{x*16,z*16};
});
c.waitFor(m -> ProgressionClient.progress.atlas(),200);
c.runOnClient(m -> {m.gui.setScreen(new SanctuaryAtlasScreen(null));Inventory012RenderProbe.reset();});
c.waitFor(m -> m.gui.screen() instanceof SanctuaryAtlasScreen a && a.isComplete() && a.renderedPixelAt(sample[0],sample[1])!=0,600);
int pixel=c.computeOnClient(m -> ((SanctuaryAtlasScreen)m.gui.screen()).renderedPixelAt(sample[0],sample[1]));
var armed=new AtomicBoolean(true);var stable=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m -> {
if(armed.get() && m.gui.screen() instanceof SanctuaryAtlasScreen a && a.renderedPixelAt(sample[0],sample[1])!=pixel)stable.set(false);
});
double released=c.computeOnClient(m -> {
var a=(SanctuaryAtlasScreen)m.gui.screen();double x=a.width/2.,y=a.height/2.;
var mouse=new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,0);
require(a.mouseClicked(new MouseButtonEvent(x,y,mouse),false),"Map starts a drag");
require(a.mouseDragged(new MouseButtonEvent(x+40,y,mouse),40,0),"Map pans");
a.mouseReleased(new MouseButtonEvent(x+40,y,mouse));return a.centerX();
});
c.waitTicks(150);
c.runOnClient(m -> {
var a=(SanctuaryAtlasScreen)m.gui.screen();
require(a.centerX()==released && stable.get(),"Drag release and periodic refresh never erase known terrain or recenter");
require(Inventory012RenderProbe.sprites.stream().anyMatch(v -> v.id().equals("minecraft:hud/locator_bar_dot/default_0")),"Map uses the native locator texture");
});
c.takeScreenshot("sanctuary-beta013-map-stable");armed.set(false);
c.runOnClient(m -> {
var a=(SanctuaryAtlasScreen)m.gui.screen();
a.children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals("sanctuary.atlas.inhabitant"))
.findFirst().orElseThrow().onPress(new KeyEvent(InputConstants.KEY_RETURN,0,0));
});
c.waitFor(m -> ((SanctuaryAtlasScreen)m.gui.screen()).observedTiles()>0,600);
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();s.getPlayerList().deop(p.nameAndId());});
c.runOnClient(m -> ((SanctuaryAtlasScreen)m.gui.screen()).recenter());
c.waitFor(m -> ((SanctuaryAtlasScreen)m.gui.screen()).status().equals("denied"),600);
c.runOnClient(m -> {require(((SanctuaryAtlasScreen)m.gui.screen()).observedTiles()==0,"Permission revocation clears retained operator terrain");m.gui.screen().onClose();});
UUID marker=UUID.randomUUID();
server.runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();var icon=new Waypoint.Icon();icon.color=java.util.Optional.of(0xFF36CEAA);
p.connection.send(ClientboundTrackedWaypointPacket.addWaypointPosition(marker,icon,p.blockPosition().offset(0,0,20)));
});
c.waitFor(m -> m.player.connection.getWaypointManager().hasWaypoints(),200);
c.runOnClient(m -> {m.player.setYRot(0);m.player.setXRot(0);Inventory012RenderProbe.reset();});
c.waitTicks(150);c.takeScreenshot("sanctuary-beta013-locator-and-xp");
c.runOnClient(m -> {
var sprites=Inventory012RenderProbe.sprites;
var markerDraw=sprites.stream().filter(v -> v.id().startsWith("minecraft:hud/locator_bar_dot/") && v.color()==0xFF36CEAA).findFirst().orElseThrow();
require(sprites.stream().anyMatch(v -> v.id().equals("minecraft:hud/experience_bar_background")),"Experience background remains drawn beyond the native temporary XP priority");
require(sprites.stream().noneMatch(v -> v.id().equals("minecraft:hud/locator_bar_background")),"Locator never replaces the experience background");
var xp=sprites.stream().filter(v -> v.id().equals("minecraft:hud/experience_bar_background")).findFirst().orElseThrow();
require(markerDraw.y()==xp.y()-2 && markerDraw.y()+markerDraw.height()>xp.y()+xp.height(),"Player marker overlays the XP bar at its native position");
});
server.runOnServer(s -> s.getPlayerList().getPlayers().getFirst().connection.send(ClientboundTrackedWaypointPacket.removeWaypoint(marker)));
}
private static void require(boolean ok,String message) {if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,166 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.progression.*;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
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.components.events.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.network.chat.Component;
/** Real widgets, fonts and purchase packets, in a fresh ignored development world. */
public final class Atlas026ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var armed=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m -> {
if (armed.get() && m.gui.screen() instanceof HelloWorldScreen 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());armed.set(false);
}
});
int scale=c.computeOnClient(m -> m.options.guiScale().get());
String language=c.computeOnClient(m -> m.getLanguageManager().getSelected());
try (var world=c.worldBuilder().adjustSettings(s -> {
s.setName("Sanctuary beta026 atlas "+UUID.randomUUID());
s.setWorldType(s.getNormalPresetList().stream().filter(e -> e.preset()!=null && e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());
s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
}).create()) {
world.getConnection().waitForChunksDownload(2400);
c.waitFor(m -> m.player!=null && ProgressionClient.active() && CycleClient.view!=null && m.gui.screen()==null,1200);
world.getServer().runOnServer(server -> {
var player=server.getPlayerList().getPlayers().getFirst();player.setExperienceLevels(20);
require(ProgressionService.purchase(player,"atlas",ProgressEffects.get(player).revision()),"Buy atlas");
int cx=player.chunkPosition().x(),cz=player.chunkPosition().z();
for(int z=cz-8;z<cz+8;z++)for(int x=cx-8;x<cx+8;x++) {
byte[] colors=new byte[256];java.util.Arrays.fill(colors,net.minecraft.world.level.material.MapColor.GOLD.getPackedId(net.minecraft.world.level.material.MapColor.Brightness.NORMAL));
colors[17]=0;colors[18]=1;
fr.koka.sanctuary.atlas.AtlasService.observations(player).observe(new fr.koka.sanctuary.atlas.AtlasStore.Tile(x,z,colors));
fr.koka.demeure.DemeureService.record(player,new net.minecraft.core.BlockPos(x*16,200,z*16),8);
}
ProgressionService.send(player,"");
});
c.waitFor(m -> ProgressionClient.progress.atlas(),200);
c.runOnClient(m -> {m.getWindow().setWindowed(1280,800);m.options.guiScale().set(2);m.resizeGui();m.gui.setScreen(new SanctuaryAtlasScreen(null));});
c.runOnClient(m -> button(m.gui.screen(),"sanctuary.atlas.demeure_off").onPress(enter()));
c.waitFor(m -> m.gui.screen() instanceof SanctuaryAtlasScreen a && a.isComplete() && a.footprintCount()>=256,1200);
c.runOnClient(m -> {var a=(SanctuaryAtlasScreen)m.gui.screen();for(int i=0;i<4;i++)a.mouseScrolled(a.width/2.,a.height/2.,0,-1);});
c.waitTicks(45);
c.runOnClient(m -> hold((SanctuaryAtlasScreen)m.gui.screen()));c.waitTicks(3);
measure(c,"stationary");
c.runOnClient(m -> {var a=(SanctuaryAtlasScreen)m.gui.screen();a.mouseDragged(mouse(a,10,0),10,0);});
measure(c,"pan-held");
c.takeScreenshot("sanctuary-beta026-demeure");
c.runOnClient(m -> {Atlas026RenderProbe.active=false;button(m.gui.screen(),"sanctuary.atlas.demeure_on").onPress(enter());});
measure(c,"disabled");
c.runOnClient(m -> button(m.gui.screen(),"sanctuary.atlas.demeure_off").onPress(enter()));
measure(c,"enabled-again");
if(!Boolean.getBoolean("sanctuary.test.atlasBaseline"))verifyUpdates(c,world.getServer());
System.out.println("ATLAS026 client checks passed");
} finally {
Atlas026RenderProbe.reset();armed.set(false);c.runOnClient(m -> {
m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
m.options.guiScale().set(scale);m.resizeGui();
});
}
}
private static void measure(ClientGameTestContext c,String phase) {
c.runOnClient(m -> {Atlas026RenderProbe.reset();Atlas026RenderProbe.active=true;});
c.waitFor(m -> Atlas026RenderProbe.nanos.size()>=30,1200);
c.runOnClient(m -> {
Atlas026RenderProbe.active=false;System.out.println(Atlas026RenderProbe.report(phase));
if(!Boolean.getBoolean("sanctuary.test.atlasBaseline")) {
require(Atlas026RenderProbe.blits==(phase.equals("disabled") ? 0 : Atlas026RenderProbe.nanos.size()),"One cached layer per visible frame: "+phase);
require(Atlas026RenderProbe.fills==0,"Overlay never submits per-block rectangles: "+phase);
require(Atlas026RenderProbe.uploads==0,"Stable data never reuploads map textures: "+phase);
}
});
}
private static net.minecraft.client.input.MouseButtonEvent mouse(SanctuaryAtlasScreen a,double dx,double dy) {
return new net.minecraft.client.input.MouseButtonEvent(a.width/2.+dx,a.height/2.+dy,new net.minecraft.client.input.MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,0));
}
private static void hold(SanctuaryAtlasScreen a) {require(a.mouseClicked(mouse(a,0,0),false),"Hold map gesture");}
private record Fixture(int token,fr.koka.sanctuary.atlas.AtlasStore.Key key,byte[] colors,fr.koka.sanctuary.atlas.AtlasDemeurePayload.Footprint home) {}
private static void verifyUpdates(ClientGameTestContext c,net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext server) {
var sample=c.computeOnClient(m -> {
var a=(SanctuaryAtlasScreen)m.gui.screen();
var key=new fr.koka.sanctuary.atlas.AtlasStore.Key(m.player.chunkPosition().x()+5,m.player.chunkPosition().z()+5);
var surveys=(java.util.Map<fr.koka.sanctuary.atlas.AtlasStore.Key,byte[]>)field(a,"surveyed");
var homes=(java.util.Map<fr.koka.sanctuary.atlas.AtlasStore.Key,fr.koka.sanctuary.atlas.AtlasDemeurePayload.Footprint>)field(a,"homes");
return new Fixture((int)field(a,"token"),key,surveys.get(key).clone(),homes.get(key));
});
c.runOnClient(m -> {
var a=(SanctuaryAtlasScreen)m.gui.screen();int x=sample.key.x()*16,z=sample.key.z()*16;
require(pixel(a,x,z)==(0xA0000000|sample.home.color()),"Original border colour and opacity");
require(pixel(a,x+3,z+1)==(0x25000000|sample.home.color()),"Original interior opacity");
require(pixel(a,x+1,z+1)==0 && pixel(a,x+2,z+1)==0,"Unknown pixels and observed void have no overlay");
Atlas026RenderProbe.reset();Atlas026RenderProbe.active=true;
});
send(server,sample,java.util.List.of(sample.home));c.waitTicks(5);
c.runOnClient(m -> {require(Atlas026RenderProbe.uploads==0,"Identical network batches do not upload either texture");Atlas026RenderProbe.active=false;});
var changed=new fr.koka.sanctuary.atlas.AtlasDemeurePayload.Footprint(sample.key.x(),sample.key.z(),"dominated","player","Updated",99,0x36CEAA);
send(server,sample,java.util.List.of(changed));
c.waitFor(m -> pixel((SanctuaryAtlasScreen)m.gui.screen(),sample.key.x()*16,sample.key.z()*16)==0xA036CEAA,200);
c.runOnClient(m -> {
var a=(SanctuaryAtlasScreen)m.gui.screen();require(pixel(a,sample.key.x()*16+1,sample.key.z()*16+1)==0,"Colour changes never reveal the fog");
button(a,"sanctuary.atlas.demeure_on").onPress(enter());Atlas026RenderProbe.reset();Atlas026RenderProbe.active=true;
});
send(server,sample,java.util.List.of(sample.home));c.waitTicks(5);
c.runOnClient(m -> {require(Atlas026RenderProbe.uploads==0,"Hidden changes wait until the layer is enabled");Atlas026RenderProbe.active=false;button(m.gui.screen(),"sanctuary.atlas.demeure_off").onPress(enter());});
c.waitFor(m -> pixel((SanctuaryAtlasScreen)m.gui.screen(),sample.key.x()*16,sample.key.z()*16)==(0xA0000000|sample.home.color()),200);
byte[] revealed=sample.colors.clone();revealed[17]=sample.colors[0];
send(server,new Fixture(sample.token,sample.key,revealed,sample.home),java.util.List.of(sample.home));
c.waitFor(m -> pixel((SanctuaryAtlasScreen)m.gui.screen(),sample.key.x()*16+1,sample.key.z()*16+1)!=0,200);
send(server,sample,java.util.List.of());
c.waitFor(m -> pixel((SanctuaryAtlasScreen)m.gui.screen(),sample.key.x()*16,sample.key.z()*16)==0,200);
c.runOnClient(m -> require(pixel((SanctuaryAtlasScreen)m.gui.screen(),sample.key.x()*16+1,sample.key.z()*16+1)==0,"Removed footprint and restored fog clear stale pixels"));
// Reprojection and real refresh restore authoritative data from the same saved observations.
c.runOnClient(m -> {
var a=(SanctuaryAtlasScreen)m.gui.screen();a.mouseReleased(mouse(a,0,0));a.recenter();
m.getWindow().setWindowed(640,480);m.options.guiScale().set(0);m.resizeGui();
});c.waitTicks(45);
c.waitFor(m -> ((SanctuaryAtlasScreen)m.gui.screen()).isComplete() && pixel((SanctuaryAtlasScreen)m.gui.screen(),sample.key.x()*16,sample.key.z()*16)==(0xA0000000|sample.home.color()),600);
c.takeScreenshot("sanctuary-beta026-demeure-compact");
// Removing the map aptitude uses the actual server permission check on the next request.
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(p,ProgressRecord.INITIAL);ProgressionService.send(p,"");});
c.runOnClient(m -> ((SanctuaryAtlasScreen)m.gui.screen()).recenter());
c.waitFor(m -> ((SanctuaryAtlasScreen)m.gui.screen()).status().equals("locked"),600);
c.runOnClient(m -> {
var a=(SanctuaryAtlasScreen)m.gui.screen();require(a.footprintCount()==0 && pixel(a,sample.key.x()*16,sample.key.z()*16)==0,"Revocation clears both map layers");
var image=((net.minecraft.client.renderer.texture.DynamicTexture)field(a,"footprintTexture")).getPixels();
a.onClose();require(image.isClosed(),"Closing releases the overlay image");
});
System.out.println("ATLAS026 updates passed: identical batches, colour, fog, removal, hidden updates, reprojection, resize, revocation, disposal");
}
private static void send(net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext server,Fixture f,java.util.List<fr.koka.sanctuary.atlas.AtlasDemeurePayload.Footprint> homes) {
server.runOnServer(s -> {
var player=s.getPlayerList().getPlayers().getFirst();
net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking.send(player,new fr.koka.sanctuary.atlas.AtlasPayloads.Response(f.token,false,true,false,false,"ready",java.util.List.of(new fr.koka.sanctuary.atlas.AtlasStore.Tile(f.key.x(),f.key.z(),f.colors))));
net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking.send(player,new fr.koka.sanctuary.atlas.AtlasDemeurePayload(f.token,homes));
});
}
private static Object field(Object target,String name) {
try {var f=target.getClass().getDeclaredField(name);f.setAccessible(true);return f.get(target);}catch(ReflectiveOperationException e){throw new AssertionError(e);}
}
private static int pixel(SanctuaryAtlasScreen a,int x,int z) {
int localX=x-(int)field(a,"originX"),localZ=z-(int)field(a,"originZ");
return ((net.minecraft.client.renderer.texture.DynamicTexture)field(a,"footprintTexture")).getPixels().getPixel(localX,localZ);
}
private static Stream<GuiEventListener> widgets(GuiEventListener widget) {
return Stream.concat(Stream.of(widget),widget instanceof ContainerEventHandler parent
? parent.children().stream().flatMap(Atlas026ClientChecks::widgets) : Stream.empty());
}
private static boolean key(AbstractWidget widget,String key) {return widget.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key);}
private static Button button(Screen screen,String key) {return widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(b -> key(b,key)).findFirst().orElseThrow();}
private static KeyEvent enter() {return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean ok,String message) {if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,115 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.atlas.*;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.progression.*;
import java.nio.file.*;
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.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.LevelResource;
/** Real 640-high world, sampling, packets, cached rendering and native controls. */
public final class Atlas058ClientChecks implements FabricClientGameTest {
private static final net.minecraft.world.level.block.Block[] BLOCKS={Blocks.STONE,Blocks.DIAMOND_BLOCK,Blocks.GOLD_BLOCK,Blocks.LAPIS_BLOCK,Blocks.EMERALD_BLOCK,Blocks.REDSTONE_BLOCK};
public void runTest(ClientGameTestContext c) {
var intro=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{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);
}});
int scale=c.computeOnClient(m->m.options.guiScale().get());String language=c.computeOnClient(m->m.getLanguageManager().getSelected());
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Atlas058 "+UUID.randomUUID());s.setSeed("58");s.setAllowCommands(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()) {
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
var server=world.getServer();
int[] fixture=server.computeOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;
var level=p.level();check(level.getMaxY()==639,"Real Sanctuary dimension reaches 640 blocks");
int x=p.chunkPosition().x()*16+4,z=p.chunkPosition().z()*16+4;
for(int dx=0;dx<4;dx++)for(int dz=0;dz<4;dz++) {
for(int y=level.getMinY();y<=level.getMaxY();y++)level.setBlock(new BlockPos(x+dx,y,z+dz),Blocks.AIR.defaultBlockState(),2);
if(dx==3&&dz==3)continue;
for(int i=0;i<6;i++)level.setBlock(new BlockPos(x+dx,Math.min(639,AtlasAltitude.LEVELS.get(i)),z+dz),BLOCKS[i].defaultBlockState(),2);
}
int cx=Math.floorDiv(x,16),cz=Math.floorDiv(z,16),pixel=Math.floorMod(z,16)*16+Math.floorMod(x,16);
var all=AtlasSampler.sampleLayers(level,cx,cz);check(all.size()==6,"Six layers in one scan");
for(int i=0;i<6;i++) {
int altitude=AtlasAltitude.LEVELS.get(i);var tile=all.get(altitude);
int color=Byte.toUnsignedInt(tile.colors()[pixel])/4;
check(color==BLOCKS[i].defaultMapColor().id,"Topmost block below inclusive ceiling "+altitude);
check(Arrays.equals(tile.colors(),AtlasSampler.sample(level,cx,cz,altitude).colors()),"Single-layer and shared descent agree "+altitude);
check(tile.colors()[pixel+51]==1,"Empty column stays transparent void "+altitude);
AtlasService.observations(p,altitude).observe(AtlasFog.reveal(tile,p.getX(),p.getZ()));
}
check(AtlasSampler.sample(level,1000000,1000000,280)==null&&AtlasSampler.sampleLayers(level,1000000,1000000).isEmpty()&&level.getChunkSource().getChunkNow(1000000,1000000)==null,"Sampling never loads unknown distant chunks");
// A transparent block at the cut must not hide the colour just underneath.
level.setBlock(new BlockPos(x,140,z),Blocks.GLASS.defaultBlockState(),2);level.setBlock(new BlockPos(x,139,z),Blocks.DIAMOND_BLOCK.defaultBlockState(),2);
check(Byte.toUnsignedInt(AtlasSampler.sample(level,cx,cz,140).colors()[pixel])/4==Blocks.DIAMOND_BLOCK.defaultMapColor().id,"Transparent cut reveals support");
p.setExperienceLevels(20);check(ProgressionService.purchase(p,"atlas",ProgressEffects.get(p).revision()),"Atlas purchased after recording lower layers");
s.getWorldData().setAllowCommands(false);ProgressionService.send(p,"");
return new int[]{x,z,cx,cz};
});
c.waitFor(m->ProgressionClient.progress.atlas()&&!ProgressionClient.operator,200);
c.runOnClient(m->{m.getWindow().setWindowed(1280,800);m.options.guiScale().set(2);m.resizeGui();m.gui.setScreen(new SanctuaryAtlasScreen(null));});
waitLayer(c,640,fixture);
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();check(!(boolean)field(a,"grid")&&!(boolean)field(a,"footprints"),"Chunk grid and Demeure off by default");
for(String key:List.of("sanctuary.atlas.grid_off","sanctuary.atlas.demeure_off"))a.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow().onPress(enter());
check((boolean)field(a,"grid")&&(boolean)field(a,"footprints"),"Optional overlays can be enabled");
for(String key:List.of("sanctuary.atlas.grid_on","sanctuary.atlas.demeure_on"))a.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow().onPress(enter());
});
double[] position=c.computeOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();a.mouseScrolled(a.width/2.,a.height/2.,0,1);var info=new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,0);a.mouseClicked(new MouseButtonEvent(a.width/2.,a.height/2.,info),false);a.mouseDragged(new MouseButtonEvent(a.width/2.+28,a.height/2.,info),28,0);a.mouseReleased(new MouseButtonEvent(a.width/2.+28,a.height/2.,info));return new double[]{a.centerX(),a.zoom()};});
c.waitTicks(25);
for(int i=5;i>=0;i--) {
int ceiling=AtlasAltitude.LEVELS.get(i);
if(i<5)c.runOnClient(m->click((SanctuaryAtlasScreen)m.gui.screen(),""));
waitLayer(c,ceiling,fixture);
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();check(a.centerX()==position[0]&&a.zoom()==position[1],"Layer change preserves pan and zoom");check(button(a,"").active==(ceiling<640)&&button(a,"").active==(ceiling>0),"Buttons clamp both ends");});
if(i==5||i==2||i==0)c.takeScreenshot("sanctuary-beta058-altitude-"+ceiling);
}
c.runOnClient(m->{
var a=(SanctuaryAtlasScreen)m.gui.screen();int oldToken=(int)field(a,"token");
for(int i=0;i<5;i++)click(a,"");for(int i=0;i<2;i++)click(a,"");
check(a.altitude()==420,"Rapid clicks end at requested layer");
byte[] bad=new byte[256];Arrays.fill(bad,(byte)100);
a.accept(new AtlasPayloads.Response(oldToken,false,true,true,false,"ready",List.of(new AtlasStore.Tile(fixture[2],fixture[3],bad))));
a.accept(new AtlasDemeurePayload(oldToken,List.of(new AtlasDemeurePayload.Footprint(fixture[2],fixture[3],"inhabited","nature","",50,0xFF00FF))));
check(a.observedTiles()==0&&a.footprintCount()==0,"Old terrain and Demeure packets are ignored after layer switch");
});waitLayer(c,420,fixture);
int pixel=c.computeOnClient(m->((SanctuaryAtlasScreen)m.gui.screen()).renderedPixelAt(fixture[0],fixture[1]));
c.waitTicks(130);c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();check(a.renderedPixelAt(fixture[0],fixture[1])==pixel&&a.centerX()==position[0],"Periodic refresh keeps displayed terrain and position");});
// FR/EN and minimum GUI scale retain clickable controls without footer overlap.
for(String lang:List.of("fr_fr","en_us"))c.runOnClient(m->{m.getLanguageManager().setSelected(lang);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(4);m.resizeGui();var a=(SanctuaryAtlasScreen)m.gui.screen();check(button(a,"").getBottom()<a.height-58,"Altitude buttons clear coordinate/footer overlay");check(!SanctuaryAtlasScreen.label("altitude",420).getString().contains("sanctuary."),"Translated altitude label");});
c.takeScreenshot("sanctuary-beta058-small-gui");
// Additive persistence and server-side permission checks on every layer.
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var root=s.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-atlas");try{
for(int altitude:AtlasAltitude.LEVELS){var survey=AtlasService.observations(p,altitude);survey.save();var file=altitude==640?root.resolve(p.getUUID()+".json"):root.resolve("layers/"+p.getUUID()+"/"+altitude+".json");var bytes=Files.readAllBytes(file);var read=new AtlasStore(file,p.getUUID(),s.overworld().getSeed());check(Arrays.equals(read.get(fixture[2],fixture[3]).colors(),survey.get(fixture[2],fixture[3]).colors()),"Layer survives disk reload "+altitude);read.save();check(Arrays.equals(bytes,Files.readAllBytes(file)),"Layer reload is idempotent");}
check(!AtlasService.canRead(p,true)&&AtlasService.canRead(p,false),"Normal mode cannot inspect operator chunks");
}catch(Exception e){throw new RuntimeException(e);}});
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();int next=AtlasClient.nextToken();setField(a,"token",next);AtlasClient.send(new AtlasPayloads.Request(next,fixture[2],fixture[3],true,420));});
c.waitFor(m->((SanctuaryAtlasScreen)m.gui.screen()).status().equals("denied"),200);
c.runOnClient(m->{check(((SanctuaryAtlasScreen)m.gui.screen()).observedTiles()==0,"Denied operator response clears all pixels");m.gui.screen().onClose();});
protocol();System.out.println("ATLAS058_PASS: ceilings, actual colours, fog, persistence, packets, controls, optional overlays, pan, permissions, FR/EN");
} finally {intro.set(false);c.runOnClient(m->{m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(scale);m.resizeGui();});}
}
private static void waitLayer(ClientGameTestContext c,int altitude,int[] fixture) {
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen a&&a.altitude()==altitude&&a.isComplete()&&a.renderedPixelAt(fixture[0],fixture[1])!=0,800);
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();int index=AtlasAltitude.LEVELS.indexOf(altitude);int color=a.renderedPixelAt(fixture[0],fixture[1]);boolean match=false;for(var shade:net.minecraft.world.level.material.MapColor.Brightness.values())match|=color==net.minecraft.world.level.material.MapColor.getColorFromPackedId(BLOCKS[index].defaultMapColor().getPackedId(shade));check(match,"Actual rendered pixel belongs to layer "+altitude);check(a.renderedPixelAt(fixture[0]+3,fixture[1]+3)==0,"Void pixels remain transparent");check(a.renderedPixelAt(fixture[0]+250,fixture[1]+250)==0,"Unknown territory remains hidden");});
}
private static void protocol(){for(int altitude:AtlasAltitude.LEVELS){var b=new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(),net.minecraft.core.RegistryAccess.EMPTY);try{var r=new AtlasPayloads.Request(4,-2,3,false,altitude);AtlasPayloads.Request.CODEC.encode(b,r);check(AtlasPayloads.Request.CODEC.decode(b).equals(r),"Altitude round trip");}finally{b.release();}}for(int invalid:new int[]{-1,1,139,141,641,Integer.MAX_VALUE}){boolean failed=false;try{new AtlasPayloads.Request(1,0,0,false,invalid);}catch(IllegalArgumentException expected){failed=true;}check(failed,"Reject invalid altitude");}check(AtlasAltitude.step(640,1)==640&&AtlasAltitude.step(0,-1)==0&&AtlasAltitude.step(560,1)==640,"Bounded final 80-block step");}
private static Button button(SanctuaryAtlasScreen a,String text){return a.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().equals(text)).findFirst().orElseThrow();}
private static void click(SanctuaryAtlasScreen a,String text){var b=button(a,text);check(b.active,"Enabled altitude control");a.mouseClicked(new MouseButtonEvent(b.getX()+5,b.getY()+5,new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,0)),false);a.mouseReleased(new MouseButtonEvent(b.getX()+5,b.getY()+5,new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,0)));}
private static Object field(Object o,String name){try{var f=o.getClass().getDeclaredField(name);f.setAccessible(true);return f.get(o);}catch(Exception e){throw new RuntimeException(e);}}
private static void setField(Object o,String name,Object value){try{var f=o.getClass().getDeclaredField(name);f.setAccessible(true);f.set(o,value);}catch(Exception e){throw new RuntimeException(e);}}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,105 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.atlas.*;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.worldgen.aerial.*;
import java.nio.file.*;
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.*;
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.core.component.DataComponents;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.resources.*;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.saveddata.maps.*;
import net.minecraft.world.level.storage.LevelResource;
import net.minecraft.world.level.storage.loot.*;
import net.minecraft.world.level.storage.loot.parameters.*;
import net.minecraft.world.phys.*;
/** Native banner interactions, actual loot-generated maps, packets and no operator knowledge leak. */
public final class Atlas059ClientChecks implements FabricClientGameTest {
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);}
});
int scale=c.computeOnClient(m->m.options.guiScale().get());String lang=c.computeOnClient(m->m.getLanguageManager().getSelected());
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Atlas059 "+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()) {
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);var server=world.getServer();
var banner=server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.setExperienceLevels(20);check(ProgressionService.purchase(p,"atlas",ProgressEffects.get(p).revision()),"Unlock atlas");p.setItemInHand(InteractionHand.MAIN_HAND,ItemStack.EMPTY);p.inventoryMenu.broadcastChanges();
var pos=p.blockPosition().offset(1,0,1);p.level().setBlock(pos.below(),Blocks.STONE.defaultBlockState(),2);p.level().setBlock(pos,Blocks.BANNER.red().defaultBlockState(),2);rename(p.level(),pos,"Port de Zoé");
var sites=AtlasMarkers.treasureSites(p.level());check(sites.size()==2,"Existing medium-world plan contains both mining treasures; structures="+p.level().structureManager().shouldGenerateStructures()+" generator="+p.level().getChunkSource().getGenerator());
check(AtlasMarkers.discoveries(p).isEmpty(),"No treasure known at arrival");s.getWorldData().setAllowCommands(false);ProgressionService.send(p,"");return pos;
});
c.waitFor(m->!ProgressionClient.operator&&m.player.getMainHandItem().isEmpty()&&m.level.getBlockState(banner).is(Blocks.BANNER.red()),200);
useBanner(c,banner);c.waitTicks(12);server.runOnServer(s->check(AtlasMarkers.discoveries(s.getPlayerList().getPlayers().getFirst()).stream().anyMatch(m->m.name().equals("Port de Zoé")),"Real right-click records named red banner"));
useBanner(c,banner);c.waitTicks(12);server.runOnServer(s->check(AtlasMarkers.discoveries(s.getPlayerList().getPlayers().getFirst()).isEmpty(),"Second right-click removes native-style marker"));
useBanner(c,banner);c.waitTicks(12);
c.runOnClient(m->{m.getWindow().setWindowed(1280,800);m.options.guiScale().set(2);m.resizeGui();m.gui.setScreen(new SanctuaryAtlasScreen(null));});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen a&&a.isComplete()&&a.markers().size()==1,600);
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();check(a.markers().getFirst().kind().equals("banner"),"Normal atlas receives no undiscovered treasure coordinates");spriteCheck(m,a);});
c.takeScreenshot("sanctuary-beta059-named-banner");
server.runOnServer(s->rename(s.overworld(),banner,"Quai des îles"));c.runOnClient(m->((SanctuaryAtlasScreen)m.gui.screen()).recenter());
c.waitFor(m->((SanctuaryAtlasScreen)m.gui.screen()).markers().stream().anyMatch(v->v.name().equals("Quai des îles")),300);
c.runOnClient(m->m.gui.screen().onClose());
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var map=MapItem.create(p.level(),banner.getX(),banner.getZ(),(byte)1,true,true);MapItemSavedData.addTargetDecoration(map,banner.offset(80,0,80),"mansion",MapDecorationTypes.WOODLAND_MANSION);p.setItemInHand(InteractionHand.OFF_HAND,map);p.inventoryMenu.broadcastChanges();});
c.waitFor(m->m.player.getOffhandItem().has(DataComponents.MAP_ID),200);
c.runOnClient(m->m.gameMode.useItemOn(m.player,InteractionHand.OFF_HAND,new BlockHitResult(Vec3.atCenterOf(banner),Direction.UP,banner,false)));c.waitTicks(12);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(MapItem.getSavedData(p.getOffhandItem(),p.level()).getBanners().size()==1,"Offhand physical map keeps native banner recording");});
c.runOnClient(m->m.gameMode.useItem(m.player,InteractionHand.OFF_HAND));c.waitTicks(12);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(AtlasMarkers.discoveries(p).stream().anyMatch(v->v.kind().equals("point")&&v.y()==null),"Native exploration point imported without invented altitude");check(AtlasMarkers.discoveries(p).stream().anyMatch(v->v.name().equals("Quai des îles")),"Named banner imported from the native physical map");});
// Generate actual loot from both unmodified site identities; no synthetic clue grants.
var target=server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var sites=AtlasMarkers.treasureSites(p.level());var charts=new ArrayList<ItemStack>();
for(int i=0;i<2;i++) {
String table=i==0?"merchant_provisions":"merchant_tools";var loot=loot(p.level(),table,p.position());
check(loot.stream().anyMatch(v->v.is(iItem(table))),"Original merchant loot remains");if(i==0)check(loot.stream().filter(v->v.is(Items.BREAD)).mapToInt(ItemStack::getCount).sum()>=24,"Declared provision quantities apply in the native modifier format");
var map=loot.stream().filter(v->v.has(DataComponents.MAP_ID)).findFirst().orElseThrow();var expected=AtlasMarkers.treasure(sites.get(i));
check(map.get(DataComponents.MAP_DECORATIONS).decorations().values().stream().anyMatch(d->d.x()==expected.x()&&d.z()==expected.z()&&d.type().equals(MapDecorationTypes.RED_X)),"Loot map targets the saved island chest "+i);charts.add(map);
}
var mineral=loot(p.level(),"mineral_islet",Vec3.atCenterOf(AerialGeometry24.chest(sites.getFirst())));var next=mineral.stream().filter(v->v.has(DataComponents.MAP_ID)).findFirst().orElseThrow();var nextPoint=AtlasMarkers.treasure(sites.get(1));check(next.get(DataComponents.MAP_DECORATIONS).decorations().values().stream().anyMatch(d->d.x()==nextPoint.x()&&d.z()==nextPoint.z()),"Mining chest leads to the next island");
p.setItemInHand(InteractionHand.MAIN_HAND,charts.getFirst());p.inventoryMenu.broadcastChanges();return AtlasMarkers.treasure(sites.getFirst());
});
c.waitFor(m->m.player.getMainHandItem().has(DataComponents.MAP_ID),200);
c.runOnClient(m->m.gameMode.useItem(m.player,InteractionHand.MAIN_HAND));c.waitTicks(15);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(AtlasMarkers.discoveries(p).stream().filter(m->m.kind().equals("treasure")).count()==1,"Using physical map unlocks exactly its own treasure");check(p.getMainHandItem().has(DataComponents.MAP_ID)&&p.getMainHandItem().getCount()==1,"Chart remains shareable");check(AtlasMarkers.importMap(p,p.getMainHandItem())==0,"Repeated use does not duplicate markers");});
c.runOnClient(m->m.gui.setScreen(new SanctuaryAtlasScreen(null)));c.waitFor(m->((SanctuaryAtlasScreen)m.gui.screen()).markers().stream().anyMatch(v->v.id().equals(target.id())),600);
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();setField(a,"centerX",(double)target.x());setField(a,"centerZ",(double)target.z());spriteCheck(m,a);});c.takeScreenshot("sanctuary-beta059-treasure-chart");
// The full operator list never changes normal discoveries, and revocation clears it.
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();s.getWorldData().setAllowCommands(true);ProgressionService.send(p,"");});
c.waitFor(m->((SanctuaryAtlasScreen)m.gui.screen()).markers().stream().filter(v->v.kind().equals("treasure")).count()==2,600);
c.takeScreenshot("sanctuary-beta059-operator-treasures");int opToken=c.computeOnClient(m->(int)field(m.gui.screen(),"token"));
var opMarkers=c.computeOnClient(m->((SanctuaryAtlasScreen)m.gui.screen()).markers());
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();check(AtlasMarkers.discoveries(p).stream().filter(v->v.kind().equals("treasure")).count()==1,"Operator inspection never grants knowledge");s.getWorldData().setAllowCommands(false);ProgressionService.send(p,"");});
c.waitFor(m->!ProgressionClient.operator&&((SanctuaryAtlasScreen)m.gui.screen()).isComplete()&&((SanctuaryAtlasScreen)m.gui.screen()).markers().stream().filter(v->v.kind().equals("treasure")).count()==1,600);
c.runOnClient(m->{var a=(SanctuaryAtlasScreen)m.gui.screen();a.accept(new AtlasMarkerPayload(opToken,opMarkers));check(a.markers().stream().filter(v->v.kind().equals("treasure")).count()==1,"Delayed operator payload cannot restore hidden treasure");});
server.runOnServer(s->s.overworld().setBlock(banner,Blocks.AIR.defaultBlockState(),2));c.runOnClient(m->((SanctuaryAtlasScreen)m.gui.screen()).recenter());
c.waitFor(m->((SanctuaryAtlasScreen)m.gui.screen()).markers().stream().noneMatch(v->v.kind().equals("banner")),300);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();try{var file=s.getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-atlas/markers/"+p.getUUID()+".json");var read=new AtlasMarkerStore(file,p.getUUID(),s.overworld().getSeed());check(read.all().equals(AtlasMarkers.discoveries(p)),"Discovery and banner deletion already survive disk reload");}catch(Exception e){throw new RuntimeException(e);}});
for(String language:List.of("fr_fr","en_us"))c.runOnClient(m->{m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());check(!AtlasMarkers.label("treasure_chart").getString().contains("sanctuary."),"Localized chart and markers");});
protocol(target);System.out.println("ATLAS059_PASS: native banners, treasure loot/maps, physical use, server filtering, operator revocation, native sprites and persistence");
}finally{intro.set(false);c.runOnClient(m->{m.getLanguageManager().setSelected(lang);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(scale);m.resizeGui();});}
}
private static Item iItem(String table){return table.equals("merchant_tools")?Items.IRON_PICKAXE:Items.BREAD;}
private static List<ItemStack> loot(net.minecraft.server.level.ServerLevel level,String table,Vec3 origin){var key=ResourceKey.create(Registries.LOOT_TABLE,Identifier.fromNamespaceAndPath("sanctuary","chests/aerial24/"+table));return level.getServer().reloadableRegistries().getLootTable(key).getRandomItems(new LootParams.Builder(level).withParameter(LootContextParams.ORIGIN,origin).create(LootContextParamSets.CHEST),59L);}
private static void rename(net.minecraft.server.level.ServerLevel level,BlockPos pos,String name){var item=new ItemStack(Items.BANNER.red());item.set(DataComponents.CUSTOM_NAME,Component.literal(name));var be=level.getBlockEntity(pos);be.applyComponentsFromItemStack(item);be.setChanged();level.sendBlockUpdated(pos,level.getBlockState(pos),level.getBlockState(pos),3);}
private static void useBanner(ClientGameTestContext c,BlockPos pos){c.runOnClient(m->m.gameMode.useItemOn(m.player,InteractionHand.MAIN_HAND,new BlockHitResult(Vec3.atCenterOf(pos),Direction.UP,pos,false)));}
private static void spriteCheck(net.minecraft.client.Minecraft m,SanctuaryAtlasScreen a){for(var marker:a.markers()){var id=Identifier.parse(marker.sprite());check(m.getAtlasManager().getAtlasOrThrow(net.minecraft.data.AtlasIds.MAP_DECORATIONS).getSprite(id).contents().name().equals(id),"Real native decoration sprite exists: "+id);}}
private static void protocol(AtlasMarker target){var b=new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(),RegistryAccess.EMPTY);try{var p=new AtlasMarkerPayload(17,List.of(target,new AtlasMarker("point",0,null,0,"point","minecraft:mansion","")));AtlasMarkerPayload.CODEC.encode(b,p);check(AtlasMarkerPayload.CODEC.decode(b).equals(p),"Marker codec preserves unknown Y and identities");}finally{b.release();}}
private static Object field(Object o,String name){try{var f=o.getClass().getDeclaredField(name);f.setAccessible(true);return f.get(o);}catch(Exception e){throw new RuntimeException(e);}}
private static void setField(Object o,String name,Object v){try{var f=o.getClass().getDeclaredField(name);f.setAccessible(true);f.set(o,v);}catch(Exception e){throw new RuntimeException(e);}}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,78 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.ProgressionScreen;
import fr.koka.sanctuary.client.SanctuaryBlocodexScreen;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.AbstractWidget;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.ObjectSelectionList;
import net.minecraft.client.gui.components.StringWidget;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
/** Exercises real GUI resizes and the live discovery catalogue inside the progression test world. */
final class Blocodex004ClientChecks {
static void run(ClientGameTestContext context) {
int originalWidth=context.computeOnClient(c -> c.getWindow().getScreenWidth());
int originalHeight=context.computeOnClient(c -> c.getWindow().getScreenHeight());
int originalScale=context.computeOnClient(c -> c.options.guiScale().get());
try {
for(int[] size:new int[][]{{854,480},{960,720}}){
context.runOnClient(c -> c.getWindow().setWindowed(size[0],size[1]));context.waitTicks(3);
for(int scale:new int[]{1,2,3,0}){
context.runOnClient(c -> {c.options.guiScale().set(scale);c.resizeGui();});
for(String page:new String[]{"blocks","progress","history","identity"}){
context.runOnClient(c -> fr.koka.sanctuary.client.ProgressionClient.open(null,page));context.waitTick();
context.runOnClient(c -> frame(c.gui.screen(),page));
}
}
}
context.runOnClient(c -> fr.koka.sanctuary.client.ProgressionClient.open(null,"progress"));
System.out.println("Native compact page headers and footers fit both window sizes and four GUI scales.");
}finally{
context.runOnClient(c -> {c.getWindow().setWindowed(originalWidth,originalHeight);c.options.guiScale().set(originalScale);c.resizeGui();});context.waitTicks(3);
}
}
static void language(ClientGameTestContext context,String language) {
if (context.computeOnClient(client -> client.getLanguageManager().getSelected().equals(language))) return;
var reload=context.computeOnClient(client -> {
client.getLanguageManager().setSelected(language);
return client.reloadResourcePacks();
});
context.waitFor(client -> reload.isDone(),600);
reload.join();
context.waitFor(client -> client.gui.overlay()==null,600);
context.runOnClient(client -> client.resizeGui());
context.waitTick();
}
static void frame(Screen screen,String tab) {
Button done=button(screen,"gui.done");
require(done.getBottom()<=screen.height-5 && Math.abs(done.getX()+done.getWidth()/2-screen.width/2)<=1,"Native Done footer fits and stays centered");
var title=screen.children().stream().filter(StringWidget.class::isInstance).map(StringWidget.class::cast)
.filter(w -> key(w).equals("sanctuary.progression."+tab)).findFirst().orElseThrow();
require(title.getY()>=0 && title.getBottom()<=24,"Compact page title fits its header");
require(screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.noneMatch(b -> List.of("blocks","progress","history","identity").stream().anyMatch(k -> key(b).equals("sanctuary.progression."+k))),"Pages do not duplicate pause navigation");
}
private static String key(AbstractWidget widget) {
return widget.getMessage().getContents() instanceof TranslatableContents t ? t.getKey() : "";
}
private static Button button(Screen screen,String key) {
return screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> key(b).equals(key)).findFirst().orElseThrow();
}
private static ObjectSelectionList<?> list(Screen screen) {
return screen.children().stream().filter(ObjectSelectionList.class::isInstance).map(ObjectSelectionList.class::cast)
.findFirst().orElseThrow();
}
private static EditBox search(Screen screen) {
return screen.children().stream().filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow();
}
private static void require(boolean condition,String message) { if (!condition) throw new AssertionError(message); }
}
@@ -0,0 +1,55 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.recipes.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
import mezz.jei.api.constants.*;
import mezz.jei.api.recipe.RecipeIngredientRole;
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.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.inventory.InventoryScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.*;
/** End-to-end catalogue lookup, not just presence in the server recipe set. */
public final class Blueprint036ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var armed=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(armed.get()&&m.gui.screen() instanceof HelloWorldScreen 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());armed.set(false);}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sanctuary blueprint036 "+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()&&RecipeClient.ready&&SanctuaryJeiPlugin.runtime()!=null&&m.gui.screen()==null,1200);var server=world.getServer();
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();p.giveExperienceLevels(4-p.experienceLevel);RecipeService.refresh(p);});c.waitFor(m->RecipeClient.xp>=4,150);
c.runOnClient(m->RecipeClient.request("buy"));c.waitFor(m->RecipeClient.allowed()&&RecipeClient.knowledge.learned(),150);
c.runOnClient(m->{var filter=SanctuaryJeiPlugin.runtime().getIngredientFilter();filter.setFilterText("");require(filter.getFilteredItemStacks().stream().noneMatch(i->i.is(Items.BOW)),"Undiscovered bow blueprint is initially hidden");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();for(String id:List.of("adventure/kill_a_mob","story/root","husbandry/fishy_business","adventure/trade")){var a=s.getAdvancements().get(Identifier.withDefaultNamespace(id));for(String criterion:a.value().criteria().keySet())p.getAdvancements().award(a,criterion);}RecipeService.refresh(p);});
c.waitFor(m->RecipeClient.knowledge.collections.containsAll(List.of("sanctuary:archery","sanctuary:basic_wood","sanctuary:boats_and_rafts","sanctuary:books_and_storage")),200);
c.runOnClient(m->{var r=SanctuaryJeiPlugin.runtime();r.getIngredientFilter().setFilterText("");var shown=r.getIngredientFilter().getFilteredItemStacks();
for(Item item:List.of(Items.BOW,Items.CROSSBOW,Items.CRAFTING_TABLE,Items.OAK_BOAT,Items.BAMBOO_RAFT,Items.BOOK,Items.BOOKSHELF)){
String id=net.minecraft.core.registries.BuiltInRegistries.ITEM.getKey(item).toString();require(!RecipeClient.knowledge.items.contains(id),"Blueprint does not invent possession: "+id);require(shown.stream().anyMatch(i->i.is(item)),"Known recipe output searchable in actual JEI catalogue: "+id);
require(r.getRecipeManager().createRecipeLookup(RecipeTypes.CRAFTING).get().anyMatch(v->v.id().identifier().toString().equals(id)),"Corresponding recipe really visible: "+id);
}
require(shown.stream().noneMatch(i->i.is(Items.DIAMOND_BLOCK)||i.is(Items.STRING)),"Unrelated item and unknown input remain hidden");
require(!RecipeClient.operator,"All checks use inhabitant mode");});
for(Item item:List.of(Items.BOW,Items.CRAFTING_TABLE,Items.OAK_BOAT,Items.BOOK)){
c.runOnClient(m->{m.gui.setScreen(new InventoryScreen(m.player));var r=SanctuaryJeiPlugin.runtime();r.getRecipesGui().show(r.getJeiHelpers().getFocusFactory().createFocus(RecipeIngredientRole.OUTPUT,VanillaTypes.ITEM_STACK,item.getDefaultInstance()));});
c.waitFor(m->m.gui.screen() instanceof mezz.jei.gui.recipes.RecipesGui,100);c.waitTicks(3);c.takeScreenshot("sanctuary-beta036-blueprint-"+net.minecraft.core.registries.BuiltInRegistries.ITEM.getKey(item).getPath());c.runOnClient(m->m.gui.setScreen(null));
}
System.out.println("BLUEPRINT036 passed: Archery, Woodworking, Boats and Rafts, Books and Storage; real JEI searchable outputs + focused recipe screens; undiscovered inputs/other objects hidden; no invented possessions; inhabitant permissions.");
}
}
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 Button button(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);}
private static void require(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,115 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.boats.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.entity.CampfireBlockEntity;
import net.minecraft.client.renderer.blockentity.state.*;
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
import fr.koka.sanctuary.client.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
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.minecraft.client.CameraType;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.Vec3;
public final class BoatsHead049ClientChecks 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);
widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b,"sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());onboarding.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{
s.setName("BoatsHead049 "+UUID.randomUUID());s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setAllowCommands(true);
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);
for(String locale:new String[]{"en_us","fr_fr"})c.runOnClient(m->{
m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
for(var v:CrewBoats.variants()){
var boat=v.type().create(m.level,EntitySpawnReason.COMMAND);
require(m.getEntityRenderDispatcher().getRenderer(boat)!=null,"Native renderer for "+v.wood());
var mesh=TiledBoatModel.hull(v.columns(),v.rows(),v.raft()).bakeRoot();
require(mesh.getAllParts().size()==v.tiles()+3+(v.raft()?0:2*(v.columns()+v.rows())+4),"One floor per cell, only exterior rails and two paddles");
require(mesh.getAllParts().stream().allMatch(part->part.xScale==1&&part.yScale==1&&part.zScale==1),"No stretched model part");
require(mesh.hasChild("left_paddle")&&mesh.hasChild("right_paddle"),"Exactly one native pair of paddles");
require(TiledBoatModel.waterMask(v.columns(),v.rows()).bakeRoot().getAllParts().size()==v.tiles()+1,"Water mask covers every floor tile");
require(!new ItemStack(v.item()).getHoverName().getString().startsWith("item.sanctuary"),"Localized item name "+locale);
}
});
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(),"sanctuary time vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");
p.setPos(p.position().add(0,2,0));
AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,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.runOnClient(m->m.options.setCameraType(CameraType.THIRD_PERSON_FRONT));c.getInput().pressKey(InputConstants.KEY_F1);c.getInput().lookAt(0,0);c.waitTicks(15);
c.runOnClient(m->{var h=head(m);require(h.blockEntity instanceof CampfireRenderState fire&&fire.items.stream().anyMatch(i->!i.isEmpty()),"Actual campfire renderer receives network food");});
c.takeScreenshot("sanctuary-beta049-campfire-food");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,new ItemStack(Items.CHEST));HeadBlockService.session(p).event(1,1);});
c.waitTicks(15);
c.runOnClient(m->{var h=head(m);require(h.blockEntity instanceof ChestRenderState chest&&chest.open>.7,"Native lid animation opens on client from server event");});
c.takeScreenshot("sanctuary-beta049-chest-open");
server.runOnServer(s->HeadBlockService.session(s.getPlayerList().getPlayers().getFirst()).event(1,0));c.waitTicks(15);
c.runOnClient(m->{var h=head(m);require(h.blockEntity instanceof ChestRenderState chest&&chest.open<.05,"Native lid closes without recreating the proxy");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,new ItemStack(Items.SHULKER_BOX));HeadBlockService.session(p).event(1,1);});
c.waitTicks(15);
c.runOnClient(m->require(head(m).blockEntity instanceof ShulkerBoxRenderState box&&box.progress>.9,"Shulker opens visually without running client block collisions"));
c.takeScreenshot("sanctuary-beta049-shulker-open");
server.runOnServer(s->HeadBlockService.session(s.getPlayerList().getPlayers().getFirst()).event(1,0));c.waitTicks(15);
c.runOnClient(m->require(head(m).blockEntity instanceof ShulkerBoxRenderState box&&box.progress<.05,"Shulker lid closes"));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,new ItemStack(Items.BELL));HeadBlockService.session(p).event(1,net.minecraft.core.Direction.NORTH.get3DDataValue());});
c.waitTicks(12);c.runOnClient(m->require(head(m).blockEntity instanceof BellRenderState bell&&bell.ticks>1,"Bell ring animates over successive ticks"));
c.waitTicks(50);c.runOnClient(m->require(head(m).blockEntity instanceof BellRenderState bell&&bell.shakeDirection==null,"Bell ringing stops"));
for(var item:List.of(Items.FURNACE,Items.PLAYER_HEAD,Items.OAK_TRAPDOOR,CrewBoats.variant("oak",3,1).item())){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,new ItemStack(item));HeadBlockService.session(p);});
c.waitTicks(12);
c.runOnClient(m->{var h=head(m);if(item instanceof net.minecraft.world.item.BoatItem)require(h.entity instanceof net.minecraft.client.renderer.entity.state.BoatRenderState,"Boat hat uses its entity model");else require(h.blockEntity!=null||!h.block.isEmpty(),"Native head visual exists");});
c.takeScreenshot("sanctuary-beta049-head-"+net.minecraft.core.registries.BuiltInRegistries.ITEM.getKey(item).getPath());
}
server.runOnServer(s->AccessoryAccess.of(s.getPlayerList().getPlayers().getFirst()).clearContent());
c.runOnClient(m->m.options.setCameraType(CameraType.THIRD_PERSON_BACK));
for(var shape:List.of(new CrewBoats.Shape(3,1),new CrewBoats.Shape(1,3),new CrewBoats.Shape(2,3),new CrewBoats.Shape(3,2))){
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var old=p.getVehicle();p.stopRiding();if(old!=null)old.getSelfAndPassengers().toList().reversed().forEach(Entity::discard);
var boat=CrewBoats.variant("oak",shape.columns(),shape.rows()).type().create(s.overworld(),EntitySpawnReason.COMMAND);boat.setPos(p.position().add(0,8,0));s.overworld().addFreshEntity(boat);
var motor=EntityTypes.ALLAY.create(s.overworld(),EntitySpawnReason.COMMAND);motor.setPos(boat.position());motor.setNoAi(true);s.overworld().addFreshEntity(motor);motor.startRiding(boat);p.startRiding(boat,true,true);
});
c.waitFor(m->m.player.getVehicle() instanceof CrewBoat b&&b.columns()==shape.columns()&&b.rows()==shape.rows(),200);
c.getInput().holdKey(o->o.keyJump);c.getInput().holdKey(o->o.keyUp);c.waitTicks(20);
Vec3 from=c.computeOnClient(m->m.player.getVehicle().position());c.waitTicks(40);Vec3 to=c.computeOnClient(m->m.player.getVehicle().position());
require(to.y>from.y+2&&to.subtract(from).horizontalDistance()>5,"Each rectangular boat flies from native pilot inputs");
c.getInput().lookAt(25,20);c.waitTicks(5);c.takeScreenshot("sanctuary-beta049-boat-"+shape.columns()+"x"+shape.rows());
c.getInput().releaseKey(o->o.keyUp);c.getInput().releaseKey(o->o.keyJump);
}
System.out.println("BOATS_HEAD049_CLIENT_PASS: 66 tiled models FR/EN, actual campfire food, chest and Shulker lids open/close, head models, four rectangular flights");
}
}
private static HeadCosmeticState head(net.minecraft.client.Minecraft m){
var renderer=m.getEntityRenderDispatcher().getRenderer(m.player);
var state=renderer.createRenderState(m.player,1);
return ((HeadCosmeticState.Access)state).sanctuary$headCosmetic();
}
private static Stream<GuiEventListener> widgets(ContainerEventHandler root){return root.children().stream().flatMap(w->w instanceof ContainerEventHandler child?Stream.concat(Stream.of(w),widgets(child)):Stream.of(w));}
private static boolean key(Button button,String key){return button.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean yes,String message){if(!yes)throw new AssertionError(message);}
}
@@ -0,0 +1,111 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.building.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.input.MouseButtonInfo;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.stats.Stats;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
/** Real native input and network packets, with a fresh development-only wall. */
final class Building009ClientChecks {
static void run(ClientGameTestContext c,TestServerContext server) {
server.runOnServer(s -> s.getPlayerList().getPlayers().getFirst().giveExperienceLevels(3));
c.waitFor(m -> m.player.experienceLevel==3,400);
for (int rank=1;rank<=2;rank++) {
c.runOnClient(m -> {
var button=m.gui.screen().children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals("sanctuary.progression.upgrade_cost"))
.sorted(java.util.Comparator.comparingInt(Button::getY)).skip(3).findFirst().orElseThrow();
require(button.active,"Construction is purchasable in the skill column");
double x=(button.getX()+button.getWidth()/2.)*m.getWindow().getScreenWidth()/m.getWindow().getGuiScaledWidth();
double y=(button.getY()+button.getHeight()/2.)*m.getWindow().getScreenHeight()/m.getWindow().getGuiScaledHeight();
m.mouseHandler.onMove(m.getWindow().handle(),x,y,0,0);m.mouseHandler.handleAccumulatedMovement();
var mouse=new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,0);
m.mouseHandler.onButton(m.getWindow().handle(),mouse,InputConstants.PRESS);
m.mouseHandler.onButton(m.getWindow().handle(),mouse,InputConstants.RELEASE);
});
int expected=rank;c.waitFor(m -> ProgressionClient.progress.building()==expected,400);
}
c.waitFor(m -> m.player.experienceLevel==0,400);
Blocodex004ClientChecks.language(c,"fr_fr");c.takeScreenshot("sanctuary-beta009-construction-fr");
Blocodex004ClientChecks.language(c,"en_us");c.clickScreenButton("gui.done");c.waitForScreen(null);
var origin=server.computeOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();var base=p.blockPosition().above(14);
for(int x=-3;x<=3;x++) for(int z=-1;z<=5;z++) {
p.level().setBlockAndUpdate(base.offset(x,-1,z),Blocks.BEDROCK.defaultBlockState());
for(int y=0;y<=6;y++) p.level().setBlockAndUpdate(base.offset(x,y,z),Blocks.AIR.defaultBlockState());
}
var target=base.offset(0,2,3);
for(int x=-2;x<=2;x++) for(int y=-2;y<=2;y++) p.level().setBlockAndUpdate(target.offset(x,y,0),Blocks.STONE.defaultBlockState());
p.teleportTo(base.getX()+.5,base.getY(),base.getZ()+.5);
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.COBBLESTONE,32));p.inventoryMenu.broadcastChanges();
return target;
});
c.waitFor(m -> m.level.getBlockState(origin).is(Blocks.STONE) && m.player.getMainHandItem().is(Items.COBBLESTONE)
&& Math.abs(m.player.getY()-(origin.getY()-2))<.1,600);
c.getInput().lookAt(origin);c.waitTicks(3);c.getInput().holdKey(InputConstants.KEY_R);
c.waitFor(m -> BuildingClient.previewSize()==25 && MiningClient.previewSize()==0,200);
c.takeScreenshot("sanctuary-beta009-building-preview-25");
c.runOnClient(m -> {
int slot=m.player.getInventory().getSelectedSlot();m.mouseHandler.onScroll(m.getWindow().handle(),0,-1);
require(BuildingClient.selectedRank()==1 && m.player.getInventory().getSelectedSlot()==slot,"Shared modifier wheel selects a 3x3 surface without changing material");
});
c.waitFor(m -> BuildingClient.previewSize()==9,100);
c.runOnClient(m -> m.mouseHandler.onScroll(m.getWindow().handle(),0,1));
c.waitFor(m -> BuildingClient.previewSize()==25,100);
c.getInput().holdKey(o -> o.keyUse);
c.waitFor(m -> m.level.getBlockState(origin.north()).is(Blocks.COBBLESTONE),300);
c.getInput().releaseKey(o -> o.keyUse);
server.waitFor(s -> !BuildingManager.active(s.getPlayerList().getPlayers().getFirst()),200);
int first=server.computeOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();int used=32-p.getMainHandItem().getCount();
require(used>0 && used<25,"Releasing right click keeps only the paid partial surface");
return used;
});
c.getInput().releaseKey(InputConstants.KEY_R);resetWall(c,server,origin);
c.getInput().lookAt(origin);c.waitTicks(3);c.getInput().holdKey(InputConstants.KEY_R);
c.waitFor(m -> BuildingClient.previewSize()==25,200);c.getInput().holdKey(o -> o.keyUse);
server.waitFor(s -> s.getPlayerList().getPlayers().getFirst().getMainHandItem().getCount()==7,600);
c.waitTicks(10); // Holding beyond completion must not create a second layer.
server.runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();
require(p.getMainHandItem().getCount()==7 && !BuildingManager.active(p),"One gesture creates exactly one 25-block surface");
require(p.getStats().getValue(Stats.ITEM_USED.get(Items.COBBLESTONE))==25+first,"Native placement statistics count every paid block once");
for(int x=-2;x<=2;x++) for(int y=-2;y<=2;y++) require(p.level().getBlockState(origin.offset(x,y,-1)).is(Blocks.COBBLESTONE),"Full surface matches the preview");
});
c.getInput().releaseKey(o -> o.keyUse);c.getInput().releaseKey(InputConstants.KEY_R);c.waitTicks(3);
c.takeScreenshot("sanctuary-beta009-built-wall");resetWall(c,server,origin);c.getInput().lookAt(origin);c.waitTicks(3);
c.runOnClient(m -> {sharedKey(m).setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_G));KeyMapping.resetMapping();});
try {
c.getInput().holdKey(InputConstants.KEY_G);c.waitFor(m -> BuildingClient.previewSize()==25,200);
c.getInput().holdKey(o -> o.keyUse);
server.waitFor(s -> BuildingManager.active(s.getPlayerList().getPlayers().getFirst()),200);
c.getInput().pressKey(InputConstants.KEY_B);c.waitForScreen(ProgressionScreen.class);
server.waitFor(s -> !BuildingManager.active(s.getPlayerList().getPlayers().getFirst()),200);
} finally {
c.getInput().releaseKey(o -> o.keyUse);c.getInput().releaseKey(InputConstants.KEY_R);c.getInput().releaseKey(InputConstants.KEY_G);
c.runOnClient(m -> {sharedKey(m).setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_R));KeyMapping.resetMapping();});
}
System.out.println("Building beta009 native client passed: two real purchases, 25/9 previews, wheel, release, exact consumption, one frozen layer, key rebind and Blocodex interruption.");
}
private static void resetWall(ClientGameTestContext c,TestServerContext server,BlockPos origin) {
server.runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();
for(int x=-2;x<=2;x++) for(int y=-2;y<=2;y++) p.level().setBlockAndUpdate(origin.offset(x,y,-1),Blocks.AIR.defaultBlockState());
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.COBBLESTONE,32));p.inventoryMenu.broadcastChanges();
});
c.waitFor(m -> m.level.getBlockState(origin.north()).isAir() && m.player.getMainHandItem().getCount()==32,300);
}
private static KeyMapping sharedKey(net.minecraft.client.Minecraft m) {
return java.util.Arrays.stream(m.options.keyMappings).filter(k -> k.getName().equals("sanctuary.mining.key")).findFirst().orElseThrow();
}
private static void require(boolean value,String message) {if(!value) throw new AssertionError(message);}
}
@@ -0,0 +1,121 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.recipes.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.inventory.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
import mezz.jei.api.constants.RecipeTypes;
import mezz.jei.api.sanctuary.CatalogueAccess;
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.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.inventory.InventoryScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.item.*;
public final class Collections035ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c){
var onboarding=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(onboarding.get()&&m.gui.screen() instanceof HelloWorldScreen 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());onboarding.set(false);
}});
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
int scale=c.computeOnClient(m->m.options.guiScale().get());
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sanctuary beta035 recipes "+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()){
saved=world.getWorldSave();
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&RecipeClient.ready&&SanctuaryJeiPlugin.runtime()!=null&&m.gui.screen()==null,1200);
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.gui.setScreen(new InventoryScreen(m.player));});c.waitTicks(6);
c.runOnClient(m->{require(!RecipeClient.allowed(),"Catalogue initially locked");require(!SanctuaryJeiPlugin.runtime().getIngredientListOverlay().isListDisplayed(),"Locked catalogue has no sidebar");SanctuaryJeiPlugin.runtime().getRecipesGui().showTypes(List.of(RecipeTypes.CRAFTING));require(m.gui.screen() instanceof InventoryScreen,"JEI cannot open while locked");});
c.takeScreenshot("sanctuary-beta035-locked-inventory");
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();p.getInventory().setItem(0,new ItemStack(Items.OAK_PLANKS,8));p.giveExperienceLevels(4-p.experienceLevel);RecipeService.refresh(p);});
c.waitFor(m->RecipeClient.knowledge.recipes.contains("minecraft:crafting_table")&&RecipeClient.xp>=4,120);
c.runOnClient(m->{require(!RecipeClient.knowledge.recipes.contains("minecraft:shield"),"Plank discovery does not teach shield");m.gui.setScreen(new RecipeScreen(new net.minecraft.client.gui.screens.PauseScreen(true)));});c.waitTicks(3);c.takeScreenshot("sanctuary-beta035-collections-locked");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.recipes.buy").onPress(enter()));c.waitFor(m->RecipeClient.allowed()&&RecipeClient.knowledge.learned(),120);
c.runOnClient(m->{var r=SanctuaryJeiPlugin.runtime();require(r.getIngredientFilter().getFilteredItemStacks().stream().noneMatch(i->i.is(Items.DIAMOND_BLOCK)),"Unknown item hidden");
require(r.getIngredientFilter().getFilteredIngredients(mezz.jei.api.fabric.constants.FabricTypes.FLUID_STACK).isEmpty(),"Unknown fluids hidden too");
require(r.getRecipeManager().createRecipeCategoryLookup().get().anyMatch(cat->cat.getRecipeType().equals(RecipeTypes.CRAFTING)),"Learned blueprints appear before discovering their crafting station");
require(r.getRecipeManager().createRecipeLookup(RecipeTypes.CRAFTING).get().anyMatch(v->v.id().identifier().toString().equals("minecraft:crafting_table")),"Learned wood recipe visible");
require(r.getRecipeManager().createRecipeLookup(RecipeTypes.CRAFTING).get().noneMatch(v->v.id().identifier().toString().equals("minecraft:shield")),"Unknown recipe hidden");
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");});
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.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);
p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);p.getInventory().clearContent();p.getInventory().setItem(SanctuaryInventoryCapacity.EXTRA_START+17,new ItemStack(Items.OAK_PLANKS,8));p.inventoryMenu.broadcastChanges();ProgressionService.send(p,"");});
c.waitFor(m->ProgressionClient.progress.inventoryRows()==6&&m.player.getInventory().getItem(SanctuaryInventoryCapacity.EXTRA_START+17).getCount()==8,120);
c.runOnClient(m->{m.gui.setScreen(new InventoryScreen(m.player));var menu=m.player.inventoryMenu;var slots=CatalogueAccess.inventory.apply(menu,List.of());require(slots.size()==54,"JEI sees all six unlocked storage rows");
var r=SanctuaryJeiPlugin.runtime();var manager=r.getRecipeManager();var category=manager.getRecipeCategory(RecipeTypes.CRAFTING);
var recipe=manager.createRecipeLookup(RecipeTypes.CRAFTING).get().filter(v->v.id().identifier().toString().equals("minecraft:crafting_table")).findFirst().orElseThrow();
var layout=manager.createRecipeLayoutDrawable(category,recipe,r.getJeiHelpers().getFocusFactory().createFocusGroup(List.of())).orElseThrow();
var handler=r.getRecipeTransferManager().getRecipeTransferHandler(menu,category).orElseThrow();
require(handler.transferRecipe(menu,recipe,layout.getRecipeSlotsView(),m.player,false,true)==null,"Real JEI transfer accepts overflow ingredients");});
c.waitFor(m->m.player.inventoryMenu.getSlot(0).getItem().is(Items.CRAFTING_TABLE),120);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();require(p.getInventory().getItem(SanctuaryInventoryCapacity.EXTRA_START+17).getCount()==4,"Exactly four planks consumed from row six");
for(int i=1;i<=4;i++)require(p.inventoryMenu.getSlot(i).getItem().is(Items.OAK_PLANKS)&&p.inventoryMenu.getSlot(i).getItem().getCount()==1,"Grid receives one plank per slot");});
c.takeScreenshot("sanctuary-beta035-overflow-transfer");
c.runOnClient(m->{m.player.closeContainer();NotificationClient.clearFeed();});c.waitTicks(3);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
for(String id:List.of("minecraft:story/smelt_iron","minecraft:nether/brew_potion","minecraft:end/find_end_city")){
var a=s.getAdvancements().get(net.minecraft.resources.Identifier.parse(id));for(String criterion:a.value().criteria().keySet())p.getAdvancements().award(a,criterion);
}
RecipeService.refresh(p);
});
c.waitFor(m->RecipeClient.knowledge.collections.containsAll(List.of("sanctuary:ironworking","sanctuary:brewing","sanctuary:shulker_storage")),200);
c.runOnClient(m->{
require(!RecipeClient.knowledge.items.contains("minecraft:iron_ingot")&&!RecipeClient.knowledge.items.contains("minecraft:shulker_shell"),"Advancement unlocks plans without revealing their ingredients as discovered");
require(RecipeClient.knowledge.recipes.contains("minecraft:shield")&&RecipeClient.knowledge.recipes.contains("minecraft:shulker_box"),"Full families arrive over the actual personal network");
require(SanctuaryJeiPlugin.runtime().getRecipeManager().createRecipeLookup(RecipeTypes.CRAFTING).get().anyMatch(r->r.id().identifier().toString().equals("minecraft:shulker_box")),"Shulker blueprint visible without shell possession");
require(NotificationClient.messages().stream().anyMatch(e->e.value().text().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.notifications.collection_batch")),"New collection batch is announced");
});
c.takeScreenshot("sanctuary-beta035-collection-notifications");
for(String language:List.of("fr_fr","en_us")){
var reload=c.computeOnClient(m->{m.getLanguageManager().setSelected(language);m.options.languageCode=language;return m.reloadResourcePacks();});c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();
c.runOnClient(m->{m.gui.setScreen(new RecipeScreen(null));require(RecipeClient.collectionName("sanctuary:ironworking").getString().equals(language.equals("fr_fr")?"Fer et forge de base":"Ironworking"),"Selected language is rendered");for(String id:RecipeClient.knowledge.collections)require(!RecipeClient.collectionName(id).getString().startsWith("sanctuary"),"Collections are translated");});
c.waitTicks(5);c.takeScreenshot("sanctuary-beta035-collections-"+language);
}
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var progress=ProgressEffects.get(p);
for(String skill:ProgressRecord.SKILLS)while(progress.rank(skill)<ProgressRecord.cap(skill))progress=progress.buy(skill,1);
p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);
var action=new fr.koka.sanctuary.cycle.CyclePayloads.Action("ngp","","",ProgressionService.session(s).cycles.revision(),progress.revision());
require(fr.koka.sanctuary.cycle.CycleService.perform(p,action).equals("done"),"Real New Game+ succeeds");RecipeService.refresh(p);});
c.waitFor(m->ProgressionClient.progress.inventoryRows()==1&&ProgressionClient.progress.cycle()==1,120);
c.runOnClient(m->{require(CatalogueAccess.inventory.apply(m.player.inventoryMenu,List.of()).size()==9,"After reset, locked recovery rows excluded from JEI autofill");require(RecipeClient.knowledge.learned(),"Catalogue persists after skill reset");
m.options.guiScale().set(4);m.resizeGui();m.gui.setScreen(new RecipeScreen(null));});c.waitTicks(4);c.takeScreenshot("sanctuary-beta035-compact-blocodex");
c.runOnClient(m->m.gui.setScreen(null));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.hurtServer(p.level(),p.damageSources().fellOutOfWorld(),Float.MAX_VALUE);});
c.waitForScreen(net.minecraft.client.gui.screens.DeathScreen.class);c.waitTicks(45);c.clickScreenButton("deathScreen.respawn");
c.waitFor(m->m.player!=null&&m.player.isAlive()&&m.gui.screen()==null&&ProgressionClient.active(),1200);c.waitTicks(25);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();require(RecipeKnowledge.parse(p.getAttached(RecipeService.RECORD)).learned(),"Real death preserves catalogue attachment");s.saveEverything(true,true,true);});
System.out.println("COLLECTIONS035: locked UI, personal discoveries, recipes, operator revocation and real six-row transfer passed.");
}finally{onboarding.set(false);c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();});}
c.runOnClient(m->require(!RecipeClient.ready&&!RecipeClient.allowed(),"Recipe state cleared on world exit"));
try(var reopened=saved.open()){
reopened.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&RecipeClient.ready&&SanctuaryJeiPlugin.runtime()!=null,1200);
c.runOnClient(m->{require(RecipeClient.knowledge.learned()&&RecipeClient.knowledge.recipes.contains("minecraft:crafting_table"),"Catalogue and recipes survive server restart");require(!RecipeClient.operator,"Operator inspection is not persisted");require(RecipeClient.knowledge.collections.containsAll(List.of("sanctuary:ironworking","sanctuary:brewing","sanctuary:shulker_storage"))&&RecipeClient.knowledge.recipes.contains("minecraft:shulker_box"),"New collections survive actual prestige, death and world restart");});
System.out.println("COLLECTIONS035: actual prestige, death and server restart preserve recipe knowledge.");
}
}
private static void hover(ClientGameTestContext c,int index){c.runOnClient(m->{var screen=(net.minecraft.client.gui.screens.inventory.AbstractContainerScreen<?>)m.gui.screen();var a=(fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor)screen;var slot=screen.getMenu().getSlot(index);var w=m.getWindow();double x=a.sanctuary$getLeftPos()+slot.x+8.0,y=a.sanctuary$getTopPos()+slot.y+8.0;double sx=(double)w.getScreenWidth()/w.getGuiScaledWidth(),sy=(double)w.getScreenHeight()/w.getGuiScaledHeight();m.mouseHandler.onMove(w.handle(),x*sx,y*sy,(x-m.mouseHandler.getScaledXPos(w))*sx,(y-m.mouseHandler.getScaledYPos(w))*sy);m.mouseHandler.handleAccumulatedMovement();});c.waitTicks(2);}
private static void require(boolean value,String message){if(!value)throw new AssertionError(message);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static Stream<GuiEventListener> widgets(ContainerEventHandler c){return c.children().stream().flatMap(w->w instanceof ContainerEventHandler nested?Stream.concat(Stream.of(w),widgets(nested)):Stream.of(w));}
private static Button button(Screen screen,String key){return widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(w->w.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow(()->new AssertionError("Missing "+key));}
}
@@ -0,0 +1,82 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.companion.*;
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.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
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.level.ServerPlayer;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
/** Integrated development world: native AI, networking and persistence, without a dedicated server EULA. */
public final class Combat054ClientChecks 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);
}});
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
var individual=new java.util.concurrent.atomic.AtomicReference<UUID>();
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Combat054 "+UUID.randomUUID());s.setSeed("54");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setAllowCommands(true);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
saved=world.getWorldSave();world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
world.getServer().runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;
require(FamiliarBattle.world(s)!=null&&FamiliarBattle.world(s).catalog.profiles().size()==88,"All 88 server profiles loaded");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set night");
equip(p,"wolf");
});
c.waitTicks(25);
world.getServer().runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);
require(pet!=null,"Physical familiar spawns");individual.set(FamiliarBattle.bond(p).id());
var zombie=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);zombie.setNoAi(true);zombie.setPos(pet.position().add(0,0,2));p.level().addFreshEntity(zombie);
FamiliarBattle.engage(p,zombie,false);
require(FamiliarBattle.state(p).target.equals(zombie.getUUID()),"Engage binds an enemy");
});
c.waitTicks(100);
world.getServer().runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var target=FamiliarBattle.target(p);
require(target!=null&&target.getHealth()<target.getMaxHealth(),"Native familiar navigation reaches and damages its enemy");
var pet=FamiliarService.active(p);var b=FamiliarBattle.bond(p);
pet.hurtServer(p.level(),pet.damageSources().mobAttack(target),3);
require(FamiliarBattle.bond(p).health()<b.health(),"Hostile damage persists on individual");
target.discard();FamiliarBattle.cancel(p);FamiliarBattle.flush(s);s.saveEverything(true,true,true);
});
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(6);c.takeScreenshot("sanctuary-beta054-familiar-"+locale);c.runOnClient(m->m.gui.setScreen(null));
}
}
try(var world=saved.open()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();require(individual.get().equals(FamiliarBattle.bond(p).id()),"Same individual after reconnect");require(FamiliarBattle.bond(p).health()<FamiliarBattle.profile(p).health(),"Reconnect does not fully heal");});
Duel054Checks.run(c,world.getServer());
Experience054Checks.run(c,world.getServer());
}
try(var world=saved.open()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
world.getServer().runOnServer(s->Experience054Checks.reconnect(s.getPlayerList().getPlayers().getFirst()));
}
System.out.println("COMBAT054_CORE_PASS: physical autonomous attack, wound persistence, 88 profiles, FR/EN interface and real reconnect");
}
static void equip(ServerPlayer p,String id){
FamiliarBattle.release(p);var type=net.minecraft.core.registries.BuiltInRegistries.ENTITY_TYPE.getValue(net.minecraft.resources.Identifier.withDefaultNamespace(id));
var egg=new ItemStack(SpawnEggItem.byId(type).orElseThrow());AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);
CompanionService.tick(p);p.tickCount+=10;FamiliarService.tick(p);
}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
static void require(boolean value,String text){if(!value)throw new AssertionError(text);}
}
@@ -0,0 +1,143 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
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.components.events.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.Vec3;
public final class Companion019ClientChecks 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 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());onboarding.set(false);
}
});
int scale=c.computeOnClient(m->m.options.guiScale().get());
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sanctuary beta019 familiars "+UUID.randomUUID());s.setSeed("42");
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);}).create()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&CompanionClient.record!=null&&m.gui.screen()==null,1200);
var server=world.getServer();server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var at=p.blockPosition().above(5);
for(int x=-9;x<=9;x++)for(int z=-9;z<=9;z++)for(int y=-1;y<8;y++)p.level().setBlockAndUpdate(at.offset(x,y,z),(y==-1?Blocks.STONE:Blocks.AIR).defaultBlockState());
p.teleportTo(at.getX()+.5,at.getY(),at.getZ()+.5);p.setYRot(0);p.setXRot(-8);
p.getInventory().clearContent();p.giveExperienceLevels(10-p.experienceLevel);AccessoryAccess.of(p).setItem(0,new ItemStack(Items.CAT_SPAWN_EGG));CompanionService.send(p,"");
});
c.waitFor(m->CompanionClient.species==CompanionType.CAT,200);
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.gui.setScreen(new ProgressionScreen(null));});c.waitTick();
c.runOnClient(m->{require(!CompanionClient.record.unlocked(),"First session starts with active bond locked");button(m.gui.screen(),"sanctuary.companion.buy").onPress(enter());});
c.waitFor(m->CompanionClient.record!=null&&CompanionClient.record.unlocked(),200);
require(server.computeOnServer(s->s.getPlayerList().getPlayers().getFirst().experienceLevel)==6,"Purchase is synchronized and costs four levels");
c.runOnClient(m->CompanionClient.openInhabitant(null));c.waitTick();
c.runOnClient(m->button(m.gui.screen(),"sanctuary.companion.consent_on").onPress(enter()));c.waitFor(m->!CompanionClient.record.acceptAllies(),200);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.companion.consent_off").onPress(enter()));c.waitFor(m->CompanionClient.record.acceptAllies(),200);
for(int gui:new int[]{2,3,4}){
c.runOnClient(m->{m.options.guiScale().set(gui);m.resizeGui();});c.waitTick();
c.runOnClient(m->{for(var widget:widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).toList())
require(widget.getWidth()>0&&widget.getX()>=0&&widget.getRight()<=m.gui.screen().width,"Familiar buttons fit GUI width");});
c.takeScreenshot("sanctuary-beta019-familiar-gui-"+gui);
}
c.runOnClient(m->{m.gui.setScreen(null);m.options.guiScale().set(2);m.resizeGui();});c.waitTick();
// Extract all native render states at an actual nonzero world position, at two animation times.
c.runOnClient(m->{
try{
var field=FamiliarEntity.class.getDeclaredField("EGG");field.setAccessible(true);
@SuppressWarnings("unchecked") var eggData=(net.minecraft.network.syncher.EntityDataAccessor<ItemStack>)field.get(null);
var pet=FamiliarService.TYPE.create(m.level,EntitySpawnReason.COMMAND);pet.setId(900000);pet.setPos(m.player.position().add(2,1,3));
pet.xo=pet.getX();pet.yo=pet.getY();pet.zo=pet.getZ();pet.xOld=pet.getX();pet.yOld=pet.getY();pet.zOld=pet.getZ();
var renderer=(FamiliarRenderer)m.getEntityRenderDispatcher().getRenderer(pet);
for(var type:CompanionType.values()){
pet.getEntityData().set(eggData,new ItemStack(BuiltInRegistries.ITEM.getValue(Identifier.withDefaultNamespace(type.id()+"_spawn_egg"))));
pet.tickCount=100;pet.setDeltaMovement(.05,0,.1);pet.setYRot(30);
var state=renderer.createRenderState();renderer.extractRenderState(pet,state,.5F);
require(state.appearance!=null&&state.renderer!=null,"Native visual exists for "+type);
require(net.minecraft.util.LightCoordsUtil.sky(state.appearance.lightCoords)==m.level.getBrightness(net.minecraft.world.level.LightLayer.SKY,pet.blockPosition()),"Native light follows familiar world position for "+type);
pet.tickCount=110;pet.setYRot(65);renderer.extractRenderState(pet,state,.5F);
require(Float.isFinite(state.size)&&state.size>0,"Native visual keeps finite scale for "+type);
if(pet.motion().flies()){
var camera=new net.minecraft.client.renderer.state.level.CameraRenderState();camera.pos=pet.position().add(1.5,.35,0);camera.orientation=new org.joml.Quaternionf();
var collector=new net.minecraft.client.renderer.SubmitNodeStorage();
renderer.submit(state,new com.mojang.blaze3d.vertex.PoseStack(),collector,camera);
var submits=new ArrayList<net.minecraft.client.renderer.feature.ModelFeatureRenderer.Submit<?>>();
collector.drainPhases(phase->phase.sortInto((node,sorted)->{if(node instanceof net.minecraft.client.renderer.feature.ModelFeatureRenderer.Submit<?> model)submits.add(model);}));
require(!submits.isEmpty(),"Flying model submits translucent geometry: "+type);
for(var model:submits)require(model.renderType().hasBlending()&&(model.tintedColor()>>>24)<100,"Body and eye layers both fade: "+type);
require(FamiliarFade.current()==1,"Transparency never leaks into the next entity");
}
float ordinarySize=state.size;
for(int perMille:new int[]{120,6000}){
var sized=pet.egg().copy();net.minecraft.world.item.component.CustomData.update(net.minecraft.core.component.DataComponents.CUSTOM_DATA,sized,tag->{
var trait=new net.minecraft.nbt.CompoundTag();trait.putInt("schema",1);trait.putInt("per_mille",perMille);tag.put(fr.koka.sanctuary.cosmetics.FamiliarSize.KEY,trait);
});pet.getEntityData().set(eggData,sized);renderer.extractRenderState(pet,state,.5F);
require(Math.abs(state.size-ordinarySize*perMille/1000F)<.0001,"Smallest and largest egg scale applies to native model "+type);
}
}
}catch(ReflectiveOperationException error){throw new AssertionError(error);}
});
for(var species:List.of(CompanionType.ENDER_DRAGON,CompanionType.WITHER,CompanionType.GHAST)){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();AccessoryAccess.of(p).setItem(0,new ItemStack(BuiltInRegistries.ITEM.getValue(Identifier.withDefaultNamespace(species.id()+"_spawn_egg"))));});
c.waitFor(m->CompanionClient.species==species,200);c.waitTicks(60);
var geometry=server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);return new double[]{pet.getY()-p.getY(),pet.getX(),pet.getZ()};});
require(geometry[0]>.3,"Airborne familiar stays airborne: "+species);
c.runOnClient(m->{var toward=new Vec3(geometry[1],m.player.getY()+geometry[0]+.35,geometry[2]).subtract(m.player.getEyePosition());
m.player.setYRot((float)Math.toDegrees(Math.atan2(-toward.x,toward.z)));m.player.setXRot((float)-Math.toDegrees(Math.atan2(toward.y,toward.horizontalDistance())));
});c.waitTick();c.takeScreenshot("sanctuary-beta019-"+species.id());
}
for(double distance:new double[]{4,2,.8}){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);pet.setNoAi(true);pet.getNavigation().stop();pet.setDeltaMovement(Vec3.ZERO);pet.setPos(p.getEyePosition().add(0,-.35,distance));});
c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);});c.waitTicks(5);
c.takeScreenshot("sanctuary-beta019-fade-"+distance);
}
// Real client key -> server activation -> durable cooldown, followed by an egg swap.
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();AccessoryAccess.of(p).setItem(0,new ItemStack(Items.SLIME_SPAWN_EGG));});
c.waitFor(m->CompanionClient.species==CompanionType.SLIME&&CompanionClient.attunement()==0,400);
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_G);
c.waitFor(m->CompanionClient.seconds()>0,100);
require(server.computeOnServer(s->CompanionEffects.has(s.getPlayerList().getPlayers().getFirst(),CompanionEffects.Kind.REBOUND)),"Configured key activates the actual equipped power");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();AccessoryAccess.of(p).setItem(0,new ItemStack(Items.CAT_SPAWN_EGG));});
c.waitFor(m->CompanionClient.species==CompanionType.CAT,100);
require(CompanionClient.seconds()>0,"Changing egg does not reset the client cooldown");
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();AccessoryAccess.of(p).setItem(0,new ItemStack(Items.VILLAGER_SPAWN_EGG));CompanionService.tick(p);CompanionService.runtime(p).equippedAt=p.tickCount-200;
var old=CompanionService.record(p);CompanionService.save(p,new CompanionRecord(1,old.unlocked(),old.paid(),old.purchasedAt(),0,0,0,old.acceptAllies(),old.notes()));
p.getInventory().setItem(0,new ItemStack(Items.EMERALD,10));p.setYRot(0);p.setXRot(0);
var v=EntityTypes.VILLAGER.create(p.level(),EntitySpawnReason.COMMAND);v.setNoAi(true);v.setPos(p.position().add(0,0,2));
var offers=new net.minecraft.world.item.trading.MerchantOffers();offers.add(new net.minecraft.world.item.trading.MerchantOffer(new net.minecraft.world.item.trading.ItemCost(Items.EMERALD,2),new ItemStack(Items.BREAD),2,1,0));v.setOffers(offers);p.level().addFreshEntity(v);CompanionService.send(p,"");
});
c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);});
c.waitFor(m->CompanionClient.species==CompanionType.VILLAGER&&CompanionClient.attunement()==0,100);c.waitTicks(15);
c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_G);
c.waitFor(m->m.gui.screen() instanceof CompanionChoiceScreen||!CompanionClient.message.isEmpty(),100);
require(c.computeOnClient(m->m.gui.screen() instanceof CompanionChoiceScreen),"Quote opens; server response: "+CompanionClient.message);
c.takeScreenshot("sanctuary-beta019-trade-quote");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.companion.choose").onPress(enter()));
c.waitFor(m->m.gui.screen()==null,100);
require(server.computeOnServer(s->new CompanionInventory.Plan(s.getPlayerList().getPlayers().getFirst()).count(i->i.is(Items.BREAD)))==2,"The reviewed client quote produces two paid trades");
require(!CompanionClient.hasChoice(),"A successful quote disappears from the menu");
}finally{c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();});}
}
private static Stream<GuiEventListener> widgets(ContainerEventHandler container){return container.children().stream().flatMap(w->w instanceof ContainerEventHandler nested?Stream.concat(Stream.of(w),widgets(nested)):Stream.of(w));}
private static Button button(Screen screen,String key){return widgets(screen).filter(Button.class::isInstance).map(Button.class::cast)
.filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow(()->new AssertionError("Missing button "+key));}
private static KeyEvent enter(){return new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);}
private static void require(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,81 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
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.components.events.*;
import net.minecraft.client.gui.screens.Screen;
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.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.resources.Identifier;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.*;
import net.minecraft.world.effect.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
/** Fresh-world real key, network and rendering checks for the v2 abilities. */
public final class Companion032ClientChecks 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 screen){
widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
widgets(screen).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("Sanctuary refonte032 "+UUID.randomUUID());s.setSeed("42");
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);}).create()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&m.gui.screen()==null,1200);var server=world.getServer();
BlockPos origin=server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var at=p.blockPosition().above(80);
for(int x=-12;x<=12;x++)for(int z=-12;z<=12;z++)for(int y=-1;y<7;y++)p.level().setBlockAndUpdate(at.offset(x,y,z),(y==-1?Blocks.STONE:Blocks.AIR).defaultBlockState());
p.teleportTo(at.getX()+.5,at.getY(),at.getZ()+.5);p.getInventory().clearContent();p.setYRot(0);p.setXRot(55);return at;});
c.waitFor(m->m.player.getY()>origin.getY()-.2,200);c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(55);});c.waitTicks(20);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();equip(p,CompanionType.SHEEP);p.getInventory().setItem(0,new ItemStack(Items.WOOL.white(),2));p.inventoryMenu.broadcastChanges();});
c.waitFor(m->CompanionClient.species==CompanionType.SHEEP&&CompanionClient.attunement()==0,200);
c.getInput().pressKey(InputConstants.KEY_G);c.waitFor(m->CompanionClient.seconds()>0,100);
require(server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();return p.getMainHandItem().getCount()==1&&CompanionZones.active(p.level()).stream().anyMatch(z->z.kind==CompanionZones.Kind.CUSHION);}),"Real G input spends one wool and creates the world cushion");
c.waitTicks(10);c.takeScreenshot("sanctuary-beta032-wool-cushion");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();equip(p,CompanionType.WITCH);
var potion=new ItemStack(Items.POTION);potion.set(DataComponents.POTION_CONTENTS,new net.minecraft.world.item.alchemy.PotionContents(Optional.empty(),Optional.empty(),List.of(new MobEffectInstance(MobEffects.SPEED,600,0)),Optional.empty()));p.setItemInHand(InteractionHand.MAIN_HAND,potion);p.setYRot(0);p.setXRot(0);p.inventoryMenu.broadcastChanges();});
c.waitFor(m->CompanionClient.species==CompanionType.WITCH&&CompanionClient.attunement()==0,100);c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(0);});c.waitTicks(10);
c.getInput().pressKey(InputConstants.KEY_G);c.waitFor(m->CompanionClient.seconds()>0,100);c.waitTicks(10);c.takeScreenshot("sanctuary-beta032-potion-mist");
require(server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();return p.getMainHandItem().is(Items.GLASS_BOTTLE)&&CompanionZones.active(p.level()).stream().anyMatch(z->z.kind==CompanionZones.Kind.MIST&&!z.spent);}),"A real dose becomes a visible unused mist and returns one bottle");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var mist=CompanionZones.active(p.level()).stream().filter(z->z.kind==CompanionZones.Kind.MIST).findFirst().orElseThrow();p.teleportTo(mist.center.x,mist.center.y-.8,mist.center.z);});
c.waitFor(m->m.player.hasEffect(MobEffects.SPEED),100);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();equip(p,CompanionType.RABBIT);p.teleportTo(origin.getX()+.5,origin.getY(),origin.getZ()+.5);});
c.waitFor(m->CompanionClient.species==CompanionType.RABBIT&&CompanionClient.attunement()==0,100);c.runOnClient(m->{m.player.setYRot(0);m.player.setXRot(-70);});c.waitTicks(20);
c.getInput().pressKey(InputConstants.KEY_G);c.waitFor(m->m.player.getY()>origin.getY()+.2,100);c.takeScreenshot("sanctuary-beta032-rabbit-hop");
c.waitTicks(45);require(server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();return p.getY()<origin.getY()+2.2;}),"Rabbit hop stays bounded and lands, even with a native speed potion");
// Exercise all 88 FR/EN tooltip paths in the native client; no translation keys may leak.
for(String locale:List.of("fr_fr","en_us")){
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.options.languageCode=locale;m.reloadResourcePacks();});c.waitTicks(60);
c.runOnClient(m->{for(var type:CompanionType.values()){
String passive=type.passive().getString(),active=type.active().getString();
require(!passive.contains("sanctuary.companion")&&!active.contains("sanctuary.companion"),"Native translation resolves both powers: "+locale+"/"+type);
}});
}
c.takeScreenshot("sanctuary-beta032-native-world");
}
}
private static void equip(net.minecraft.server.level.ServerPlayer p,CompanionType type){
AccessoryAccess.of(p).setItem(0,new ItemStack(BuiltInRegistries.ITEM.getValue(Identifier.withDefaultNamespace(type.id()+"_spawn_egg"))));
FamiliarService.tick(p);CompanionService.tick(p);CompanionService.request(p,new CompanionPayloads.Request("operator_ready",-1));CompanionService.send(p,"");
}
private static Stream<GuiEventListener> widgets(ContainerEventHandler container){return container.children().stream().flatMap(w->w instanceof ContainerEventHandler nested?Stream.concat(Stream.of(w),widgets(nested)):Stream.of(w));}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,89 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.knowledge.BlockKnowledgeService;
import fr.koka.sanctuary.notifications.NotificationLayout.*;
import fr.koka.sanctuary.mixin.client.NotificationAdvancementTabAccess;
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.minecraft.client.Minecraft;
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.advancements.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.network.chat.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.*;
import net.minecraft.world.level.block.Blocks;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
public final class Corrections036ClientChecks implements FabricClientGameTest {
private static final Identifier DIET=Identifier.withDefaultNamespace("husbandry/balanced_diet");
public void runTest(ClientGameTestContext c){
var armed=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(armed.get()&&m.gui.screen() instanceof HelloWorldScreen 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());armed.set(false);}});
c.runOnClient(m->{require(new NotificationOptions().position==Position.TOP_RIGHT,"Fresh installs default top right");var o=NotificationClient.options();o.position=Position.TOP_RIGHT;o.seconds=3;o.pinnedAdvancements=true;o.advancements=true;o.discoveries=true;o.collections=true;});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sanctuary corrections036 "+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()&&RecipeClient.ready&&m.gui.screen()==null,1200);var server=world.getServer();
c.runOnClient(m->{m.player.setXRot(-90);m.options.guiScale().set(2);m.resizeGui();});c.waitTicks(30);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();for(String id:List.of("husbandry/root","husbandry/plant_seed")){var a=s.getAdvancements().get(Identifier.withDefaultNamespace(id));for(String k:a.value().criteria().keySet())p.getAdvancements().award(a,k);}var a=s.getAdvancements().get(DIET);p.getAdvancements().award(a,"rotten_flesh");});
c.waitFor(m->!NotificationClient.tracked().isEmpty(),200);c.waitTicks(130);
c.runOnClient(m->{require(NotificationClient.tracked().isEmpty(),"A Balanced Diet expires after rotten flesh without becoming an automatic permanent tracker");require(NotificationClient.messages().isEmpty(),"Messages expire as well");});
openDiet(c);clickDiet(c,false);c.runOnClient(m->require(NotificationClient.isPinned(DIET),"Actual native advancement click pins objective"));c.takeScreenshot("sanctuary-beta036-pin-advancement");
clickDiet(c,true);c.runOnClient(m->require(NotificationClient.isPinned(DIET),"Dragging tree never toggles tracking"));
openDiet(c);clickDiet(c,false);c.runOnClient(m->require(!NotificationClient.isPinned(DIET),"Second click unpins"));clickDiet(c,false);
c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(125);c.runOnClient(m->require(NotificationClient.isPinned(DIET)&&NotificationClient.tracked().size()==1,"Explicit follow survives transient expiry"));
server.runOnServer(s->BlockKnowledgeService.recordSeen(s.getPlayerList().getPlayers().getFirst(),Blocks.AMETHYST_BLOCK));c.waitFor(m->!NotificationClient.messages().isEmpty(),100);c.takeScreenshot("sanctuary-beta036-top-right");
c.runOnClient(m->{m.gui.toastManager().clear();m.debugEntries.setOverlayVisible(true);});c.waitTicks(5);c.runOnClient(m->{require(!NotificationObstacles.debug.isEmpty(),"Native F3 text captured");noOverlap();});c.takeScreenshot("sanctuary-beta036-f3");
for(int scale:List.of(1,2,3,4))for(Position position:Position.values()){
c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();NotificationClient.options().position=position;});c.waitTicks(2);c.runOnClient(m->noOverlap());
}
c.runOnClient(m->{m.options.guiScale().set(3);m.resizeGui();NotificationClient.options().position=Position.TOP_RIGHT;m.options.debugGuiScale().set(1);});c.waitTicks(3);c.runOnClient(m->noOverlap());c.takeScreenshot("sanctuary-beta036-f3-separate-scale");
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.options.debugGuiScale().set(-1);m.debugEntries.setOverlayVisible(false);});
server.runOnServer(s->{var b=s.getCustomBossEvents().create(s.overworld().getRandom(),Identifier.withDefaultNamespace("sanctuary_test_boss"),Component.literal("HUD layout test"));b.addPlayer(s.getPlayerList().getPlayers().getFirst());b.setValue(50);});
c.runOnClient(m->NotificationClient.options().position=Position.TOP_CENTER);c.waitTicks(5);c.runOnClient(m->{var b=NotificationObstacles.lastLayout;require(b.y()>20,"Top center reserves native boss bar");});c.takeScreenshot("sanctuary-beta036-boss");
c.runOnClient(m->NotificationClient.options().position=Position.BOTTOM_CENTER);c.waitTicks(3);c.takeScreenshot("sanctuary-beta036-bottom-center");
server.runOnServer(s->{var b=s.getCustomBossEvents().get(Identifier.withDefaultNamespace("sanctuary_test_boss"));b.removeAllPlayers();s.getCustomBossEvents().remove(b);});
for(String locale:List.of("fr_fr","en_us")){
var reload=c.computeOnClient(m->{m.getLanguageManager().setSelected(locale);m.options.languageCode=locale;return m.reloadResourcePacks();});c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();
c.runOnClient(m->{var o=NotificationClient.options();o.position=Position.TOP_RIGHT;o.save();m.gui.setScreen(new NotificationOptionsScreen(null));require(NotificationOptions.load().position==Position.TOP_RIGHT,"Anchor saved");require(NotificationOptions.load().trackedByWorld.values().stream().anyMatch(ids->ids.contains(DIET.toString())),"Explicit tracks saved per world");
require(NotificationClient.label("position").getString().equals("Position"),"Actual translated position label");});c.waitTicks(3);c.takeScreenshot("sanctuary-beta036-options-"+locale);c.runOnClient(m->m.gui.setScreen(null));
}
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().hurtServer(s.overworld(),s.overworld().damageSources().fellOutOfWorld(),Float.MAX_VALUE));
c.waitFor(m->m.gui.screen() instanceof DeathScreen,200);c.waitTicks(45);c.runOnClient(m->button(m.gui.screen(),"deathScreen.respawn").onPress(enter()));c.waitFor(m->m.player!=null&&m.player.isAlive()&&m.gui.screen()==null,300);c.waitTicks(30);
c.runOnClient(m->{require(NotificationClient.isPinned(DIET)&&NotificationClient.tracked().size()==1,"Selected follow survives respawn");require(NotificationClient.messages().isEmpty(),"Respawn does not replay old notifications");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var a=s.getAdvancements().get(DIET);for(String k:a.value().criteria().keySet())p.getAdvancements().award(a,k);});c.waitFor(m->!NotificationClient.isPinned(DIET),200);c.runOnClient(m->require(NotificationClient.tracked().isEmpty(),"Completed objective automatically leaves tracker"));
gliding(c,server);
System.out.println("HUD036 client passed: rotten-flesh expiry; real click/unclick/drag follow; persistent per-world selection; respawn; completion; F3 collision at 36 layouts + independent scale; boss/hotbar; FR/EN; live carried-parrot descent and landing.");
}finally{c.runOnClient(m->{m.debugEntries.setOverlayVisible(false);var o=NotificationClient.options();o.position=Position.TOP_RIGHT;o.seconds=5;o.save();});}
}
private static void gliding(ClientGameTestContext c,TestServerContext server){
var landing=server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var ground=p.blockPosition().above(3);for(int x=-3;x<=3;x++)for(int z=-3;z<=3;z++){p.level().setBlockAndUpdate(ground.offset(x,0,z),Blocks.STONE.defaultBlockState());for(int y=1;y<=15;y++)p.level().setBlockAndUpdate(ground.offset(x,y,z),Blocks.AIR.defaultBlockState());}p.teleportTo(ground.getX()+.5,ground.getY()+1,ground.getZ()+.5);p.setHealth(p.getMaxHealth());return ground;});
c.waitFor(m->m.player.onGround()&&m.player.getY()>=landing.getY()+1,200);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var bird=EntityTypes.PARROT.create(p.level(),EntitySpawnReason.COMMAND);bird.setPos(p.position().add(0,0,1));p.level().addFreshEntity(bird);require(CarryService.interact(p,bird),"Native parrot carry");p.teleportTo(p.getX(),landing.getY()+10,p.getZ());p.positionRider(bird);});
c.waitFor(m->CarryGlide.active(m.player)&&m.player.getY()>landing.getY()+4,200);c.waitTicks(12);
c.runOnClient(m->{require(m.player.getDeltaMovement().y>=-.121&&m.player.getDeltaMovement().y<0,"Native LocalPlayer physics glides");require(m.player.fallDistance<.2,"No stored fall damage");});c.takeScreenshot("sanctuary-beta036-parrot-gliding");
c.waitFor(m->m.player.onGround(),300);c.runOnClient(m->require(m.player.getHealth()==m.player.getMaxHealth(),"Real landing survives without fall damage"));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var bird=p.getFirstPassenger();CarryService.release(bird,true);bird.discard();});c.waitFor(m->!CarryGlide.supported(m.player),100);
}
private static void noOverlap(){var b=NotificationObstacles.lastLayout;for(var obstacle:NotificationObstacles.boxes())require(!b.intersects(obstacle),"Notification overlap "+b+" / "+obstacle);}
private static void openDiet(ClientGameTestContext c){
c.runOnClient(m->{var adv=m.getConnection().getAdvancements();m.gui.setScreen(new AdvancementsScreen(adv));var root=adv.progress().keySet().stream().filter(a->a.id().toString().equals("minecraft:husbandry/root")).findFirst().orElseThrow();adv.setSelectedTab(root,true);});c.waitTicks(2);
c.runOnClient(m->{var tab=tab((AdvancementsScreen)m.gui.screen());var access=(NotificationAdvancementTabAccess)tab;var w=access.sanctuary$widgets().values().stream().filter(a->a.getAdvancement().id().equals(DIET)).findFirst().orElseThrow();tab.scroll(100-w.getX()-access.sanctuary$scrollX(),45-w.getY()-access.sanctuary$scrollY());});c.waitTicks(2);
}
private static void clickDiet(ClientGameTestContext c,boolean drag){c.runOnClient(m->{var screen=(AdvancementsScreen)m.gui.screen();var tab=(NotificationAdvancementTabAccess)tab(screen);var w=tab.sanctuary$widgets().values().stream().filter(a->a.getAdvancement().id().equals(DIET)).findFirst().orElseThrow();int x=(int)Math.floor(tab.sanctuary$scrollX())+w.getX()+12,y=(int)Math.floor(tab.sanctuary$scrollY())+w.getY()+12;require(x>0&&x<234&&y>0&&y<113,"Advancement icon reachable in native viewport");var e=new MouseButtonEvent(number(screen,"leftPos")+9+x,number(screen,"topPos")+18+y,new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,0));screen.mouseClicked(e,false);if(drag)screen.mouseDragged(e,8,0);screen.mouseReleased(e);});c.waitTicks(2);}
private static AdvancementTab tab(AdvancementsScreen screen){try{var f=AdvancementsScreen.class.getDeclaredField("selectedTab");f.setAccessible(true);return (AdvancementTab)f.get(screen);}catch(Exception e){throw new AssertionError(e);}}
private static int number(AdvancementsScreen screen,String key){try{var f=AdvancementsScreen.class.getDeclaredField(key);f.setAccessible(true);return f.getInt(screen);}catch(Exception e){throw new AssertionError(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 Button button(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(()->new AssertionError(key));}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,76 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.progression.*;
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.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
/** Creates only an ignored development world; validates confirmation through real packets. */
public final class Cycle011ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var armed=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m -> {
if (armed.get() && m.gui.screen() instanceof HelloWorldScreen 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");armed.set(false);
}
});
int scale=c.computeOnClient(m -> m.options.guiScale().get());
try (var world=c.worldBuilder().adjustSettings(s -> {
s.setName("Sanctuary beta011 cycles "+UUID.randomUUID());
s.setWorldType(s.getNormalPresetList().stream().filter(e -> e.preset()!=null && e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());
s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
}).create()) {
world.getConnection().waitForChunksDownload(2400);
c.waitFor(m -> m.player!=null && ProgressionClient.active() && CycleClient.view!=null && m.gui.screen()==null,1200);
for(int gui:new int[]{2,0}) {
c.runOnClient(m -> {m.options.guiScale().set(gui);m.resizeGui();CycleClient.open(new net.minecraft.client.gui.screens.PauseScreen(true),"cycle");});
c.waitTick();
c.runOnClient(m -> require(!button(m.gui.screen(),"sanctuary.cycle.ngp").active,"New character cannot begin NGP"));
c.takeScreenshot("sanctuary-beta011-cycle-locked-gui-"+gui);
}
world.getServer().runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();var progress=ProgressRecord.INITIAL.buy("names",4).buy("atlas",4);
for (String skill:ProgressRecord.SKILLS) while(progress.rank(skill)<ProgressRecord.cap(skill))progress=progress.buy(skill,1);
p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);p.giveExperienceLevels(20);ProgressionService.send(p,"");
});
c.waitFor(m -> ProgressionClient.progress.complete(),200);
c.runOnClient(m -> {press(m.gui.screen(),"sanctuary.cycle.ngp");require(CycleClient.view.prestige()==0,"Opening preview makes no server change");});
c.takeScreenshot("sanctuary-beta011-ngp-confirmation");
c.runOnClient(m -> {press(m.gui.screen(),"sanctuary.cycle.cancel");require(CycleClient.view.prestige()==0,"Cancel keeps cycle");press(m.gui.screen(),"sanctuary.cycle.ngp");press(m.gui.screen(),"sanctuary.cycle.confirm");});
c.waitFor(m -> CycleClient.view.prestige()==1 && ProgressionClient.progress.cycle()==1 && m.player.experienceLevel==0,200);
c.runOnClient(m -> require(ProgressionClient.progress.names() && ProgressionClient.progress.atlas() && CycleClient.view.charges()==1,"Server confirmation preserves aptitudes and grants one charge"));
for (int i=0;i<6;i++) c.waitTick();
world.getServer().runOnServer(server -> {var p=server.getPlayerList().getPlayers().getFirst();p.giveExperienceLevels(10);ProgressionService.send(p,"");});
c.waitFor(m->m.player.experienceLevel==10,100);
c.runOnClient(m -> {CycleClient.open(new net.minecraft.client.gui.screens.PauseScreen(true),"faction");});c.waitTick();
c.runOnClient(m -> {
var screen=m.gui.screen();screen.children().stream().filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow().setValue("Les Veilleurs");
press(screen,"sanctuary.cycle.create");press(screen,"sanctuary.cycle.confirm");
});
c.waitFor(m -> CycleClient.view.faction()!=null && CycleClient.view.charges()==1 && m.player.experienceLevel==0,200);
c.takeScreenshot("sanctuary-beta011-faction-created");
for(int gui:new int[]{2,0}) {
c.runOnClient(m -> {m.options.guiScale().set(gui);m.resizeGui();CycleClient.open(new net.minecraft.client.gui.screens.PauseScreen(true),"log");});
c.waitTick();c.takeScreenshot("sanctuary-beta011-log-gui-"+gui);
}
} finally {armed.set(false);c.runOnClient(m -> {m.options.guiScale().set(scale);m.resizeGui();});}
}
private static KeyEvent enter() {return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static Button button(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 press(Screen screen,String key) {var button=button(screen,key);require(button.active,"Active button: "+key);button.onPress(enter());}
private static void require(boolean ok,String message) {if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,142 @@
package fr.koka.sanctuary.gametest;
import com.mojang.authlib.GameProfile;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.progression.*;
import java.util.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.minecraft.network.Connection;
import net.minecraft.network.protocol.PacketFlow;
import net.minecraft.network.protocol.game.*;
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;
/** Two actual ServerPlayer entities, native AI/projectiles, persistent escrow and client screens. */
public final class Duel054Checks {
private static void check(boolean value,String message){Combat054ClientChecks.require(value,message);}
// The context API type is obtained from the public Fabric test world rather than a dedicated server.
public static void run(ClientGameTestContext c,net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext server){
var remote=new java.util.concurrent.atomic.AtomicReference<ServerPlayer>();
ServerTickEvents.END_SERVER_TICK.register(s->{var p=remote.get();if(p!=null&&p.level().getServer()==s){p.tickCount++;CompanionService.tick(p);FamiliarService.tick(p);FamiliarBattle.tick(p);}});
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var at=p.blockPosition();
p.level().getGameRules().set(net.minecraft.world.level.gamerules.GameRules.PVP,false,s);
for(int x=-10;x<=10;x++)for(int z=-10;z<=10;z++)for(int y=-1;y<8;y++)p.level().setBlockAndUpdate(at.offset(x,y,z),(y==-1?Blocks.STONE:Blocks.AIR).defaultBlockState());
var profile=new GameProfile(UUID.randomUUID(),"Rival054");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(p.position().add(0,0,5));other.setYRot(180);other.setYHeadRot(180);
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);
remote.set(other);p.getInventory().clearContent();other.getInventory().clearContent();fresh(p,"wolf");fresh(other,"wolf");
p.getInventory().setItem(0,new ItemStack(Items.EMERALD,3));other.getInventory().setItem(0,new ItemStack(Items.DIAMOND,2));
});
c.waitTicks(20);
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var q=remote.get();
var invited=FamiliarDuels.invite(p,q);check(invited.equals("duel.sent"),"Invitation between available players: "+invited+" ready="+FamiliarDuels.ready(p)+"/"+FamiliarDuels.ready(q)+" combat="+FamiliarBattle.fighting(p)+"/"+FamiliarBattle.fighting(q)+" distance="+p.distanceTo(q)+" sight="+p.hasLineOfSight(q));var m=FamiliarDuels.match(p);
check(Experience054Checks.transfer(p,"deposit_all").equals("xp.locked"),"XP transfers lock from the invitation, before stake consent");
request(q,"accept",-1,0);request(p,"stake",0,3);request(q,"stake",0,2);
request(p,"ready",-1,0);check(m.ready[0]&&!m.ready[1]&&!m.running(),"One confirmation cannot start combat");
int old=m.revision;request(q,"stake",0,1);check(!m.ready[0]&&!m.ready[1],"Changing a stake invalidates both confirmations");
p.tickCount+=4;FamiliarDuels.request(p,m.id.toString(),old,"ready",-1,0);check(!m.ready[0],"Stale consent cannot accept a changed offer");
request(q,"stake",9,1);check(m.stakes[1].getCount()==1,"Locked inventory slots cannot be wagered");
check(p.getInventory().getItem(0).getCount()==3&&q.getInventory().getItem(0).getCount()==2,"Offers do not remove items before mutual agreement");
request(q,"stake",0,2);FamiliarDuels.open(p);
});
c.waitFor(m->m.gui.screen() instanceof FamiliarDuelScreen,100);
for(String locale:List.of("fr_fr","en_us")){
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new FamiliarDuelScreen());});
c.waitTicks(4);c.takeScreenshot("sanctuary-beta054-duel-stakes-"+locale);
}
c.runOnClient(m->{
var slot=widgets(m.gui.screen()).filter(net.minecraft.client.gui.components.AbstractWidget.class::isInstance).map(net.minecraft.client.gui.components.AbstractWidget.class::cast)
.filter(w->w.getWidth()==20&&w.getHeight()==20)
.filter(w->w.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.familiar.duel.own_stake")).findFirst().orElseThrow();
boolean clicked=m.gui.screen().mouseClicked(new net.minecraft.client.input.MouseButtonEvent(slot.getX()+10,slot.getY()+10,new net.minecraft.client.input.MouseButtonInfo(com.mojang.blaze3d.platform.InputConstants.MOUSE_BUTTON_LEFT,0)),false);
System.out.println("DUEL054_CLICK "+clicked+" x="+slot.getX()+" y="+slot.getY()+" phase="+FamiliarClient.duel.phase()+" revision="+FamiliarClient.duel.revision());
});
c.waitFor(m->FamiliarClient.duel!=null&&FamiliarClient.duel.ownStake().isEmpty(),100);
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var q=remote.get();check(FamiliarDuels.match(p).stakes[0].isEmpty(),"Native slot click reaches the authoritative offer");request(p,"stake",0,3);request(p,"ready",-1,0);request(q,"ready",-1,0);var m=FamiliarDuels.match(p);
check(m!=null&&m.phase==FamiliarDuels.Phase.COUNTDOWN,"Two confirmations establish a durable escrow");
check(p.getInventory().getItem(0).isEmpty()&&q.getInventory().getItem(0).isEmpty(),"Both real stakes removed exactly once");
check(!FamiliarBattle.enemy(p,FamiliarService.active(q)),"Countdown blocks attacks");m.until=FamiliarBattle.now();
});
c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(25);
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var q=remote.get();var a=FamiliarService.active(p);var b=FamiliarService.active(q);
check(FamiliarBattle.enemy(p,b)&&!FamiliarBattle.enemy(p,q),"The agreement targets the rival familiar, never its player");
check(!CompanionWorld.enemy(p,q),"The ordinary PvP prohibition remains in effect");
check(!FamiliarTechniques.ally(q,a),"Opponent cannot supply healing assistance");
check(!b.hurtServer(p.level(),b.damageSources().playerAttack(p),3),"Players cannot directly damage duelling familiars");
a.setPos(p.position().add(1,0,2));b.setPos(p.position().add(1,0,3.5));FamiliarBattle.engage(p,b,false);FamiliarBattle.engage(q,a,false);
});
c.waitTicks(80);
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var q=remote.get();var m=FamiliarDuels.match(p);check(m!=null&&m.damage[0]+m.damage[1]>0,"Autonomous duel produces measured physical hits");
// A familiar knockout is the actual victory trigger; no /win command or player damage.
var a=FamiliarService.active(p);var b=FamiliarService.active(q);var cast=new FamiliarTechniques.Cast(a,FamiliarBattle.profile(p).initiative(),false,b,b.position());
FamiliarBattle.state(p).cast=null;FamiliarBattle.state(q).cast=null;
for(int i=0;i<12&&FamiliarDuels.match(p)!=null;i++)FamiliarTechniques.hit(cast,b,4);
check(FamiliarDuels.match(p)==null&&FamiliarBattle.bond(q).health()==0,"K.O. ends the match");
check(count(p,Items.EMERALD)==3&&count(p,Items.DIAMOND)==2&&count(q,Items.DIAMOND)==0,"Winner receives both exact stakes");
FamiliarDuels.ledger(s).claim(p);check(count(p,Items.DIAMOND)==2,"Repeated collection cannot duplicate a payout");
check(!FamiliarTechniques.hit(cast,b,4),"An old duel cast is invalid after settlement");
check(!FamiliarBattle.bond(p).memories().containsKey("signature"),"Duels do not farm familiar mastery");
System.out.println("DUEL054_MATCH_PASS: consent, changed stakes, native hits, isolation, knockout and exact payout");
});
c.waitTicks(25);
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var q=remote.get();fresh(p,"skeleton");fresh(q,"cow");
p.getInventory().setItem(1,new ItemStack(Items.ARROW,12));check(FamiliarDuels.invite(p,q).equals("duel.sent"),"A rematch can be offered");
request(q,"accept",-1,0);request(p,"ready",-1,0);request(q,"ready",-1,0);var m=FamiliarDuels.match(p);check(m.running(),"Duels may start without a wager");m.until=FamiliarBattle.now();FamiliarDuels.tick(s);
var a=FamiliarService.active(p);var b=FamiliarService.active(q);a.setPos(p.position().add(1,0,2));b.setPos(p.position().add(1,0,5));b.setNoAi(true);
var cast=new FamiliarTechniques.Cast(a,FamiliarBattle.profile(p).signature(),false,b,b.position());cast.supply=new ItemStack(Items.ARROW);FamiliarProjectiles.launch(cast);
var shots=p.level().getEntitiesOfClass(net.minecraft.world.entity.projectile.Projectile.class,a.getBoundingBox().inflate(2),FamiliarProjectiles::managed);check(!shots.isEmpty()&&shots.stream().noneMatch(Entity::shouldBeSaved),"Managed native projectiles cannot resurrect after a world reload");
for(var shot:shots)FamiliarProjectiles.impact(shot,new net.minecraft.world.phys.EntityHitResult(b));
check(b.getHealth()<b.getMaxHealth(),"Native arrow impacts damage the rival familiar");float wounded=b.getHealth();request(p,"cancel",-1,0);
check(FamiliarDuels.match(p)==null&&b.getHealth()==wounded,"Surrender ends the duel without resetting wounds");
FamiliarProjectiles.launch(cast);for(var shot:p.level().getEntitiesOfClass(net.minecraft.world.entity.projectile.Projectile.class,a.getBoundingBox().inflate(2),FamiliarProjectiles::managed))FamiliarProjectiles.impact(shot,new net.minecraft.world.phys.EntityHitResult(b));
check(b.getHealth()==wounded,"Late projectiles cannot damage after surrender");
System.out.println("DUEL054_PROJECTILES_PASS: native projectiles, no save resurrection, surrender and late impact isolation");
});
// Exercise refunds and inventory-full claims using the same persisted transaction contract.
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var q=remote.get();var ledger=FamiliarDuels.ledger(s);
p.getInventory().clearContent();q.getInventory().clearContent();fresh(p,"skeleton");fresh(q,"blaze");p.getInventory().setItem(0,new ItemStack(Items.EMERALD,3));q.getInventory().setItem(0,new ItemStack(Items.DIAMOND,2));
var id=UUID.randomUUID();check(ledger.reserve(id,p,q,new ItemStack(Items.EMERALD,3),new ItemStack(Items.DIAMOND,2)),"Independent escrow established");
for(int slot:CompanionInventory.slots(p,true))p.getInventory().setItem(slot,new ItemStack(Items.STONE,64));ledger.finish(id,0);ledger.claim(p);ledger.claim(q);
check(ledger.pending(p)&&count(p,Items.DIAMOND)==0,"Full inventory retains payout without dropping it into the world");
for(int slot:CompanionInventory.slots(p,true))p.getInventory().setItem(slot,ItemStack.EMPTY);ledger.claim(p);check(count(p,Items.DIAMOND)==2&&count(p,Items.EMERALD)==3&&!ledger.pending(p),"Freed space collects the complete pending payout");
q.getInventory().setItem(0,new ItemStack(Items.DIAMOND,2));var interrupted=UUID.randomUUID();check(ledger.reserve(interrupted,p,q,new ItemStack(Items.EMERALD,3),new ItemStack(Items.DIAMOND,2)),"Interrupted escrow fixture");
try{
var reloaded=new FamiliarStakes(s);reloaded.claim(p);reloaded.claim(q);
check(count(p,Items.EMERALD)==3&&count(q,Items.DIAMOND)==2,"Journal reload refunds both stakes after interruption");
reloaded.claim(p);reloaded.claim(q);check(count(q,Items.DIAMOND)==2,"Recovery receipts prevent repeated refunds");
var named=new ItemStack(Items.CHEST);named.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME,net.minecraft.network.chat.Component.literal("Archive du duel"));
named.set(net.minecraft.core.component.DataComponents.CONTAINER,net.minecraft.world.item.component.ItemContainerContents.fromItems(List.of(new ItemStack(Items.DIAMOND,7))));
p.getInventory().setItem(3,named.copy());var custom=UUID.randomUUID();check(reloaded.reserve(custom,p,q,named,ItemStack.EMPTY),"Named container can be wagered");reloaded.finish(custom,0);reloaded.claim(p);reloaded.claim(q);
check(CompanionInventory.slots(p,true).stream().anyMatch(slot->ItemStack.matches(named,p.getInventory().getItem(slot))),"All native item components and nested contents survive escrow");
}catch(Exception e){throw new AssertionError(e);}
System.out.println("DUEL054_ESCROW_PASS: durable receipts, full inventory and interrupted match recovery");
// Reload uses a fresh journal owner: discard this fixture before the normal shutdown hook writes.
FamiliarDuels.start(s);
});
c.runOnClient(m->m.gui.setScreen(new FamiliarOrdersScreen()));c.waitTicks(3);c.takeScreenshot("sanctuary-beta054-orders");c.runOnClient(m->m.gui.setScreen(null));
server.runOnServer(s->{var q=remote.getAndSet(null);FamiliarDuels.disconnect(q);FamiliarBattle.release(q);var pet=FamiliarService.active(q);if(pet!=null)pet.discard();q.discard();});
}
private static void request(ServerPlayer p,String action,int slot,int count){var m=FamiliarDuels.match(p);p.tickCount+=4;FamiliarDuels.request(p,m.id.toString(),m.revision,action,slot,count);}
private static void fresh(ServerPlayer p,String species){FamiliarBattle.cancel(p);p.setAttached(FamiliarBattle.COMBAT_LOCK,0L);CompanionService.save(p,CompanionService.record(p).cooldown(0));Combat054ClientChecks.equip(p,species);p.tickCount=((p.tickCount/10)+1)*10;FamiliarService.tick(p);}
private static int count(ServerPlayer p,Item item){return CompanionInventory.slots(p,true).stream().mapToInt(i->p.getInventory().getItem(i).is(item)?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 container?container.children().stream().flatMap(Duel054Checks::widgets):java.util.stream.Stream.empty());
}
}
@@ -0,0 +1,140 @@
package fr.koka.sanctuary.gametest;
import com.mojang.authlib.GameProfile;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
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.components.events.*;
import net.minecraft.client.gui.screens.Screen;
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.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.network.protocol.PacketFlow;
import net.minecraft.network.protocol.game.*;
import net.minecraft.server.level.*;
import net.minecraft.server.network.*;
import net.minecraft.util.LightCoordsUtil;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.*;
import net.minecraft.world.level.LightLayer;
import net.minecraft.world.level.block.*;
/** Fresh enclosed room: native network synchronization, meshing and rendered evidence. */
public final class Dynamic030ClientChecks 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 screen){
widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
widgets(screen).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("Sanctuary beta030 lighting "+UUID.randomUUID());s.setSeed("42");
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());
s.setGameMode(WorldCreationUiState.SelectedGameMode.CREATIVE);}).create()) {
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&m.gui.screen()==null,1200);
var server=world.getServer();
BlockPos origin=server.computeOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var at=p.blockPosition().above(80);
for(int x=-12;x<=12;x++)for(int z=-12;z<=12;z++)for(int y=-1;y<=7;y++)p.level().setBlockAndUpdate(at.offset(x,y,z),
(y==-1||y==7||Math.abs(x)==12||Math.abs(z)==12?Blocks.STONE:Blocks.AIR).defaultBlockState());
for(int x=-4;x<=-2;x++)for(int z=3;z<=5;z++)p.level().setBlockAndUpdate(at.offset(x,0,z),Blocks.WATER.defaultBlockState());
p.teleportTo(at.getX()+.5,at.getY(),at.getZ()-5.5);p.setYRot(0);p.setXRot(12);
p.getInventory().clearContent();p.setItemInHand(InteractionHand.OFF_HAND,ItemStack.EMPTY);head(p,ItemStack.EMPTY);p.inventoryMenu.broadcastChanges();return at;
});
c.waitFor(m->m.player.getY()>=origin.getY()-.1,200);
c.runOnClient(m->{m.getWindow().setWindowed(1280,800);m.options.guiScale().set(2);m.resizeGui();m.player.setYRot(0);m.player.setXRot(12);});
c.waitTicks(100);
c.runOnClient(m->{require(DynamicLightClient.snapshot(m.level).size()==0,"Empty room has no dynamic sources");
require(m.level.getBrightness(LightLayer.BLOCK,origin.above())==0,"No real block light in test room");});
c.takeScreenshot("sanctuary-beta030-room-dark");
Dynamic030RenderProbe.active=true;
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.TORCH));p.inventoryMenu.broadcastChanges();});
c.waitFor(m->DynamicLightClient.snapshot(m.level).size()==1,200);c.waitTicks(50);
c.runOnClient(m->{var pos=m.player.blockPosition().above();require((LightCoordsUtil.getLightCoords(m.level,pos)&255)>150,"Held torch reaches native light lookup");
require(m.level.getBrightness(LightLayer.BLOCK,pos)==0,"World light engine remains untouched");
require((m.getEntityRenderDispatcher().getRenderer(m.player).getPackedLightCoords(m.player,1)&255)>150,"Player and held-item extraction receive dynamic light");
require(Dynamic030RenderProbe.litTerrainSamples.get()>0,"Actual terrain compilation consumes dynamic light");
System.out.println("LIGHT-01 native meshing: "+Dynamic030RenderProbe.litTerrainSamples.get()+" lit samples");});
c.takeScreenshot("sanctuary-beta030-hand-torch");
c.waitFor(m->DynamicLightClient.pendingSections()==0,200);
long samples=Dynamic030RenderProbe.litTerrainSamples.get();
for(int i=0;i<20;i++){c.runOnClient(m->m.player.setYRot(m.player.getYRot()+18));c.waitTick();}
c.runOnClient(m->require(DynamicLightClient.pendingSections()==0,"Looking around does not invalidate the source"));
c.waitTicks(30);System.out.println("LIGHT-01 settled terrain samples before/after camera turn: "+samples+" / "+Dynamic030RenderProbe.litTerrainSamples.get());
double oldX=c.computeOnClient(m->m.player.getX()),oldY=c.computeOnClient(m->m.player.getEyeY()),oldZ=c.computeOnClient(m->m.player.getZ());
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(origin.getX()+9.5,origin.getY(),origin.getZ()+9.5));
c.waitFor(m->m.player.getZ()>origin.getZ()+9,100);c.waitTicks(40);
c.runOnClient(m->{var light=DynamicLightClient.snapshot(m.level);
require(light.apply(0,oldX,oldY,oldZ)==0,"Walking away removes the old pool of light");
require((light.apply(0,m.player.getX(),m.player.getEyeY(),m.player.getZ())&255)>200,"Light follows the moving player");});
server.runOnServer(s->s.getPlayerList().getPlayers().getFirst().teleportTo(oldX,origin.getY(),oldZ));
c.waitFor(m->m.player.getZ()<origin.getZ()-5,100);c.waitTicks(30);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setItemInHand(InteractionHand.MAIN_HAND,ItemStack.EMPTY);p.setItemInHand(InteractionHand.OFF_HAND,new ItemStack(Items.SOUL_LANTERN));p.inventoryMenu.broadcastChanges();});
c.waitFor(m->m.player.getOffhandItem().is(Items.SOUL_LANTERN),100);c.waitTicks(40);
c.runOnClient(m->require((DynamicLightClient.snapshot(m.level).apply(0,m.player.getX(),m.player.getEyeY(),m.player.getZ())&255)>=150,"Offhand light follows native soul intensity"));
c.takeScreenshot("sanctuary-beta030-offhand");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setItemInHand(InteractionHand.OFF_HAND,ItemStack.EMPTY);head(p,new ItemStack(Items.GLOWSTONE));p.inventoryMenu.broadcastChanges();});
c.waitFor(m->HeadCosmeticService.appearance(m.player).is(Items.GLOWSTONE),100);c.waitTicks(40);
c.runOnClient(m->require((DynamicLightClient.snapshot(m.level).apply(0,m.player.getX(),m.player.getEyeY(),m.player.getZ())&255)>220,"Head block supplies its own light"));
c.takeScreenshot("sanctuary-beta030-head-glowstone");
server.runOnServer(s->head(s.getPlayerList().getPlayers().getFirst(),cake(true)));c.waitFor(m->HeadCosmeticData.lit(HeadCosmeticService.appearance(m.player)),100);c.waitTicks(40);
c.runOnClient(m->require((DynamicLightClient.snapshot(m.level).apply(0,m.player.getX(),m.player.getEyeY(),m.player.getZ())&255)>25,"Lit cake produces its smaller pool of light"));
c.takeScreenshot("sanctuary-beta030-head-candle-cake");
server.runOnServer(s->head(s.getPlayerList().getPlayers().getFirst(),cake(false)));c.waitFor(m->DynamicLightClient.snapshot(m.level).size()==0,100);c.waitTicks(40);
c.runOnClient(m->require((LightCoordsUtil.getLightCoords(m.level,m.player.blockPosition().above())&255)==0,"Extinguishing removes dynamic light"));
c.takeScreenshot("sanctuary-beta030-cake-extinguished");
UUID remote=server.computeOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();head(p,ItemStack.EMPTY);
var profile=new GameProfile(UUID.randomUUID(),"Luciole");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(origin.getX()+3.5,origin.getY(),origin.getZ()+3.5);other.setYRot(180);other.setYHeadRot(180);other.setNoGravity(true);
other.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.LANTERN));other.connection.handleAcceptPlayerLoad(new ServerboundPlayerLoadedPacket());
p.connection.send(ClientboundPlayerInfoUpdatePacket.createPlayerInitializing(List.of(other)));p.level().addNewPlayer(other);return other.getUUID();
});
c.waitFor(m->m.level.getPlayerByUUID(remote)!=null&&DynamicLightClient.snapshot(m.level).size()==1,200);c.waitTicks(40);
c.runOnClient(m->{var other=m.level.getPlayerByUUID(remote);require((LightCoordsUtil.getLightCoords(m.level,other.blockPosition().above())&255)>180,"Other player's lantern lights terrain");});
c.takeScreenshot("sanctuary-beta030-other-player-lantern");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var other=(ServerPlayer)p.level().getPlayerByUUID(remote);other.setItemInHand(InteractionHand.MAIN_HAND,ItemStack.EMPTY);
// The synthetic remote has no live connection ticking its inventory; deliver its normal equipment update.
p.connection.send(new ClientboundSetEquipmentPacket(other.getId(),List.of(com.mojang.datafixers.util.Pair.of(net.minecraft.world.entity.EquipmentSlot.MAINHAND,ItemStack.EMPTY))));
for(int i=0;i<200;i++){var item=new ItemEntity(p.level(),origin.getX()+.5,origin.getY()+.2,origin.getZ()+2.5,new ItemStack(Items.TORCH));
item.setDeltaMovement(net.minecraft.world.phys.Vec3.ZERO);item.setNoGravity(true);item.setDefaultPickUpDelay();p.level().addFreshEntity(item);}});
c.waitFor(m->m.level.getPlayerByUUID(remote).getMainHandItem().isEmpty(),200);
c.waitTicks(30);
c.runOnClient(m->System.out.println("LIGHT-01 dropped field: "+DynamicLightClient.snapshot(m.level).size()+" sources; "+m.level.getEntitiesOfClass(ItemEntity.class,m.player.getBoundingBox().inflate(30)).size()+" item entities"));
c.waitFor(m->DynamicLightClient.snapshot(m.level).size()==1,200);c.waitTicks(20);
c.runOnClient(m->{require(DynamicLightClient.snapshot(m.level).size()==1,"200 dropped torches form one source even while stacks merge");
var dropped=m.level.getEntitiesOfClass(ItemEntity.class,m.player.getBoundingBox().inflate(30)).getFirst();
// Flowing water carries the pile: sample where the items are now, not where they were thrown.
int light=LightCoordsUtil.getLightCoords(m.level,dropped.blockPosition())&255;
System.out.println("LIGHT-01 floating drop at "+dropped.position()+" has light "+light);
require(light>180,"Dropped cluster follows the items moving in water");});
c.takeScreenshot("sanctuary-beta030-dropped-cluster");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();for(var e:p.level().getEntitiesOfClass(ItemEntity.class,p.getBoundingBox().inflate(30)))e.discard();});
c.waitFor(m->DynamicLightClient.snapshot(m.level).size()==0&&DynamicLightClient.pendingSections()==0,200);c.waitTicks(40);
c.runOnClient(m->require((LightCoordsUtil.getLightCoords(m.level,origin.offset(0,0,2))&255)==0,"Despawn clears every old source"));
c.takeScreenshot("sanctuary-beta030-cluster-removed");
}finally{onboarding.set(false);Dynamic030RenderProbe.active=false;}
c.runOnClient(m->require(DynamicLightClient.pendingSections()==0,"Disconnect clears queued light updates"));
System.out.println("LIGHT-01 client passed: hands, head, cake, remote player, 200 drops, meshing, cleanup and unchanged world light.");
}
private static ItemStack cake(boolean lit) {var item=new ItemStack(Items.CAKE);var tag=new CompoundTag();tag.putString(HeadCosmeticData.CANDLE,"minecraft:candle");item.set(DataComponents.CUSTOM_DATA,CustomData.of(tag));item.set(DataComponents.BLOCK_STATE,BlockItemStateProperties.EMPTY.with(CandleBlock.LIT,lit));return item;}
private static void head(ServerPlayer p,ItemStack stack) {AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,stack);AccessoryService.publish(p);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static Stream<GuiEventListener> widgets(GuiEventListener element){return Stream.concat(Stream.of(element),element instanceof ContainerEventHandler parent?parent.children().stream().flatMap(Dynamic030ClientChecks::widgets):Stream.empty());}
private static void require(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,134 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.familiar.*;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import java.util.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.*;
/** Exact balances, real damage, native button requests and interrupted save recovery. */
public final class Experience054Checks {
private static void check(boolean ok,String message){Combat054ClientChecks.require(ok,message);}
public static void run(ClientGameTestContext c,TestServerContext server){
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();peace(p);Combat054ClientChecks.equip(p,"wolf");p.tickCount=((p.tickCount/10)+1)*10;FamiliarService.tick(p);
check(FamiliarService.active(p)!=null,"Physical familiar for XP combat test");
check(FamiliarLevels.xpAt(16)==352&&FamiliarLevels.xpAt(17)==394&&FamiliarLevels.xpAt(31)==1507&&FamiliarLevels.xpAt(32)==1628&&FamiliarLevels.xpAt(40)==2920,"XP boundaries match Minecraft point costs");
check(FamiliarLevels.previous(FamiliarLevels.xpAt(40)+7)==FamiliarLevels.xpAt(40)+7-FamiliarLevels.xpAt(39),"A level withdrawal removes the partial XP and reaches the lower whole level");
for(int level:new int[]{0,1,15,16,17,30,31,32,40,120,100000,1000000}){
long xp=FamiliarLevels.xpAt(level);check(FamiliarLevels.level(xp)==level,"Native XP boundary "+level);
if(level>0)check(FamiliarLevels.level(xp-1)==level-1,"Native XP lower boundary "+level);
}
p.setExperienceLevels(Integer.MAX_VALUE);p.experienceProgress=.8F;
check(FamiliarExperience.playerXp(p)>Integer.MAX_VALUE&&transfer(p,"deposit_all").equals("xp.native_limit"),"Extreme operator levels are refused without integer overflow");
long source=FamiliarLevels.xpAt(120)+7;FamiliarExperience.setPlayerXp(p,source);p.totalExperience=1;
check(FamiliarExperience.playerXp(p)==source,"Spending levels cannot confuse XP balance with stale totalExperience");
var b=FamiliarBattle.bond(p);float health=FamiliarLevels.current(b);
check(transfer(p,"deposit_all").equals("xp.transferred"),"Exact XP deposit");
b=FamiliarBattle.bond(p);check(b.storedXp()==source&&FamiliarExperience.playerXp(p)==0,"All points debited and banked once");
check(FamiliarLevels.level(b.storedXp())==120&&FamiliarLevels.health(b)>1.5F&&FamiliarLevels.damage(b.storedXp())>1.25F,"Power continues beyond the rejected caps");
check(Math.abs(FamiliarLevels.current(b)-health)<.001,"Deposit cannot heal");
check(FamiliarLevels.rarity("common",b.storedXp()).name().equals("LEGENDARY"),"Rarity rises to legendary");
check(FamiliarBattle.world(s).experience.transfer(p,b.id(),0,"deposit").equals("xp.changed"),"Stale request rejected");
check(transfer(p,"withdraw_all").equals("xp.transferred")&&FamiliarExperience.playerXp(p)==source,"Full withdrawal restores exactly the original points");
check(FamiliarBattle.bond(p).storedXp()==0&&Math.abs(FamiliarLevels.current(FamiliarBattle.bond(p))-health)<.001,"Withdrawal removes bonuses without healing");
check(transfer(p,"deposit").equals("xp.transferred")&&FamiliarBattle.bond(p).storedXp()==7,"One level uses the familiar XP curve");
check(transfer(p,"withdraw").equals("xp.transferred")&&FamiliarExperience.playerXp(p)==source,"Single level round trip conserves XP");
check(transfer(p,"deposit_all").equals("xp.transferred"),"Charge for physical damage check");
var pet=FamiliarService.active(p);var target=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);target.setNoAi(true);target.setPos(pet.position().add(0,0,1.5));p.level().addFreshEntity(target);
var cast=new FamiliarTechniques.Cast(pet,FamiliarBattle.profile(p).initiative(),true,target,target.position());
check(FamiliarTechniques.hit(cast,target,4)&&target.getHealth()<13,"Stored levels increase actual native outgoing damage");
float before=FamiliarLevels.current(FamiliarBattle.bond(p));pet.hurtServer(p.level(),pet.damageSources().mobAttack(target),3);
check(Math.abs(FamiliarLevels.current(FamiliarBattle.bond(p))-(before-3))<.001,"Incoming damage is applied against the expanded HP reserve");
check(transfer(p,"withdraw_all").equals("xp.locked"),"Cannot drain combat strength during a fight");target.discard();peace(p);
check(transfer(p,"withdraw_all").equals("xp.transferred"),"XP is recoverable after combat");
recovery(p);trade(p);
FamiliarExperience.setPlayerXp(p,FamiliarLevels.xpAt(40)+7);FamiliarBattle.send(p,"");
});
c.waitTicks(3);
c.runOnClient(m->m.gui.setScreen(new FamiliarExperienceScreen(null)));
c.runOnClient(m->{var button=m.gui.screen().children().stream().filter(net.minecraft.client.gui.components.Button.class::isInstance).map(net.minecraft.client.gui.components.Button.class::cast)
.filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.familiar.xp.deposit_all")).findFirst().orElseThrow();
check(button.active,"Deposit control is enabled");button.onPress(new net.minecraft.client.input.KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0));});
c.waitFor(m->FamiliarClient.bond!=null&&FamiliarClient.bond.storedXp()==FamiliarLevels.xpAt(40)+7,100);
for(String locale:List.of("fr_fr","en_us")){
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new FamiliarExperienceScreen(null));});
c.waitTicks(3);c.takeScreenshot("sanctuary-beta054-experience-"+locale);
}
c.runOnClient(m->{
var egg=AccessoryAccess.of(m.player).getItem(AccessoryContainer.FAMILIAR);
check(CompanionTooltips.rarity(egg).name().equals("LEGENDARY"),"Item rarity uses server individual balance");
check(CompanionTooltips.color(egg)==net.minecraft.ChatFormatting.LIGHT_PURPLE,"Egg name uses the legendary color");m.gui.setScreen(null);
});
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var b=FamiliarBattle.bond(p);
// A copied object points to one canonical balance, not another reserve of XP.
var copy=FamiliarBattle.egg(p).copy();p.getInventory().setItem(2,copy);
check(FamiliarIdentity.get(copy).equals(b.id()),"An exchanged/copied egg retains its individual identity");
FamiliarBattle.flush(s);s.saveEverything(true,true,true);
});
System.out.println("XP054_BALANCE_PASS: native XP conservation, uncapped health and damage, no transfer healing, combat lock, receipt recovery and FR/EN controls");
}
public static void reconnect(ServerPlayer p){
var b=FamiliarBattle.bond(p);check(b!=null&&b.storedXp()==FamiliarLevels.xpAt(40)+7&&FamiliarExperience.playerXp(p)==0,"Reconnect preserves stored points and debited player balance");
check(FamiliarIdentity.get(p.getInventory().getItem(2)).equals(b.id()),"Copied egg still references the same individual after reconnect");
System.out.println("XP054_RECONNECT_PASS: real world reload preserves individual XP, player debit and shared egg identity");
}
static String transfer(ServerPlayer p,String action){var b=FamiliarBattle.bond(p);return FamiliarBattle.world(p.level().getServer()).experience.transfer(p,b.id(),b.storedXp(),action);}
private static void peace(ServerPlayer p){
FamiliarBattle.cancel(p);p.setAttached(FamiliarBattle.COMBAT_LOCK,0L);var b=FamiliarBattle.bond(p);
if(b!=null){var json=FamiliarCatalog.JSON.toJsonTree(b).getAsJsonObject();json.addProperty("combatUntil",0);FamiliarBattle.update(p,FamiliarBondStore.parse(json));}
}
private static void trade(ServerPlayer p){
var server=p.level().getServer();check(transfer(p,"deposit_all").equals("xp.transferred"),"Charge an egg before trading it");
var egg=FamiliarBattle.egg(p).copy();long xp=FamiliarBattle.bond(p).storedXp();
FamiliarBattle.release(p);AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,net.minecraft.world.item.ItemStack.EMPTY);FamiliarService.tick(p);
var identity=new com.mojang.authlib.GameProfile(UUID.randomUUID(),"XpTrader054");
var q=new ServerPlayer(server,p.level(),identity,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(server,connection,q,net.minecraft.server.network.CommonListenerCookie.createInitial(identity,false));
q.setPos(p.position().add(2,0,0));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);
q.connection.handleAcceptPlayerLoad(new net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket());p.level().addNewPlayer(q);
AccessoryAccess.of(q).setItem(AccessoryContainer.FAMILIAR,egg.copy());fr.koka.sanctuary.companion.CompanionService.tick(q);
check(FamiliarBattle.prepare(q)&&FamiliarBattle.bond(q).storedXp()==xp,"Egg trade preserves its exact stored XP");
AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg.copy());
check(!FamiliarBattle.prepare(p)&&transfer(p,"withdraw_all").equals("unavailable"),"A retained copy cannot steal the active new owner's balance");
check(transfer(q,"withdraw_all").equals("xp.transferred")&&FamiliarExperience.playerXp(q)==xp,"New owner can recover all stored XP");
check(FamiliarExperience.playerXp(p)==0&&FamiliarBattle.bond(p).storedXp()==0,"Copies do not hold another XP balance");
FamiliarBattle.release(q);AccessoryAccess.of(q).setItem(AccessoryContainer.FAMILIAR,net.minecraft.world.item.ItemStack.EMPTY);FamiliarService.tick(q);q.discard();
check(FamiliarBattle.prepare(p),"Empty traded egg can return to its original owner");p.tickCount=((p.tickCount/10)+1)*10;FamiliarService.tick(p);
System.out.println("XP054_TRADE_PASS: two players transfer a charged egg, recover its XP and reject a duplicated claim");
}
private static void recovery(ServerPlayer p){
var s=p.level().getServer();var store=FamiliarBattle.world(s).store;
try{
var original=FamiliarBattle.bond(p);var legacy=FamiliarCatalog.JSON.toJsonTree(original).getAsJsonObject();legacy.addProperty("schema",1);legacy.remove("storedXp");
check(FamiliarBondStore.parse(legacy).storedXp()==0,"Schema 1 migration starts with zero XP");
var migrationFile=s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("sanctuary/xp-migration-test.json");
var unknown=legacy.deepCopy();var unknownId=UUID.randomUUID();unknown.addProperty("id",unknownId.toString());unknown.addProperty("schema",99);
var individuals=new com.google.gson.JsonObject();individuals.add(original.id().toString(),legacy);individuals.add(unknownId.toString(),unknown);
var oldRoot=new com.google.gson.JsonObject();oldRoot.addProperty("schema",1);oldRoot.add("individuals",individuals);java.nio.file.Files.writeString(migrationFile,FamiliarCatalog.JSON.toJson(oldRoot));
var migrating=new FamiliarBondStore(migrationFile);check(migrating.get(unknownId)==null,"Unknown individual schema is disabled");migrating.put(migrating.get(original.id()));migrating.flush();
var migrated=com.google.gson.JsonParser.parseString(java.nio.file.Files.readString(migrationFile)).getAsJsonObject().getAsJsonObject("individuals");
check(migrated.get(unknownId.toString()).equals(unknown)&&migrated.getAsJsonObject(original.id().toString()).get("schema").getAsInt()==2,"Migration preserves unknown data while upgrading known individuals");
for(int phase:new int[]{0,1,2}){boolean paid=phase>0;
var before=FamiliarBattle.bond(p);var after=before.charge(before.storedXp()+7,FamiliarBattle.profile(p).health());long balance=FamiliarExperience.playerXp(p);
var transfer=new com.google.gson.JsonObject();String receipt=UUID.randomUUID().toString();transfer.addProperty("receipt",receipt);
transfer.add("before",FamiliarCatalog.JSON.toJsonTree(before));transfer.add("after",FamiliarCatalog.JSON.toJsonTree(after));transfer.addProperty("playerBefore",balance);transfer.addProperty("playerAfter",balance-7);
var entries=new com.google.gson.JsonObject();entries.add(p.getStringUUID(),transfer);var root=new com.google.gson.JsonObject();root.addProperty("schema",1);root.add("pending",entries);
store.flush();java.nio.file.Files.writeString(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("sanctuary/familiar-xp-v1.json"),FamiliarCatalog.JSON.toJson(root));
if(paid){FamiliarExperience.setPlayerXp(p,balance-7);p.setAttached(FamiliarExperience.RECEIPT,receipt);}
if(phase==2){store.put(after);store.flush();}
var reloaded=new FamiliarExperience(s,store);check(reloaded.blocked(before.id()),"Pending transfer freezes the individual");reloaded.recover(p);
check(store.get(before.id()).storedXp()==before.storedXp()+(paid?7:0),"Recovery commits only a paid native receipt");
reloaded.recover(p);check(FamiliarExperience.playerXp(p)==balance-(paid?7:0),"Repeated recovery never credits player XP twice");
}
// Original ledger resumes once the recovered journal is the same empty canonical document.
check(transfer(p,"withdraw_all").equals("xp.transferred"),"Recovered bank can be withdrawn exactly once");
}catch(Exception error){throw new AssertionError(error);}
}
}
@@ -0,0 +1,126 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
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.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.inventory.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.Vec3;
public final class Familiar020ClientChecks 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 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());onboarding.set(false);
}});
int scale=c.computeOnClient(m->m.options.guiScale().get());
var forward=c.computeOnClient(m->m.options.keyUp.getDefaultKey());
var posture=c.computeOnClient(m->KeyMapping.get("sanctuary.progression.lying_key").getDefaultKey());
var camera=c.computeOnClient(m->m.options.getCameraType());
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sanctuary beta020 interactions "+UUID.randomUUID());s.setSeed("42");
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());
s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);}).create()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&CompanionClient.record!=null&&m.gui.screen()==null,1200);
var server=world.getServer();server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var at=p.blockPosition().above(6);
for(int x=-10;x<=10;x++)for(int z=-10;z<=10;z++)for(int y=-1;y<9;y++)p.level().setBlockAndUpdate(at.offset(x,y,z),(y==-1?Blocks.STONE:Blocks.AIR).defaultBlockState());
p.teleportTo(at.getX()+.5,at.getY(),at.getZ()+.5);p.getInventory().clearContent();p.giveExperienceLevels(10-p.experienceLevel);
var egg=new ItemStack(Items.CAT_SPAWN_EGG);egg.set(DataComponents.CUSTOM_NAME,Component.literal("Nuage"));AccessoryAccess.of(p).setItem(0,egg);
p.getInventory().setItem(0,new ItemStack(Items.CAT_SPAWN_EGG));CompanionService.send(p,"");
});
c.waitFor(m->CompanionClient.species==CompanionType.CAT&&clientPet(m)!=null&&FamiliarSize.value(clientPet(m).egg())>0,200);
c.runOnClient(m->{
require(!ProgressionClient.operator&&!CompanionClient.studied&&CompanionClient.record.acceptAllies(),"Ordinary inhabitant starts with assistance on and study locked");
var lines=tooltip(m,new ItemStack(Items.CAT_SPAWN_EGG));
require(lines.stream().anyMatch(t->t.getString().contains("Velvet paws"))&&lines.stream().noneMatch(t->t.getString().contains("20%")),"Unlearned tooltip only gives cryptic names");
int distinct=(int)List.of(Items.CAT_SPAWN_EGG,Items.BEE_SPAWN_EGG,Items.BLAZE_SPAWN_EGG,Items.GHAST_SPAWN_EGG,Items.WITHER_SPAWN_EGG).stream()
.map(i->new ItemStack(i).getStyledHoverName().getStyle().getColor().getValue()).distinct().count();require(distinct==5,"Five rarity colors reach native hover names");
var pet=clientPet(m);var state=(FamiliarRenderer.State)m.getEntityRenderDispatcher().extractEntity(pet,.5F);
require(((net.minecraft.client.renderer.entity.state.LivingEntityRenderState)state.appearance).isBaby,"Native baby cat model selected");
m.options.guiScale().set(2);m.resizeGui();m.gui.setScreen(new InventoryScreen(m.player));
});c.waitTick();hover(c,36);c.takeScreenshot("sanctuary-beta020-egg-cryptic");
c.runOnClient(m->m.gui.setScreen(new ProgressionScreen(null)));c.waitTick();
c.runOnClient(m->{require(widgets(m.gui.screen()).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).noneMatch(w->key(w,"sanctuary.companion.open")||key(w,"sanctuary.companion.activate")),"Aptitudes have no familiar menu or use-power button");button(m.gui.screen(),"sanctuary.companion.study_buy").onPress(enter());});
c.waitFor(m->CompanionClient.studied,200);require(server.computeOnServer(s->s.getPlayerList().getPlayers().getFirst().experienceLevel)==6,"Actual study purchase costs four levels");
c.runOnClient(m->{var lines=tooltip(m,new ItemStack(Items.CAT_SPAWN_EGG));require(lines.stream().anyMatch(t->t.getString().contains("20%")),"Purchased study reveals actual mechanics");m.gui.setScreen(new InventoryScreen(m.player));});
c.waitTick();hover(c,36);c.takeScreenshot("sanctuary-beta020-egg-deciphered");
c.runOnClient(m->CompanionClient.openInhabitant(null));c.waitTick();
for(int gui:new int[]{2,3,4}){
c.runOnClient(m->{m.options.guiScale().set(gui);m.resizeGui();});c.waitTick();
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.companion.consent_on");for(var widget:widgets(m.gui.screen()).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).toList())
require(widget.getX()>=0&&widget.getRight()<=m.gui.screen().width,"Inhabitant widgets fit at every GUI scale");});
c.takeScreenshot("sanctuary-beta020-inhabitant-"+gui);
}
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();button(m.gui.screen(),"sanctuary.companion.consent_on").onPress(enter());});c.waitFor(m->!CompanionClient.record.acceptAllies(),200);
c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(3);
var at=server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);pet.setNoAi(true);pet.setDeltaMovement(Vec3.ZERO);pet.setPos(p.position().add(0,0,2));p.setItemInHand(net.minecraft.world.InteractionHand.MAIN_HAND,ItemStack.EMPTY);return pet.position().add(0,.35,0);});
c.getInput().holdKey(o->o.keyShift);c.waitTicks(3);face(c,at);c.waitTicks(2);
clickWorld(c,InputConstants.MOUSE_BUTTON_RIGHT);
c.waitFor(m->clientPet(m)!=null&&CarryService.carried(clientPet(m)),200);c.getInput().releaseKey(o->o.keyShift);c.waitTicks(3);
require(server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();return CarryService.carrying(p)&&p.getFirstPassenger()==FamiliarService.active(p);}),"Actual sneak-click carries equipped familiar");
c.runOnClient(m->{var state=(FamiliarRenderer.State)m.getEntityRenderDispatcher().extractEntity(clientPet(m),.5F);
require(((net.minecraft.client.renderer.entity.state.CatRenderState)state.appearance).isSitting,"Carried cat sits on head");m.options.setCameraType(CameraType.THIRD_PERSON_BACK);m.player.setXRot(10);});
c.waitTicks(3);c.takeScreenshot("sanctuary-beta020-carried-baby");
c.getInput().holdKey(o->o.keyShift);c.waitTicks(2);clickWorld(c,InputConstants.MOUSE_BUTTON_RIGHT);c.waitFor(m->!CarryService.carrying(m.player),200);c.getInput().releaseKey(o->o.keyShift);
c.runOnClient(m->{m.options.setCameraType(CameraType.FIRST_PERSON);m.options.keyUp.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_UP));KeyMapping.get("sanctuary.progression.lying_key").setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_F9));KeyMapping.resetMapping();m.player.setYRot(90);m.player.setXRot(0);});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var progress=ProgressEffects.get(p).buy("hunger",1);p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);p.getFoodData().setFoodLevel(8);ProgressionService.send(p,"");
// A small ledge leaves enough flight time to inspect the actual rendered dive.
var launch=p.blockPosition();for(int x=-2;x<=2;x++)for(int z=-2;z<=2;z++)for(int y=0;y<3;y++)p.level().setBlockAndUpdate(launch.offset(x,y,z),Blocks.STONE.defaultBlockState());
p.teleportTo(launch.getX()-1.5,launch.getY()+3,launch.getZ()+.5);
});
c.waitFor(m->ProgressionClient.progress.hunger()>0&&m.player.onGround(),200);
c.runOnClient(m->m.options.setCameraType(CameraType.THIRD_PERSON_BACK));
c.getInput().holdKey(o->o.keyUp);c.getInput().holdKey(o->o.keySprint);c.waitTicks(4);
c.getInput().holdKey(o->o.keyJump);c.waitTicks(2);c.getInput().releaseKey(o->o.keyJump);
c.getInput().pressKey(InputConstants.KEY_F9);
c.waitFor(m->LyingService.posture(m.player)==3,100);
c.runOnClient(m->{var state=(net.minecraft.client.renderer.entity.state.AvatarRenderState)m.getEntityRenderDispatcher().extractEntity(m.player,.5F);
require(state.swimAmount==1&&state.isVisuallySwimming,"Dive immediately renders the body horizontally");});
c.takeScreenshot("sanctuary-beta020-dive");c.getInput().releaseKey(o->o.keyUp);c.getInput().releaseKey(o->o.keySprint);
c.waitFor(m->m.player.onGround()&&LyingService.posture(m.player)==0,200);
c.runOnClient(m->m.options.setCameraType(CameraType.FIRST_PERSON));
for(int size:new int[]{120,6000}){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var egg=AccessoryAccess.of(p).getItem(0).copy();
net.minecraft.world.item.component.CustomData.update(DataComponents.CUSTOM_DATA,egg,tag->{var trait=new net.minecraft.nbt.CompoundTag();trait.putInt("schema",1);trait.putInt("per_mille",size);tag.put(FamiliarSize.KEY,trait);});AccessoryAccess.of(p).setItem(0,egg);
});c.waitFor(m->clientPet(m)!=null&&FamiliarSize.value(clientPet(m).egg())==size,200);
var position=server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var pet=FamiliarService.active(p);pet.setNoAi(true);pet.setDeltaMovement(Vec3.ZERO);pet.setPos(p.position().add(0,0,4));return pet.position().add(0,size==120?.1:1.2,0);});
face(c,position);c.waitTicks(4);c.takeScreenshot("sanctuary-beta020-size-"+size);
}
System.out.println("Familiar020 client passed: native cryptic/revealed tooltips, 5 rarity colors, study purchase, inhabitant GUI 2/3/4, baby sitting, real sneak-right-click carry/drop, configured dive key and horizontal render, landing, egg size synchronization and extremes.");
}finally{
onboarding.set(false);c.getInput().releaseKey(o->o.keyShift);c.getInput().releaseKey(o->o.keyUp);c.getInput().releaseKey(o->o.keyJump);c.getInput().releaseKey(o->o.keySprint);
c.runOnClient(m->{m.options.keyUp.setKey(forward);KeyMapping.get("sanctuary.progression.lying_key").setKey(posture);KeyMapping.resetMapping();m.options.setCameraType(camera);m.options.guiScale().set(scale);m.resizeGui();});
}
}
private static List<Component> tooltip(Minecraft m,ItemStack stack){return stack.getTooltipLines(Item.TooltipContext.of(m.level),m.player,TooltipFlag.NORMAL);}
private static FamiliarEntity clientPet(Minecraft m){for(var e:m.level.entitiesForRendering())if(e instanceof FamiliarEntity pet&&!pet.isRemoved())return pet;return null;}
private static void face(ClientGameTestContext c,Vec3 at){c.runOnClient(m->{var d=at.subtract(m.player.getEyePosition());m.player.setYRot((float)Math.toDegrees(Math.atan2(-d.x,d.z)));m.player.setXRot((float)-Math.toDegrees(Math.atan2(d.y,d.horizontalDistance())));});}
private static void clickWorld(ClientGameTestContext c,int button){c.runOnClient(m->{var w=m.getWindow();m.mouseHandler.onButton(w.handle(),new MouseButtonInfo(button,0),1);});c.waitTick();c.runOnClient(m->m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(button,0),0));c.waitTick();}
private static void hover(ClientGameTestContext c,int index){c.runOnClient(m->{var screen=(AbstractContainerScreen<?>)m.gui.screen();var a=(AbstractContainerScreenAccessor)screen;var slot=screen.getMenu().getSlot(index);var w=m.getWindow();m.mouseHandler.onMove(w.handle(),(a.sanctuary$getLeftPos()+slot.x+8.0)*w.getScreenWidth()/w.getGuiScaledWidth(),(a.sanctuary$getTopPos()+slot.y+8.0)*w.getScreenHeight()/w.getGuiScaledHeight(),0,0);m.mouseHandler.handleAccumulatedMovement();});c.waitTick();}
private static Stream<GuiEventListener> widgets(ContainerEventHandler container){return container.children().stream().flatMap(w->w instanceof ContainerEventHandler nested?Stream.concat(Stream.of(w),widgets(nested)):Stream.of(w));}
private static boolean key(AbstractWidget w,String key){return w.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key);}
private static Button button(Screen screen,String key){return widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(w->key(w,key)).findFirst().orElseThrow(()->new AssertionError("Missing "+key));}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean value,String message){if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,115 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.client.shader.VanillaLight;
import fr.koka.sanctuary.client.panorama.*;
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.minecraft.client.*;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.renderer.fog.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.effect.*;
import net.minecraft.world.level.material.FogType;
public final class Fog053ClientChecks 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("Fog053 "+UUID.randomUUID());s.setSeed("53");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setAllowCommands(true);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);
world.getServer().runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");
var base=p.blockPosition();int i=0;
for(int d:new int[]{8,32,64,96}){int x=(i++%2==0?-1:1)*d/4;for(int dy=0;dy<5;dy++)for(int dx=-2;dx<=2;dx++)p.level().setBlockAndUpdate(base.offset(x+dx,dy,d),net.minecraft.world.level.block.Blocks.CONCRETE.red().defaultBlockState());}
});
c.runOnClient(m->{m.options.renderDistance().set(8);m.options.bobView().set(false);m.options.fov().set(70);m.player.setYRot(0);m.player.yRotO=0;m.player.setXRot(8);m.player.xRotO=8;VanillaLight.setEnabled(false);});
c.waitTicks(35);
c.runOnClient(m->{
var nativeFog=setup(m,m.gameRenderer.mainCamera());require(nativeFog.renderDistanceStart>0,"Native distant start before enabling shader");
VanillaLight.setEnabled(true);var graded=setup(m,m.gameRenderer.mainCamera());require(graded.renderDistanceStart==0&&graded.environmentalStart<=0,"Fog begins immediately at the viewpoint");
sameEnds(nativeFog,graded);float prior=-1;
for(float d:new float[]{0,1,4,16,32,64,96,128}){float f=PanoramaDepth.fog(new org.joml.Vector3f(0,0,d),PanoramaDepth.Fog.of(graded));require(f>=prior&&f>=0&&f<=1,"Continuous monotonic native GPU fog curve");if(d==0)require(f==0,"No veil at zero distance");if(d==1)require(f>0&&f<.02,"Very light fog starts close to the player");prior=f;}
require(prior==1,"Fog fully hides the render boundary");VanillaLight.setEnabled(false);
});
c.waitTicks(6);c.takeScreenshot("sanctuary-beta053-fog-off");
c.runOnClient(m->VanillaLight.setEnabled(true));c.waitFor(m->m.gameRenderer.getAppliedPostEffects().contains(VanillaLight.EFFECT),100);c.waitTicks(6);c.takeScreenshot("sanctuary-beta053-fog-on");
var chain=c.computeOnClient(m->m.getShaderManager().getPostChain(VanillaLight.EFFECT,net.minecraft.client.renderer.LevelTargetBundle.MAIN_TARGETS));
for(int saturation:new int[]{0,100,200}){
c.runOnClient(m->VanillaLight.setSaturation(saturation));c.waitTicks(6);
c.runOnClient(m->require(m.getShaderManager().getPostChain(VanillaLight.EFFECT,net.minecraft.client.renderer.LevelTargetBundle.MAIN_TARGETS)==chain,"Live saturation reuses the compiled shader chain"));
c.takeScreenshot("sanctuary-beta053-saturation-"+saturation);
}
c.runOnClient(m->{
VanillaLight.setSaturation(170);
try{var pref=VanillaLight.class.getDeclaredField("preferences");pref.setAccessible(true);pref.set(null,null);}catch(ReflectiveOperationException e){throw new RuntimeException(e);}
require(VanillaLight.saturation()==170,"Saturation preference survives reload from disk");
VanillaLight.setSaturation(999);require(VanillaLight.saturation()==200,"Upper saturation limit");VanillaLight.setSaturation(-1);require(VanillaLight.saturation()==0,"Lower saturation limit");VanillaLight.setSaturation(100);
});
for(String locale:List.of("fr_fr","en_us")){
c.runOnClient(m->{
m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
VanillaLight.setSaturation(100);m.gui.setScreen(new fr.koka.sanctuary.client.shader.ShaderOptionsScreen(null));
var slider=widgets(m.gui.screen()).filter(AbstractSliderButton.class::isInstance).map(AbstractSliderButton.class::cast).findFirst().orElseThrow();
require(slider.getMessage().getString().contains("100")&&slider.getMessage().getString().contains("Saturation"),"Localized native saturation slider");
slider.keyPressed(enter());for(int i=0;i<8;i++)slider.keyPressed(new KeyEvent(InputConstants.KEY_RIGHT,0,0));
require(VanillaLight.saturation()>100,"Keyboard interaction immediately applies native slider value");
});c.waitTicks(4);c.takeScreenshot("sanctuary-beta053-shader-options-"+locale);
}
c.runOnClient(m->{m.gui.setScreen(null);VanillaLight.setSaturation(100);});
for(CameraType type:List.of(CameraType.FIRST_PERSON,CameraType.THIRD_PERSON_BACK))for(int fov:new int[]{30,110}){
c.runOnClient(m->{m.options.setCameraType(type);m.options.fov().set(fov);m.player.setYRot(30);m.player.setXRot(-10);});c.waitTicks(5);
c.runOnClient(m->{var f=m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData;require(f.renderDistanceStart==0&&f.renderDistanceEnd==128,"Camera and FOV preserve the native distance scale");});
}
c.runOnClient(m->{m.options.setCameraType(CameraType.FIRST_PERSON);m.options.fov().set(70);m.player.setYRot(0);m.player.setXRot(8);
var live=m.gameRenderer.mainCamera();
for(var medium:List.of(FogType.WATER,FogType.LAVA,FogType.POWDER_SNOW)){
Camera camera=new MediumCamera(live,medium);VanillaLight.setEnabled(false);var before=setup(m,camera);VanillaLight.setEnabled(true);sameFog(before,setup(m,camera));
}
for(var effect:List.of(MobEffects.BLINDNESS,MobEffects.DARKNESS)){
m.player.addEffect(new MobEffectInstance(effect,200));VanillaLight.setEnabled(false);var before=setup(m,live);VanillaLight.setEnabled(true);sameFog(before,setup(m,live));m.player.removeEffect(effect);
}
m.level.setRainLevel(1);m.level.setThunderLevel(1);VanillaLight.setEnabled(false);var before=setup(m,live);VanillaLight.setEnabled(true);sameEnds(before,setup(m,live));m.level.setRainLevel(0);m.level.setThunderLevel(0);
});
for(String locale:List.of("fr_fr","en_us"))c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());require(net.minecraft.network.chat.Component.translatable("sanctuary.shaders.help").getString().contains(locale.equals("fr_fr")?"brume progressive":"progressive fog"),"Localized shader tooltip");});
var reload=c.computeOnClient(m->m.reloadResourcePacks());c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();c.waitTicks(6);
c.runOnClient(m->require(m.gameRenderer.getAppliedPostEffects().contains(VanillaLight.EFFECT)&&setup(m,m.gameRenderer.mainCamera()).renderDistanceStart==0,"Native shader and fog survive resource reload"));
c.runOnClient(m->{PanoramaOptions.get().resolution=256;PanoramaCapture.request(m);});
c.waitFor(m->PanoramaCapture.lastCapture().isDone(),1200);var panorama=c.computeOnClient(m->PanoramaCapture.lastCapture().join());
try{var data=fr.koka.sanctuary.progression.AtomicJson.parse(java.nio.file.Files.readString(panorama.resolve("capture.json"))).getAsJsonObject();
for(var face:data.getAsJsonArray("faces"))require(face.getAsJsonObject().getAsJsonObject("fog").get("renderStart").getAsFloat()>0,"Technical panorama retains raw native fog matching its depth mask");
}catch(java.io.IOException e){throw new RuntimeException(e);}
c.waitTicks(5);c.runOnClient(m->require(setup(m,m.gameRenderer.mainCamera()).renderDistanceStart==0,"Normal progressive fog resumes after six panorama faces"));
c.takeScreenshot("sanctuary-beta053-fog-restored");
}
System.out.println("FOG053_CLIENT_PASS: native progressive fog, toggle, near/far, camera/FOV, fluids/effects/weather, FR/EN, resource reload and raw panorama restoration");
System.out.println("SATURATION053_CLIENT_PASS: 0/100/200 live GPU screenshots, native FR/EN keyboard slider, bounded saved preference, no shader reload per change");
}
private static FogData setup(Minecraft m,Camera camera){try{var field=net.minecraft.client.renderer.GameRenderer.class.getDeclaredField("fogRenderer");field.setAccessible(true);return ((FogRenderer)field.get(m.gameRenderer)).setupFog(camera,8,DeltaTracker.ZERO,0,m.level);}catch(ReflectiveOperationException e){throw new RuntimeException(e);}}
private static void sameEnds(FogData a,FogData b){require(a.environmentalEnd==b.environmentalEnd&&a.renderDistanceEnd==b.renderDistanceEnd&&a.skyEnd==b.skyEnd&&a.cloudEnd==b.cloudEnd&&a.color.equals(b.color),"Native ends, sky/cloud limits and colour preserved");}
private static void sameFog(FogData a,FogData b){sameEnds(a,b);require(a.environmentalStart==b.environmentalStart&&a.renderDistanceStart==b.renderDistanceStart,"Special visibility restriction remains entirely native");}
private static final class MediumCamera extends Camera {
private final Camera live;private final FogType medium;
MediumCamera(Camera live,FogType medium){this.live=live;this.medium=medium;}
@Override public FogType getFluidInCamera(){return medium;}
@Override public net.minecraft.world.entity.Entity entity(){return live.entity();}
@Override public net.minecraft.world.phys.Vec3 position(){return live.position();}
@Override public net.minecraft.core.BlockPos blockPosition(){return live.blockPosition();}
@Override public net.minecraft.world.attribute.EnvironmentAttributeProbe attributeProbe(){return live.attributeProbe();}
@Override public org.joml.Vector3fc forwardVector(){return live.forwardVector();}
}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static java.util.stream.Stream<net.minecraft.client.gui.components.events.GuiEventListener> widgets(net.minecraft.client.gui.components.events.ContainerEventHandler root){return root.children().stream().flatMap(w->w instanceof net.minecraft.client.gui.components.events.ContainerEventHandler child?java.util.stream.Stream.concat(java.util.stream.Stream.of(w),widgets(child)):java.util.stream.Stream.of(w));}
private static void require(boolean v,String text){if(!v)throw new AssertionError(text);}
}
@@ -0,0 +1,134 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.grave.*;
import fr.koka.sanctuary.food.*;
import fr.koka.sanctuary.client.panorama.*;
import net.minecraft.core.component.DataComponents;
import java.nio.file.*;
import java.nio.*;
import javax.imageio.ImageIO;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor;
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.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.inventory.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.core.BlockPos;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.SkullBlockEntity;
import net.minecraft.world.level.gamerules.GameRules;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
public final class FoodPanorama039ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
Starter039TestSupport.manual=true;
var starterFrames=new java.util.concurrent.atomic.AtomicInteger();
var starterOptions=new java.util.concurrent.atomic.AtomicReference<java.util.List<StarterCompanions.Choice>>();
var starterChosen=new java.util.concurrent.atomic.AtomicReference<String>();
ClientTickEvents.END_CLIENT_TICK.register(m->{if(m.gui.screen() instanceof HelloWorldScreen screen&&!screen.starterChoices().isEmpty()){
require(m.player==null&&m.level==null,"Starter chosen before any world or player appears");
int frame=starterFrames.incrementAndGet();
if(frame==1){starterOptions.set(screen.starterChoices());require(screen.starterChoices().size()==3,"Three starters displayed");require(!button(screen,"sanctuary.progression.enter").active,"Entering requires colour and starter");m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());}
if(frame==3){var slot=widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("StarterSlot")).findFirst().orElseThrow();var w=m.getWindow();m.mouseHandler.onMove(w.handle(),(slot.getX()+11.0)*w.getScreenWidth()/w.getGuiScaledWidth(),(slot.getY()+11.0)*w.getScreenHeight()/w.getGuiScaledHeight(),0,0);m.mouseHandler.handleAccumulatedMovement();}
if(frame==8){net.minecraft.client.Screenshot.grab(m,false);net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationNetworking.send(new StarterPayloads.Create("",0,"ender_dragon"));}
if(frame==18){require(screen.starterChoices().equals(starterOptions.get()),"Forged choice cannot reroll the offer");starterChosen.set(screen.starterChoices().getFirst().mob());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());}
}});
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave savedWorld;
var armed=new AtomicBoolean(true);
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sanctuary food panorama039 "+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();
savedWorld=world.getWorldSave();
c.runOnClient(m->{m.getLanguageManager().setSelected("en_us");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var store=ProgressionService.session(s).starters;require(store.get(p.getUUID()).selected().equals(starterChosen.get()),"Server persists chosen starter");require(starterChosen.get().equals(p.getAttached(StarterCompanions.RECEIPT)),"Starter delivery receipt on first arrival");require(fr.koka.sanctuary.inventory.AccessoryAccess.of(p).getItem(fr.koka.sanctuary.inventory.AccessoryContainer.FAMILIAR).getItem()==StarterCompanions.egg(starterChosen.get()).getItem(),"Selected egg is really equipped");});
server.runOnServer(s->{s.getGlobalGameRules().set(GameRules.NATURAL_HEALTH_REGENERATION,false,s);var p=s.getPlayerList().getPlayers().getFirst();p.getFoodData().setFoodLevel(1);p.getFoodData().setSaturation(0);p.getInventory().setItem(0,new ItemStack(Items.APPLE,12));p.inventoryMenu.broadcastChanges();});
c.waitFor(m->FoodKnowledgeClient.ready()&&FoodDetailsClient.current()!=null,150);
c.runOnClient(m->{require(!FoodKnowledgeClient.knows(new ItemStack(Items.APPLE)),"Untasted food hidden");require(new ItemStack(Items.APPLE).getTooltipImage().isEmpty(),"Unknown tooltip has no food metrics");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stack=new ItemStack(Items.APPLE);stack.get(DataComponents.CONSUMABLE).onConsume(p.level(),p,stack);});
c.waitFor(m->FoodKnowledgeClient.knows(new ItemStack(Items.APPLE)),100);
c.runOnClient(m->{require(new ItemStack(Items.APPLE).getTooltipImage().orElseThrow() instanceof FoodDetailsTooltip.Data,"Tasting reveals native icon tooltip");require(new ItemStack(Items.COOKED_BEEF).getTooltipImage().isEmpty(),"Untasted steak remains hidden");});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setExperienceLevels(8);ProgressionService.send(p,"");});
c.waitFor(m->m.player.experienceLevel==8,100);
c.runOnClient(m->m.gui.setScreen(new ProgressionScreen(null)));c.waitTicks(5);
c.runOnClient(m->{var b=button(m.gui.screen(),"sanctuary.food_knowledge.buy");require(b.active,"Food study available with XP");b.onPress(enter());});
c.waitFor(m->m.player.experienceLevel==4&&FoodKnowledgeClient.knows(new ItemStack(Items.COOKED_BEEF)),100);
c.takeScreenshot("sanctuary-beta039-food-study");c.runOnClient(m->m.gui.setScreen(null));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var stack=GraveService.head(p,List.of(new ItemStack(Items.DIAMOND)));fr.koka.sanctuary.inventory.AccessoryAccess.of(p).setItem(fr.koka.sanctuary.inventory.AccessoryContainer.HEAD,stack);});
c.waitFor(m->fr.koka.sanctuary.cosmetics.HeadCosmeticService.appearance(m.player).is(Items.PLAYER_HEAD),150);
c.runOnClient(m->{var stack=fr.koka.sanctuary.cosmetics.HeadCosmeticService.appearance(m.player);var h=((HeadCosmeticState.Access)m.getEntityRenderDispatcher().extractEntity(m.player,.5f)).sanctuary$headCosmetic();require(h.blockEntity instanceof net.minecraft.client.renderer.blockentity.state.SkullBlockRenderState,"Skull uses native component-aware renderer");var skull=(net.minecraft.client.renderer.blockentity.state.SkullBlockRenderState)h.blockEntity;require(skull.renderType==m.playerSkinRenderCache().getOrDefault(stack.get(DataComponents.PROFILE)).renderType(),"Skull uses owner's skin cache, not a generic Steve texture");require(h.block.isEmpty(),"No generic block model over the skull");m.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_FRONT);});
c.waitTicks(5);c.takeScreenshot("sanctuary-beta039-head-skin");
c.runOnClient(m->{m.gui.setScreen(new PanoramaOptionsScreen(null));});c.waitTicks(5);c.takeScreenshot("sanctuary-beta039-panorama-options");c.runOnClient(m->m.gui.setScreen(null));
c.runOnClient(m->{m.player.setYRot(33);m.player.setXRot(17);m.player.yRotO=31;m.player.xRotO=15;});
var before=c.computeOnClient(m->List.of(m.getWindow().getWidth(),m.getWindow().getHeight(),m.options.fov().get(),m.options.getCameraType()));
c.runOnClient(m->{PanoramaOptions.get().enabled=true;PanoramaOptions.get().resolution=256;net.minecraft.client.Screenshot.grab(m,false);require(m.player.getYRot()==33&&m.player.getXRot()==17&&m.player.yRotO==31&&m.player.xRotO==15,"Camera angles restored synchronously");});
c.waitFor(m->PanoramaCapture.lastCapture().isDone(),1200);
Path first=c.computeOnClient(m->PanoramaCapture.lastCapture().join());
require(c.computeOnClient(m->List.of(m.getWindow().getWidth(),m.getWindow().getHeight(),m.options.fov().get(),m.options.getCameraType())).equals(before),"Window, FOV and third-person choice restored");
verifyCapture(first);
c.waitFor(m->!PanoramaCapture.busy(),100);
c.runOnClient(m->net.minecraft.client.Screenshot.grab(m,false));c.waitFor(m->PanoramaCapture.lastCapture().isDone(),1200);
Path second=c.computeOnClient(m->PanoramaCapture.lastCapture().join());require(!first.equals(second),"Second panorama cannot overwrite first");verifyCapture(second);
c.waitFor(m->!PanoramaCapture.busy(),100);
c.runOnClient(m->{PanoramaOptions.get().enabled=false;PanoramaOptions.get().resolution=1024;net.minecraft.client.Screenshot.grab(m,false);require(PanoramaCapture.lastCapture().join().equals(second),"Normal screenshot does not invoke panorama");});
c.waitTicks(5);c.takeScreenshot("sanctuary-beta039-after-panorama");
server.runOnServer(sv->{
var p=sv.getPlayerList().getPlayers().getFirst();var progress=ProgressRecord.INITIAL;for(int i=1;i<6;i++)progress=progress.buy("inventory",i);p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);
p.getInventory().clearContent();fr.koka.sanctuary.inventory.AccessoryAccess.of(p).clearContent();fr.koka.sanctuary.inventory.HotbarRows.select(p,2);
p.getInventory().setItem(0,new ItemStack(Items.DIAMOND,12));p.getInventory().setItem(10,new ItemStack(Items.EMERALD,3));fr.koka.sanctuary.inventory.SanctuaryOverflowAccess.of(p).setItem(17,new ItemStack(Items.APPLE,5));
p.setItemSlot(net.minecraft.world.entity.EquipmentSlot.HEAD,new ItemStack(Items.IRON_HELMET));fr.koka.sanctuary.inventory.AccessoryAccess.of(p).setItem(fr.koka.sanctuary.inventory.AccessoryContainer.CAPE,new ItemStack(fr.koka.sanctuary.cosmetics.AccessoryService.ZERO_CAPE));fr.koka.sanctuary.inventory.AccessoryAccess.of(p).setItem(fr.koka.sanctuary.inventory.AccessoryContainer.FAMILIAR,new ItemStack(Items.CHICKEN_SPAWN_EGG));
var layout=GraveLayout.capture(p);for(int i=0;i<p.getInventory().getContainerSize();i++)layout.collect(p.getInventory().getItem(i));for(int i=0;i<3;i++)layout.collect(fr.koka.sanctuary.inventory.AccessoryAccess.of(p).getItem(i));
var head=GraveService.head(p,layout.items());var data=head.get(DataComponents.CUSTOM_DATA).copyTag();var marker=data.getCompound(GraveService.MARKER).orElseThrow();layout.info.write(marker);data.put(GraveService.MARKER,marker);head.set(DataComponents.CUSTOM_DATA,net.minecraft.world.item.component.CustomData.of(data));
p.getInventory().clearContent();fr.koka.sanctuary.inventory.AccessoryAccess.of(p).clearContent();fr.koka.sanctuary.inventory.HotbarRows.select(p,0);p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(p,ProgressRecord.INITIAL);ProgressionService.send(p,"");p.getInventory().setItem(0,head);GraveService.open(p,head);
});
c.waitForScreen(GraveInventoryScreen.class);c.waitFor(m->((GraveInventoryMenu)m.player.containerMenu).visibleRows()==6&&m.player.containerMenu.getSlot(18).hasItem(),150);c.waitTicks(5);
c.runOnClient(m->{var screen=(GraveInventoryScreen)m.gui.screen();var menu=screen.getMenu();require(menu.getSlot(18).x<menu.getSlot(135).x,"Death cells are left of current inventory");require(menu.rows()==6&&ProgressionClient.progress.inventoryRows()==1,"Six death rows, one current row");require(!menu.getSlot(0).mayPlace(new ItemStack(Items.STONE)),"Empty death cells are read-only");});
c.takeScreenshot("sanctuary-beta039-grave-inventories");
for(int slot:new int[]{18,54,59,60}){final int cell=slot;c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,cell,0,net.minecraft.world.inventory.ContainerInput.QUICK_MOVE,m.player));c.waitFor(m->m.player.containerMenu.getSlot(cell).getItem().isEmpty(),100);}
c.waitTicks(6);
require(server.computeOnServer(sv->{var p=sv.getPlayerList().getPlayers().getFirst();System.out.println("GRAVE039 recovery state: "+java.util.stream.IntStream.range(0,9).mapToObj(i->p.getInventory().getItem(i).toString()).toList()+" helmet="+p.getItemBySlot(net.minecraft.world.entity.EquipmentSlot.HEAD)+" accessories="+java.util.stream.IntStream.range(0,3).mapToObj(i->fr.koka.sanctuary.inventory.AccessoryAccess.of(p).getItem(i).toString()).toList());return p.getInventory().contains(new ItemStack(Items.DIAMOND))&&p.getItemBySlot(net.minecraft.world.entity.EquipmentSlot.HEAD).is(Items.IRON_HELMET)&&fr.koka.sanctuary.inventory.AccessoryAccess.of(p).getItem(fr.koka.sanctuary.inventory.AccessoryContainer.CAPE).is(fr.koka.sanctuary.cosmetics.AccessoryService.ZERO_CAPE)&&fr.koka.sanctuary.inventory.AccessoryAccess.of(p).getItem(fr.koka.sanctuary.inventory.AccessoryContainer.FAMILIAR).is(Items.CHICKEN_SPAWN_EGG);}),"Real recovery packets route storage, helmet, cape and familiar correctly");
c.waitTicks(5);c.takeScreenshot("sanctuary-beta039-grave-recovery");c.runOnClient(m->{m.options.guiScale().set(1);m.resizeGui();});c.waitTicks(5);c.takeScreenshot("sanctuary-beta039-grave-scale1");
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());});c.waitTicks(5);c.takeScreenshot("sanctuary-beta039-grave-fr");
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitForScreen(null);c.runOnClient(m->m.options.setCameraType(net.minecraft.client.CameraType.FIRST_PERSON));
server.runOnServer(sv->{var p=sv.getPlayerList().getPlayers().getFirst();p.getFoodData().setFoodLevel(3);p.getFoodData().setSaturation(0);p.getFoodData().addExhaustion(3.5f);});c.waitTicks(12);c.takeScreenshot("sanctuary-beta039-food-underlay");
server.runOnServer(sv->{var p=sv.getPlayerList().getPlayers().getFirst();fr.koka.sanctuary.inventory.AccessoryAccess.of(p).clearContent();sv.saveEverything(true,true,true);});
System.out.println("FOOD PANORAMA039 client passed: hidden/tasted/studied tooltips, real purchase, owner skull renderer, six colour/depth/fog faces, repeated capture, restored camera. Output: "+first);
}
int beforeReconnect=starterFrames.get();
try(var world=savedWorld.open()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
require(starterFrames.get()==beforeReconnect,"Returning resident skips starter selection");
world.getServer().runOnServer(sv->{var p=sv.getPlayerList().getPlayers().getFirst();require(starterChosen.get().equals(p.getAttached(StarterCompanions.RECEIPT))&&fr.koka.sanctuary.inventory.AccessoryAccess.of(p).getItem(fr.koka.sanctuary.inventory.AccessoryContainer.FAMILIAR).isEmpty(),"Reconnect never replaces a removed starter");});
System.out.println("STARTER039_CLIENT_PASS: configuration choice, forged choice rejected, equipped once, reconnect without a second gift");
}finally{armed.set(false);Starter039TestSupport.manual=false;PanoramaOptions.get().enabled=false;}
}
private static void verifyCapture(Path folder){try{
var data=com.google.gson.JsonParser.parseString(Files.readString(folder.resolve("capture.json"))).getAsJsonObject();require(data.getAsJsonArray("faces").size()==6,"Metadata has six faces");
boolean zeroToOne=data.get("clip_z_zero_to_one").getAsBoolean();
for(int i=0;i<6;i++){
for(String prefix:List.of("panorama_","depth_","fog_")){var image=ImageIO.read(folder.resolve(prefix+i+".png").toFile());require(image.getWidth()==256&&image.getHeight()==256,"Square export "+prefix+i);}
byte[] raw=Files.readAllBytes(folder.resolve("depth_"+i+".f32"));require(raw.length==256*256*4,"Depth float32 size");
var bytes=ByteBuffer.wrap(raw).order(ByteOrder.LITTLE_ENDIAN);int surfaces=0;for(int n=0;n<256*256;n++){float z=bytes.getFloat();require(Float.isFinite(z)&&z>=0&&z<=1,"Valid reversed GPU depth");if(z>0)surfaces++;}
if(i==5)require(surfaces>256*256/2,"Bottom face contains terrain depth, not a buffer cleared by HUD");
var metadata=data.getAsJsonArray("faces").get(i).getAsJsonObject();var numbers=metadata.getAsJsonArray("inverse_view_projection_column_major");float[] inv=new float[16];for(int n=0;n<16;n++)inv[n]=numbers.get(n).getAsFloat();
var numbers2=metadata.getAsJsonArray("view_projection_column_major");float[] vp=new float[16];for(int n=0;n<16;n++)vp[n]=numbers2.get(n).getAsFloat();
var world=PanoramaDepth.position(new Matrix4f().set(inv),.2f,-.4f,.5f,zeroToOne);var clip=new Matrix4f().set(vp).transformProject(new Vector3f(world));require(Math.abs(clip.x-.2f)<.001&&Math.abs(clip.y+.4f)<.001&&Math.abs(clip.z-(zeroToOne?.5f:0))<.001,"Inverse projection matches backend depth convention");
}
}catch(Exception e){throw new RuntimeException(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 Button button(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);}
private static void require(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,77 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.grave.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor;
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.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.inventory.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.core.BlockPos;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.SkullBlockEntity;
import net.minecraft.world.level.gamerules.GameRules;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
public final class GravesFood038ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var armed=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(armed.get()&&m.gui.screen() instanceof HelloWorldScreen 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());armed.set(false);}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sanctuary graves food038 "+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->{s.getGlobalGameRules().set(GameRules.NATURAL_HEALTH_REGENERATION,false,s);s.setDifficulty(net.minecraft.world.Difficulty.NORMAL,true);var p=s.getPlayerList().getPlayers().getFirst();fr.koka.sanctuary.food.FoodKnowledgeService.tasted(p,new ItemStack(Items.COOKED_BEEF));p.getInventory().setItem(0,new ItemStack(Items.COOKED_BEEF,12));p.getFoodData().setFoodLevel(3);p.getFoodData().setSaturation(1.5f);p.getFoodData().addExhaustion(2);p.inventoryMenu.broadcastChanges();});
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.player.setXRot(10);});
c.waitFor(m->FoodDetailsClient.current()!=null&&FoodDetailsClient.current().food()==3&&FoodDetailsClient.current().capacity()==6,150);
c.waitTicks(5);c.runOnClient(m->require(FoodDetailsClient.renderedIcons()==3,"Native hunger hook renders exactly three icons"));c.takeScreenshot("sanctuary-beta038-food-three");
c.runOnClient(m->{var data=(FoodDetailsTooltip.Data)new ItemStack(Items.COOKED_BEEF).getTooltipImage().orElseThrow();require(data.food()==8&&Math.abs(data.saturation()-12.8f)<.001&&data.foodGain()==3&&data.overflow(),"Native tooltip supplies food/saturation icons with capacity-aware gain");require(net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipComponent.create(data) instanceof FoodDetailsTooltip,"Fabric maps the native item image to the food icon renderer");m.gui.setScreen(new InventoryScreen(m.player));});c.waitTicks(3);hover(c,36);c.waitTicks(5);c.takeScreenshot("sanctuary-beta038-food-tooltip");c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitForScreen(null);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var r=ProgressEffects.get(p);for(int i=0;i<7;i++)r=r.buy("hunger",1);for(int i=0;i<5;i++)r=r.buy("inventory",1);p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);ProgressionService.send(p,"");p.getFoodData().setFoodLevel(13);p.getFoodData().setSaturation(7.5f);});
c.waitFor(m->ProgressionClient.progress.inventoryRows()==6&&FoodDetailsClient.current()!=null&&FoodDetailsClient.current().capacity()==20&&FoodDetailsClient.renderedIcons()==10,150);c.waitTicks(5);c.takeScreenshot("sanctuary-beta038-food-ten");
server.runOnServer(s->Companion019GameTests.equip(s.getPlayerList().getPlayers().getFirst(),fr.koka.sanctuary.companion.CompanionType.PIG));
c.waitFor(m->FoodDetailsClient.current()!=null&&FoodDetailsClient.current().foodMultiplier()>1,200);
c.runOnClient(m->require(Math.abs(FoodDetailsClient.current().preview(new ItemStack(Items.APPLE)).saturationGain()-2.64f)<.01,"Server pig bonus reaches the actual client food preview"));
server.runOnServer(s->fr.koka.sanctuary.inventory.AccessoryAccess.of(s.getPlayerList().getPlayers().getFirst()).setItem(0,ItemStack.EMPTY));c.waitFor(m->FoodDetailsClient.current().foodMultiplier()==1,100);
c.runOnClient(m->m.gui.setScreen(new FoodDetailsOptionsScreen(null)));c.waitTicks(5);c.takeScreenshot("sanctuary-beta038-food-options");c.runOnClient(m->m.gui.setScreen(null));
BlockPos pos=server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var at=p.blockPosition().east(2);p.level().setBlockAndUpdate(at,Blocks.PLAYER_HEAD.defaultBlockState());var skull=(SkullBlockEntity)p.level().getBlockEntity(at);var items=new ArrayList<ItemStack>();for(int i=0;i<64;i++)items.add(new ItemStack(i==54?Items.EMERALD:Items.DIAMOND,i+1));skull.applyComponentsFromItemStack(GraveService.head(p,items));skull.setChanged();p.level().sendBlockUpdated(at,skull.getBlockState(),skull.getBlockState(),3);return at;});
c.waitFor(m->GraveService.isGrave(m.level.getBlockEntity(pos)),100);
c.runOnClient(m->{var result=m.gameMode.useItemOn(m.player,net.minecraft.world.InteractionHand.MAIN_HAND,new net.minecraft.world.phys.BlockHitResult(net.minecraft.world.phys.Vec3.atCenterOf(pos),net.minecraft.core.Direction.UP,pos,false));require(result.consumesAction(),"Real client block interaction consumes the right-click");});
c.waitForScreen(GraveScreen.class);c.waitFor(m->m.player.containerMenu.getSlot(0).getItem().is(Items.DIAMOND),100);c.waitTicks(5);c.takeScreenshot("sanctuary-beta038-grave-page1");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.grave.page").onPress(enter()));c.waitFor(m->((GraveMenu)m.player.containerMenu).page()==1&&m.player.containerMenu.getSlot(0).getItem().is(Items.EMERALD),100);c.waitTicks(5);c.takeScreenshot("sanctuary-beta038-grave-page2");
click(c,0,true);c.waitFor(m->m.player.containerMenu.getSlot(0).getItem().isEmpty(),100);
require(server.computeOnServer(s->GraveService.container((SkullBlockEntity)s.overworld().getBlockEntity(pos)).getItem(54).isEmpty()),"Real packet extraction removes second-page contents server-side");
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitForScreen(null);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var skull=(SkullBlockEntity)p.level().getBlockEntity(pos);var head=Block.getDrops(skull.getBlockState(),p.level(),pos,skull).getFirst();p.level().removeBlock(pos,false);p.getInventory().setItem(2,head);GraveService.open(p,head);});
c.waitForScreen(GraveScreen.class);c.waitFor(m->m.player.containerMenu.getSlot(0).getItem().is(Items.DIAMOND),100);c.waitTicks(5);c.takeScreenshot("sanctuary-beta038-grave-portable");
click(c,0,true);c.waitFor(m->m.player.containerMenu.getSlot(0).getItem().isEmpty(),100);c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitForScreen(null);
c.runOnClient(m->m.options.guiScale().set(1));c.runOnClient(m->m.resizeGui());
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();GraveService.open(p,p.getInventory().getItem(2));});c.waitForScreen(GraveScreen.class);c.waitTicks(5);c.takeScreenshot("sanctuary-beta038-grave-scale1");
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitForScreen(null);
c.runOnClient(m->m.gui.setScreen(new InventoryScreen(m.player)));c.waitTicks(3);hover(c,38);c.waitTicks(5);c.takeScreenshot("sanctuary-beta038-grave-memory");c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitForScreen(null);
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new FoodDetailsOptionsScreen(null));});c.waitTicks(5);c.takeScreenshot("sanctuary-beta038-food-options-fr");
c.runOnClient(m->{require(FoodDetailsClient.label("food_icons").getString().equals("Nourriture"),"French icon label is translated");FoodDetailsClient.options().tooltips=false;require(new ItemStack(Items.COOKED_BEEF).getTooltipImage().isEmpty(),"Tooltip display switch removes food icons");FoodDetailsClient.options().tooltips=true;m.gui.setScreen(new InventoryScreen(m.player));});c.waitTicks(3);hover(c,36);c.waitTicks(5);c.takeScreenshot("sanctuary-beta038-food-tooltip-fr");c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitForScreen(null);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setExperienceLevels(10);ProgressionService.send(p,"");});
c.waitFor(m->m.player.experienceLevel==10,100);c.runOnClient(m->CycleClient.open(null,"faction"));
c.waitFor(m->CycleClient.view!=null&&CycleClient.view.prestige()==0&&m.gui.screen() instanceof CycleFactionScreen,100);c.waitTicks(5);c.takeScreenshot("sanctuary-beta038-faction-founding");
c.runOnClient(m->{widgets(m.gui.screen()).filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow().setValue("Les Veilleurs");button(m.gui.screen(),"sanctuary.cycle.create").onPress(enter());});c.waitTicks(2);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.cycle.confirm").onPress(enter()));
c.waitFor(m->CycleClient.view.faction()!=null&&m.player.experienceLevel==0,100);c.waitTicks(5);
c.runOnClient(m->require(CycleClient.view.faction().capacity()==1&&CycleClient.view.faction().owner().equals(m.player.getUUID())&&CycleClient.view.prestige()==0,"Real founding packet spends ten levels, no prestige, one founder place"));c.takeScreenshot("sanctuary-beta038-faction-founded");
System.out.println("GRAVES FOOD038 client passed: real server food sync, three/ten icons, saturation tooltip, options, two-page chest, packet shift-click and portable grave.");
}finally{armed.set(false);}
}
private static void hover(ClientGameTestContext c,int index){c.runOnClient(m->{var screen=(AbstractContainerScreen<?>)m.gui.screen();var a=(AbstractContainerScreenAccessor)screen;var slot=screen.getMenu().getSlot(index);var w=m.getWindow();m.mouseHandler.onMove(w.handle(),(a.sanctuary$getLeftPos()+slot.x+8.0)*w.getScreenWidth()/w.getGuiScaledWidth(),(a.sanctuary$getTopPos()+slot.y+8.0)*w.getScreenHeight()/w.getGuiScaledHeight(),0,0);m.mouseHandler.handleAccumulatedMovement();});}
private static void click(ClientGameTestContext c,int index,boolean shift){hover(c,index);c.waitTick();if(shift)c.getInput().holdKey(o->o.keyShift);c.runOnClient(m->{var w=m.getWindow();m.mouseHandler.onButton(w.handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,shift?InputConstants.MOD_SHIFT:0),1);m.mouseHandler.onButton(w.handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,shift?InputConstants.MOD_SHIFT:0),0);});if(shift)c.getInput().releaseKey(o->o.keyShift);c.waitTicks(2);}
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 Button button(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);}
private static void require(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,73 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.mining.*;
import fr.koka.sanctuary.building.*;
import fr.koka.sanctuary.progression.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.*;
import net.minecraft.client.KeyMapping;
import net.minecraft.core.*;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
/** Real client gestures: a changed crosshair must never replace ongoing server work. */
final class GroupWork018Checks {
static void run(ClientGameTestContext c,TestServerContext server) {
var previous=c.computeOnClient(m -> InputConstants.getKey(key().saveString()));
try {
c.runOnClient(m -> {key().setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_R));KeyMapping.resetMapping();});
BlockPos base=server.computeOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();var at=p.blockPosition().above(8);
for(int x=-6;x<=6;x++)for(int z=-6;z<=8;z++)for(int y=-1;y<=6;y++)p.level().setBlockAndUpdate(at.offset(x,y,z),(y==-1 ? Blocks.BEDROCK : Blocks.AIR).defaultBlockState());
var record=ProgressEffects.get(p);while(record.mining()<2)record=record.buy("mining",1);while(record.building()<2)record=record.buy("building",1);
p.setAttached(ProgressionService.RECORD,record.json());ProgressEffects.apply(p,record);ProgressionService.send(p,"");
p.teleportTo(at.getX()+.5,at.getY(),at.getZ()+.5);p.getInventory().clearContent();p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.DIAMOND_PICKAXE));
for(int i=0;i<8;i++)p.level().setBlockAndUpdate(at.offset(i%4,1,3+i/4),Blocks.STONE.defaultBlockState());
p.level().setBlockAndUpdate(at.offset(-3,1,0),Blocks.STONE.defaultBlockState());p.inventoryMenu.broadcastFullState();return at;
});
var origin=base.offset(0,1,3);var other=base.offset(-3,1,0);
c.waitFor(m -> m.player.onGround() && Math.abs(m.player.getY()-base.getY())<.1 && m.player.getMainHandItem().is(Items.DIAMOND_PICKAXE) && ProgressionClient.progress.mining()==2 && m.level.getBlockState(origin).is(Blocks.STONE),600);
c.getInput().lookAt(origin);c.waitTicks(3);c.getInput().holdKey(InputConstants.KEY_R);c.waitFor(m -> MiningClient.previewSize()==8,200);
c.getInput().holdKey(o -> o.keyAttack);server.waitFor(s -> MiningManager.active(s.getPlayerList().getPlayers().getFirst()),200);
c.getInput().lookAt(other);c.runOnClient(m -> m.mouseHandler.onScroll(m.getWindow().handle(),0,-1));
c.waitTick();c.runOnClient(m -> require(MiningClient.previewSize()==8 && MiningClient.selectedRank()==2,"Looking at another one-block vein and scrolling keep the original eight-block selection"));
c.getInput().holdKey(o -> o.keyRight);c.waitTicks(2);c.getInput().releaseKey(o -> o.keyRight);
c.waitFor(m -> m.level.getBlockState(origin).isAir(),500);c.waitTicks(15);
server.runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();
for(int i=0;i<8;i++)require(p.level().getBlockState(origin.offset(i%4,0,i/4)).isAir(),"Every initially selected block is mined while looking away");
require(p.level().getBlockState(other).is(Blocks.STONE) && p.getMainHandItem().getDamageValue()==8,"Holding after completion cannot start the newly aimed vein");
});c.takeScreenshot("sanctuary-beta018-mining-locked-camera");
c.getInput().releaseKey(o -> o.keyAttack);c.waitTicks(3);c.getInput().lookAt(other);c.waitTicks(3);c.getInput().holdKey(o -> o.keyAttack);
c.waitFor(m -> m.level.getBlockState(other).isAir(),300);c.getInput().releaseKey(o -> o.keyAttack);c.getInput().releaseKey(InputConstants.KEY_R);c.waitTicks(3);
var wall=base.offset(0,2,3);
server.runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(base.getX()+.5,base.getY(),base.getZ()+.5);
for(int x=-2;x<=2;x++)for(int y=-2;y<=2;y++) {p.level().setBlockAndUpdate(wall.offset(x,y,-1),Blocks.AIR.defaultBlockState());p.level().setBlockAndUpdate(wall.offset(x,y,0),Blocks.STONE.defaultBlockState());}
p.level().setBlockAndUpdate(other,Blocks.STONE.defaultBlockState());p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.OAK_STAIRS,32));p.inventoryMenu.broadcastFullState();
});
c.waitFor(m -> m.player.getMainHandItem().is(Items.OAK_STAIRS) && m.level.getBlockState(wall).is(Blocks.STONE) && Math.abs(m.player.getX()-(base.getX()+.5))<.1,400);
c.getInput().lookAt(wall);c.waitTicks(3);c.getInput().holdKey(InputConstants.KEY_R);c.waitFor(m -> BuildingClient.previewSize()==25,200);
Direction initial=c.computeOnClient(m -> m.player.getDirection());c.getInput().holdKey(o -> o.keyUse);
c.waitFor(m -> m.level.getBlockState(wall.north()).is(Blocks.OAK_STAIRS),200);
c.getInput().lookAt(other);c.runOnClient(m -> m.mouseHandler.onScroll(m.getWindow().handle(),0,-1));
c.getInput().holdKey(o -> o.keyRight);c.waitTicks(2);c.getInput().releaseKey(o -> o.keyRight);
c.runOnClient(m -> require(BuildingClient.selectedRank()==2,"Wheel cannot resize the locked surface"));
c.waitFor(m -> m.player.getMainHandItem().getCount()==7,400);c.waitTicks(15);
server.runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();require(p.getMainHandItem().getCount()==7 && !BuildingManager.active(p),"A held group places one surface only, with exact native material consumption");
for(int x=-2;x<=2;x++)for(int y=-2;y<=2;y++) {var state=p.level().getBlockState(wall.offset(x,y,-1));require(state.is(Blocks.OAK_STAIRS) && state.getValue(StairBlock.FACING)==initial,"Grid and stair orientation remain frozen while camera and player move");}
});
c.getInput().releaseKey(o -> o.keyUse);c.getInput().releaseKey(InputConstants.KEY_R);c.getInput().lookAt(wall);c.waitTicks(3);c.takeScreenshot("sanctuary-beta018-building-locked-camera");
System.out.println("GroupWork018 native client passed: camera retarget, movement within reach, frozen ranks, exact initial vein and oriented surface, no automatic restart, release and new gesture");
} finally {
c.getInput().releaseKey(o -> o.keyAttack);c.getInput().releaseKey(o -> o.keyUse);c.getInput().releaseKey(o -> o.keyRight);c.getInput().releaseKey(InputConstants.KEY_R);
c.runOnClient(m -> {key().setKey(previous);KeyMapping.resetMapping();});
}
}
private static KeyMapping key() {return KeyMapping.get("sanctuary.mining.key");}
private static void require(boolean ok,String text) {if(!ok)throw new AssertionError(text);}
}
@@ -0,0 +1,114 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.boats.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.entity.CampfireBlockEntity;
import net.minecraft.client.renderer.blockentity.state.*;
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
import fr.koka.sanctuary.client.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
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.minecraft.client.CameraType;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.Vec3;
public final class Head050ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var onboarding=new AtomicBoolean(true);var pose=new AtomicBoolean(false);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(pose.get()&&m.player!=null){m.player.setYHeadRot(0);m.player.yHeadRotO=0;m.player.setYBodyRot(0);m.player.yBodyRotO=0;}});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(onboarding.get()&&m.gui.screen() instanceof HelloWorldScreen s){
Starter039TestSupport.selectFirst(s);
widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b,"sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());onboarding.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{
s.setName("Head050 "+UUID.randomUUID());s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setAllowCommands(true);
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);
for(String locale:new String[]{"en_us","fr_fr"})c.runOnClient(m->{
m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
for(var v:CrewBoats.variants()){
var boat=v.type().create(m.level,EntitySpawnReason.COMMAND);
require(m.getEntityRenderDispatcher().getRenderer(boat)!=null,"Native renderer for "+v.wood());
var mesh=TiledBoatModel.hull(v.columns(),v.rows(),v.raft()).bakeRoot();
require(mesh.getAllParts().size()==v.tiles()+3+(v.raft()?0:2*(v.columns()+v.rows())+4),"One floor per cell, only exterior rails and two paddles");
require(mesh.getAllParts().stream().allMatch(part->part.xScale==1&&part.yScale==1&&part.zScale==1),"No stretched model part");
require(mesh.hasChild("left_paddle")&&mesh.hasChild("right_paddle"),"Exactly one native pair of paddles");
require(TiledBoatModel.waterMask(v.columns(),v.rows()).bakeRoot().getAllParts().size()==v.tiles()+1,"Water mask covers every floor tile");
require(!new ItemStack(v.item()).getHoverName().getString().startsWith("item.sanctuary"),"Localized item name "+locale);
}
});
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(),"sanctuary time vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");
p.setPos(p.position().add(0,2,0));
});
c.runOnClient(m->m.options.setCameraType(CameraType.THIRD_PERSON_FRONT));c.getInput().pressKey(InputConstants.KEY_F1);c.getInput().lookAt(45,-25);pose.set(true);
for(var item:List.of(Items.OAK_SHELF,Items.LECTERN,Items.BED.red(),Items.STONE_BUTTON,Items.LEVER,Items.FLOWER_POT,Items.CHISELED_BOOKSHELF)){
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,new ItemStack(item));
if(item==Items.OAK_SHELF)for(var contents:List.of(Items.DIAMOND,Items.POPPY,Items.GOLD_INGOT)){
p.inventoryMenu.setCarried(new ItemStack(contents,3));require(HeadBlockService.use(p,p,true),"Insert real shelf item");
}
else if(item==Items.LECTERN){p.inventoryMenu.setCarried(new ItemStack(Items.WRITABLE_BOOK));require(HeadBlockService.use(p,p,true),"Insert real lectern book");}
else if(item==Items.FLOWER_POT){p.inventoryMenu.setCarried(new ItemStack(Items.POPPY));require(HeadBlockService.use(p,p,true),"Plant real flower");}
else if(item==Items.CHISELED_BOOKSHELF){p.inventoryMenu.setCarried(new ItemStack(Items.ENCHANTED_BOOK));require(HeadBlockService.use(p,p,true),"Insert enchanted book");}
else if(item==Items.STONE_BUTTON||item==Items.LEVER)HeadBlockService.use(p,p,false);
p.level().getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class,p.getBoundingBox().inflate(8)).forEach(Entity::discard);
});
c.waitTicks(12);
c.runOnClient(m->{
var h=head(m);var state=HeadCosmeticData.state(HeadCosmeticService.appearance(m.player));
if(item==Items.OAK_SHELF)require(h.blockEntity instanceof ShelfRenderState shelf&&Arrays.stream(shelf.items).filter(Objects::nonNull).count()==3,"All three shelf items reach actual native renderer");
if(item==Items.LECTERN)require(h.blockEntity instanceof LecternRenderState book&&book.hasBook,"Lectern renderer shows its book");
if(item==Items.BED.red())require(!h.block.isEmpty()&&!h.second.isEmpty()&&Math.abs(h.secondZ)+Math.abs(h.secondX)==1,"Complete bed has two adjoining native models");
if(item==Items.STONE_BUTTON||item==Items.LEVER)require(h.baseY>=1/16F&&state.getValue(net.minecraft.world.level.block.state.properties.BlockStateProperties.ATTACH_FACE)==net.minecraft.world.level.block.state.properties.AttachFace.FLOOR,"Switch lies on top");
if(item==Items.FLOWER_POT)require(state.is(net.minecraft.world.level.block.Blocks.POTTED_POPPY),"Potted flower reaches display");
});
c.takeScreenshot("sanctuary-beta050-head-"+net.minecraft.core.registries.BuiltInRegistries.ITEM.getKey(item).getPath());
}
pose.set(false);
server.runOnServer(s->AccessoryAccess.of(s.getPlayerList().getPlayers().getFirst()).clearContent());
c.runOnClient(m->m.options.setCameraType(CameraType.THIRD_PERSON_BACK));
for(var shape:List.of(new CrewBoats.Shape(1,2),new CrewBoats.Shape(2,1))){
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var old=p.getVehicle();p.stopRiding();if(old!=null)old.getSelfAndPassengers().toList().reversed().forEach(Entity::discard);
var boat=CrewBoats.variant("oak",shape.columns(),shape.rows()).type().create(s.overworld(),EntitySpawnReason.COMMAND);boat.setPos(p.position().add(0,8,0));s.overworld().addFreshEntity(boat);
var motor=EntityTypes.ALLAY.create(s.overworld(),EntitySpawnReason.COMMAND);motor.setPos(boat.position());motor.setNoAi(true);s.overworld().addFreshEntity(motor);motor.startRiding(boat);p.startRiding(boat,true,true);
});
c.waitFor(m->m.player.getVehicle() instanceof CrewBoat b&&b.columns()==shape.columns()&&b.rows()==shape.rows(),200);
c.getInput().holdKey(o->o.keyJump);c.getInput().holdKey(o->o.keyUp);c.waitTicks(20);
Vec3 from=c.computeOnClient(m->m.player.getVehicle().position());c.waitTicks(40);Vec3 to=c.computeOnClient(m->m.player.getVehicle().position());
require(to.y>from.y+2&&to.subtract(from).horizontalDistance()>5,"Each rectangular boat flies from native pilot inputs");
c.getInput().lookAt(25,20);c.waitTicks(5);c.takeScreenshot("sanctuary-beta050-boat-"+shape.columns()+"x"+shape.rows());
c.getInput().releaseKey(o->o.keyUp);c.getInput().releaseKey(o->o.keyJump);
}
System.out.println("HEAD050_CLIENT_PASS: 88 tiled models FR/EN, shelf contents, lectern book, complete bed, switches on crown, potted flower, enchanted books and two new rectangular flights");
}
}
private static HeadCosmeticState head(net.minecraft.client.Minecraft m){
var renderer=m.getEntityRenderDispatcher().getRenderer(m.player);
var state=renderer.createRenderState(m.player,1);
return ((HeadCosmeticState.Access)state).sanctuary$headCosmetic();
}
private static Stream<GuiEventListener> widgets(ContainerEventHandler root){return root.children().stream().flatMap(w->w instanceof ContainerEventHandler child?Stream.concat(Stream.of(w),widgets(child)):Stream.of(w));}
private static boolean key(Button button,String key){return button.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean yes,String message){if(!yes)throw new AssertionError(message);}
}
@@ -0,0 +1,51 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
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.inventory.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.world.item.*;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.Container;
import com.mojang.blaze3d.platform.InputConstants;
public class HeadBlocks043ClientChecks 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);widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getString().startsWith("Enter Sanctuary")).findFirst().orElseThrow().onPress(enter());onboarding.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Head blocks043 "+UUID.randomUUID());s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setAllowCommands(true);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();var r=ProgressRecord.INITIAL;for(int i=1;i<6;i++)r=r.buy("inventory",i);p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);ProgressionService.send(p,"");AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,new ItemStack(Items.CHEST));var head=HeadBlockService.session(p);HeadBlockService.scoped(head,()->{((Container)head.entity).setItem(0,new ItemStack(Items.DIAMOND,11));return null;});});
c.waitTicks(5);c.runOnClient(m->{m.gui.setScreen(new InventoryScreen(m.player));ClientPlayNetworking.send(new HeadCosmeticPayloads.Interact(-1));});
c.waitFor(m->m.gui.screen() instanceof ContainerScreen,150);c.waitTicks(5);c.takeScreenshot("sanctuary-beta043-head-chest-six-rows");
c.runOnClient(m->m.gameMode.handleContainerInput(m.player.containerMenu.containerId,0,0,ContainerInput.QUICK_MOVE,m.player));
c.waitFor(m->m.player.getInventory().countItem(Items.DIAMOND)==11,150);
c.waitTicks(10);
require(server.computeOnServer(s->s.getPlayerList().getPlayers().getFirst().getInventory().countItem(Items.DIAMOND)==11),"Native client shift-click reaches server exactly once");
server.runOnServer(s->AccessoryAccess.of(s.getPlayerList().getPlayers().getFirst()).removeItemNoUpdate(AccessoryContainer.HEAD));
c.waitFor(m->m.player.containerMenu==m.player.inventoryMenu,150);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,new ItemStack(Items.FURNACE));HeadBlockService.use(p,p,false);});
c.waitFor(m->m.gui.screen() instanceof FurnaceScreen,150);c.waitTicks(5);c.takeScreenshot("sanctuary-beta043-head-furnace");c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(3);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.setGameMode(net.minecraft.world.level.GameType.CREATIVE);AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,new ItemStack(Items.COMMAND_BLOCK));require(HeadBlockService.use(p,p,false),"Native operator editor requested");});
c.waitFor(m->m.gui.screen() instanceof CommandBlockEditScreen,150);c.waitTicks(4);c.takeScreenshot("sanctuary-beta043-head-operator-editor");c.getInput().pressKey(InputConstants.KEY_ESCAPE);
System.out.println("HEAD043_CLIENT_PASS: inventory gesture, six rows, real shift-click packet, removal closes menu, furnace and native operator editor");
}
}
private static Stream<GuiEventListener> widgets(ContainerEventHandler root){return root.children().stream().flatMap(w->w instanceof ContainerEventHandler child?Stream.concat(Stream.of(w),widgets(child)):Stream.of(w));}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean yes,String message){if(!yes)throw new AssertionError(message);}
}
@@ -0,0 +1,142 @@
package fr.koka.sanctuary.gametest;
import com.mojang.authlib.GameProfile;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
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.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.inventory.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.client.renderer.blockentity.state.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.*;
import net.minecraft.network.chat.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.network.protocol.PacketFlow;
import net.minecraft.network.protocol.game.*;
import net.minecraft.server.level.*;
import net.minecraft.server.network.*;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.*;
import net.minecraft.world.phys.Vec3;
public final class HeadCosmetic021ClientChecks 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 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());onboarding.set(false);
}});
int scale=c.computeOnClient(m->m.options.guiScale().get());
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sanctuary beta021 cosmetics "+UUID.randomUUID());s.setSeed("42");
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());
s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);}).create()) {
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
var server=world.getServer();
UUID remote=server.computeOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var at=p.blockPosition().above(6);
for(int x=-8;x<=8;x++)for(int z=-8;z<=8;z++)for(int y=-1;y<9;y++)p.level().setBlockAndUpdate(at.offset(x,y,z),(y==-1?Blocks.STONE:Blocks.AIR).defaultBlockState());
p.teleportTo(at.getX()+.5,at.getY(),at.getZ()+.5);p.getInventory().clearContent();
var profile=new GameProfile(UUID.randomUUID(),"Porteur");
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(p.position().add(0,0,2.5));other.setYRot(180);other.setYHeadRot(180);other.setNoGravity(true);
other.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(other,ProgressRecord.INITIAL);
other.connection.handleAcceptPlayerLoad(new ServerboundPlayerLoadedPacket());
p.connection.send(ClientboundPlayerInfoUpdatePacket.createPlayerInitializing(List.of(other)));p.level().addNewPlayer(other);
equip(other,new ItemStack(Items.OAK_SIGN));return other.getUUID();
});
c.waitFor(m->m.level.getPlayerByUUID(remote)!=null&&HeadCosmeticService.appearance(m.level.getPlayerByUUID(remote)).is(Items.OAK_SIGN),240);
var aim=c.computeOnClient(m->m.level.getPlayerByUUID(remote).getEyePosition().add(0,.45,0));face(c,aim);c.waitTicks(4);
click(c);c.waitFor(m->m.gui.screen() instanceof AbstractSignEditScreen,100);
c.runOnClient(m->{for(char ch:"Bonjour!".toCharArray())m.gui.screen().charTyped(new CharacterEvent(ch));});
c.takeScreenshot("sanctuary-beta021-sign-editor");c.getInput().pressKey(InputConstants.KEY_ESCAPE);
c.waitFor(m->{var stack=HeadCosmeticService.appearance(m.level.getPlayerByUUID(remote));return List.of(DataComponents.SIGN_TEXT_FRONT,DataComponents.SIGN_TEXT_BACK).stream().anyMatch(k->stack.getOrDefault(k,SignText.EMPTY).getMessages(false).getFirst().getString().equals("Bonjour!"));},200);
c.runOnClient(m->{var h=render(m,m.level.getPlayerByUUID(remote));require(h.blockEntity instanceof SignRenderState&&!h.block.isEmpty(),"Sign has native board and native text render state");
var sign=(SignRenderState)h.blockEntity;require(Stream.concat(sign.frontText.getMessages(false).stream(),sign.backText.getMessages(false).stream()).anyMatch(t->t.getString().equals("Bonjour!")),"Written text reaches native renderer");});
c.waitTicks(3);c.takeScreenshot("sanctuary-beta021-sign-written");
server.runOnServer(s->{var p=(ServerPlayer)s.getPlayerList().getPlayers().getFirst().level().getPlayerByUUID(remote);equip(p,new ItemStack(Items.OAK_HANGING_SIGN));});
c.waitFor(m->HeadCosmeticService.appearance(m.level.getPlayerByUUID(remote)).is(Items.OAK_HANGING_SIGN),100);c.waitTicks(5);click(c);
c.waitFor(m->m.gui.screen() instanceof HangingSignEditScreen,100);c.runOnClient(m->{for(char ch:"Suspendu".toCharArray())m.gui.screen().charTyped(new CharacterEvent(ch));});c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitTicks(6);
// Native item models and components are tested through actual server synchronization and render extraction.
List<Item> models=new ArrayList<>(List.of(Items.OAK_DOOR,Items.IRON_DOOR,Items.BED.red(),Items.STRAW_BED,
Items.MINECART,Items.CHEST_MINECART,Items.HOPPER_MINECART,Items.FURNACE_MINECART,Items.TNT_MINECART,Items.COMMAND_BLOCK_MINECART,
Items.COBWEB,Items.OAK_LEAVES,Items.GLASS,Items.STAINED_GLASS.purple(),Items.TINTED_GLASS,Items.COPPER_GOLEM_STATUE.waxed().oxidized(),Items.CAKE,Items.PISTON,Items.COW_SPAWN_EGG));
models.addAll(Items.BANNER.asList());
for(Item item:models) {
server.runOnServer(s->equip((ServerPlayer)s.getPlayerList().getPlayers().getFirst().level().getPlayerByUUID(remote),new ItemStack(item)));
c.waitFor(m->HeadCosmeticService.appearance(m.level.getPlayerByUUID(remote)).is(item),100);
c.runOnClient(m->{var h=render(m,m.level.getPlayerByUUID(remote));var state=HeadCosmeticData.state(new ItemStack(item));
if(state!=null&&(state.getBlock() instanceof DoorBlock||state.getBlock() instanceof BedBlock))require(!h.block.isEmpty()&&!h.second.isEmpty(),"Both halves render: "+item);
if(item instanceof MinecartItem)require(h.entity!=null&&h.item.isEmpty(),"Minecart uses entity model: "+item);
if(state!=null&&state.getBlock() instanceof AbstractBannerBlock)require(h.blockEntity instanceof BannerRenderState b&&b.baseColor==((BannerItem)item).getColor(),"Banner keeps native base color: "+item);
if(HeadCosmeticData.wrapsHead(state))require(h.wraps,"Block encloses head: "+item);
if(item instanceof SpawnEggItem)require(h.entity==null&&!h.item.isEmpty(),"Egg retains existing item rendering");
});c.waitTicks(2);
if(List.of(Items.OAK_DOOR,Items.BED.red(),Items.MINECART,Items.COBWEB,Items.OAK_LEAVES,Items.GLASS,Items.CAKE).contains(item))c.takeScreenshot("sanctuary-beta021-"+net.minecraft.core.registries.BuiltInRegistries.ITEM.getKey(item).getPath());
}
server.runOnServer(s->{var p=(ServerPlayer)s.getPlayerList().getPlayers().getFirst().level().getPlayerByUUID(remote);equip(p,net.minecraft.world.entity.raid.Raid.getOminousBannerInstance(p.registryAccess().lookupOrThrow(Registries.BANNER_PATTERN)));});
c.waitFor(m->HeadCosmeticService.appearance(m.level.getPlayerByUUID(remote)).getOrDefault(DataComponents.BANNER_PATTERNS,BannerPatternLayers.EMPTY).layers().size()>0,100);
c.runOnClient(m->{var p=m.level.getPlayerByUUID(remote);var b=(BannerRenderState)render(m,p).blockEntity;
require(b.patterns.equals(HeadCosmeticService.appearance(p).get(DataComponents.BANNER_PATTERNS))&&!b.patterns.layers().isEmpty(),"Ominous banner keeps every native pattern");});
c.waitTicks(3);c.takeScreenshot("sanctuary-beta021-ominous-banner");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();equip(p,new ItemStack(Items.CAKE));
p.getInventory().setItem(0,new ItemStack(Items.DYED_CANDLE.blue(),2));p.getInventory().setItem(1,new ItemStack(Items.FLINT_AND_STEEL));p.inventoryMenu.broadcastChanges();});
c.waitFor(m->HeadCosmeticService.appearance(m.player).is(Items.CAKE)&&m.player.getInventory().getItem(0).is(Items.DYED_CANDLE.blue()),100);
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.gui.setScreen(new InventoryScreen(m.player));
require(widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).noneMatch(b->b.getMessage().getString().equalsIgnoreCase("Cosmetic")),"No large cosmetic button");});
clickSlot(c,36,0);c.takeScreenshot("sanctuary-beta021-cursor-candle");c.waitFor(m->m.player.inventoryMenu.getCarried().is(Items.DYED_CANDLE.blue()),100);
clickSlot(c,66,1);c.waitFor(m->HeadCosmeticData.candle(HeadCosmeticService.appearance(m.player))==Items.DYED_CANDLE.blue()&&m.player.inventoryMenu.getCarried().getCount()==1,100);
clickSlot(c,36,0);clickSlot(c,37,0);c.waitFor(m->m.player.inventoryMenu.getCarried().is(Items.FLINT_AND_STEEL),100);
clickSlot(c,66,1);c.waitFor(m->HeadCosmeticData.lit(HeadCosmeticService.appearance(m.player))&&m.player.inventoryMenu.getCarried().getDamageValue()==1,100);
clickSlot(c,37,0);c.waitFor(m->m.player.inventoryMenu.getCarried().isEmpty(),100);
c.takeScreenshot("sanctuary-beta021-inventory-candle");
for(int slot:new int[]{5,6,7,8,45,64,65,66}){hover(c,slot);c.takeScreenshot("sanctuary-beta021-slot-"+slot);}
clickSlot(c,66,1);c.waitFor(m->!HeadCosmeticData.lit(HeadCosmeticService.appearance(m.player)),100);
c.waitTicks(5);clickSlot(c,66,1);c.waitFor(m->HeadCosmeticData.candle(HeadCosmeticService.appearance(m.player))==Items.AIR&&m.player.inventoryMenu.getCarried().is(Items.DYED_CANDLE.blue()),100);
clickSlot(c,36,0);clickSlot(c,66,0);c.waitFor(m->m.player.inventoryMenu.getCarried().is(Items.CAKE)&&HeadCosmeticService.appearance(m.player).isEmpty(),100);
clickSlot(c,38,0);c.waitFor(m->m.player.inventoryMenu.getCarried().isEmpty(),100);
server.runOnServer(s->equip(s.getPlayerList().getPlayers().getFirst(),new ItemStack(Items.OAK_SIGN)));c.waitFor(m->HeadCosmeticService.appearance(m.player).is(Items.OAK_SIGN),100);c.waitTicks(3);
clickSlot(c,66,1);c.waitFor(m->m.player.inventoryMenu.getCarried().is(Items.OAK_SIGN)&&m.gui.screen() instanceof InventoryScreen,100);
clickSlot(c,39,0);c.waitFor(m->m.player.inventoryMenu.getCarried().isEmpty(),100);
c.runOnClient(m->m.gui.setScreen(null));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var other=(ServerPlayer)p.level().getPlayerByUUID(remote);
equip(other,new ItemStack(Items.TNT));other.setHealth(6);other.hurtServer(other.level(),other.damageSources().generic(),1);
});
c.waitFor(m->{for(var entity:m.level.entitiesForRendering())if(entity instanceof net.minecraft.world.entity.item.PrimedTnt)return true;return false;},80);
c.waitTicks(3);c.takeScreenshot("sanctuary-beta021-tnt-primed");
// Move the observer out of the explosion; the armed avatar and native terrain remain in the laboratory.
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.teleportTo(p.getX()+12,p.getY(),p.getZ());});
c.waitTicks(90);
c.runOnClient(m->{for(var entity:m.level.entitiesForRendering())require(!(entity instanceof net.minecraft.world.entity.item.PrimedTnt),"Native TNT completed its fuse and disappeared after explosion");});
System.out.println("HeadCosmetic021 client passed: actual remote sign/hanging-sign clicks and text packets, 16 banner colors and ominous patterns, complete beds/doors, six minecarts, wrapping blocks, unchanged egg, inventory cake/candle actions and own-sign exclusion.");
} finally {onboarding.set(false);c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();});}
}
private static HeadCosmeticState render(Minecraft m,Player p){return ((HeadCosmeticState.Access)m.getEntityRenderDispatcher().extractEntity(p,.5F)).sanctuary$headCosmetic();}
private static void equip(ServerPlayer p,ItemStack item){AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,item);}
private static void face(ClientGameTestContext c,Vec3 at){c.runOnClient(m->{var d=at.subtract(m.player.getEyePosition());m.player.setYRot((float)Math.toDegrees(Math.atan2(-d.x,d.z)));m.player.setXRot((float)-Math.toDegrees(Math.atan2(d.y,d.horizontalDistance())));});}
private static void hover(ClientGameTestContext c,int index){c.runOnClient(m->{var screen=(AbstractContainerScreen<?>)m.gui.screen();var a=(fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor)screen;var slot=screen.getMenu().getSlot(index);var w=m.getWindow();double x=a.sanctuary$getLeftPos()+slot.x+8.0,y=a.sanctuary$getTopPos()+slot.y+8.0;double sx=(double)w.getScreenWidth()/w.getGuiScaledWidth(),sy=(double)w.getScreenHeight()/w.getGuiScaledHeight();m.mouseHandler.onMove(w.handle(),x*sx,y*sy,(x-m.mouseHandler.getScaledXPos(w))*sx,(y-m.mouseHandler.getScaledYPos(w))*sy);m.mouseHandler.handleAccumulatedMovement();});c.waitTicks(2);}
private static void clickSlot(ClientGameTestContext c,int slot,int button){hover(c,slot);c.runOnClient(m->m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(button==0?InputConstants.MOUSE_BUTTON_LEFT:InputConstants.MOUSE_BUTTON_RIGHT,0),1));c.waitTick();c.runOnClient(m->m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(button==0?InputConstants.MOUSE_BUTTON_LEFT:InputConstants.MOUSE_BUTTON_RIGHT,0),0));c.waitTicks(3);}
private static void click(ClientGameTestContext c){c.runOnClient(m->m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_RIGHT,0),1));c.waitTick();c.runOnClient(m->m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_RIGHT,0),0));c.waitTick();}
private static Stream<GuiEventListener> widgets(ContainerEventHandler c){return c.children().stream().flatMap(w->w instanceof ContainerEventHandler nested?Stream.concat(Stream.of(w),widgets(nested)):Stream.of(w));}
private static Button button(Screen screen,String key){return widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(w->w.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow(()->new AssertionError("Missing "+key));}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean value,String text){if(!value)throw new AssertionError(text);}
}
@@ -0,0 +1,151 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
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.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.inventory.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.inventory.*;
import net.minecraft.world.item.*;
/** Native screen/packet checks; exclusively creates an ignored development world. */
public final class Inventory010ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var armed=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m -> {
if(armed.get() && m.gui.screen() instanceof HelloWorldScreen screen) {
var press=new KeyEvent(InputConstants.KEY_RETURN,0,0);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(press);
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(press);armed.set(false);
}
});
int scale=c.computeOnClient(m -> m.options.guiScale().get());
try(var world=c.worldBuilder().adjustSettings(s -> {
s.setName("Sanctuary beta012 inventory "+UUID.randomUUID());
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null && e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());
s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
}).create()) {
world.getConnection().waitForChunksDownload(2400);
c.waitFor(m->m.player!=null && ProgressionClient.active() && m.gui.screen()==null,1200);
for(int rank=0;rank<=5;rank++) {
if(rank>0) world.getServer().runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();p.giveExperienceLevels(16);
require(ProgressionService.purchase(p,"inventory",ProgressEffects.get(p).revision()),"Server purchase succeeds");ProgressionService.send(p,"");
});
int expected=rank;c.waitFor(m->ProgressionClient.progress.inventory()==expected,200);
for(int gui:new int[]{1,0}) {
c.runOnClient(m -> {m.options.guiScale().set(gui);m.resizeGui();m.gui.setScreen(new InventoryScreen(m.player));bounds((AbstractContainerScreen<?>)m.gui.screen());});
c.waitTick();c.takeScreenshot("sanctuary-beta012-inventory-"+(rank+1)+"-gui-"+gui);
c.runOnClient(m->m.player.closeContainer());
}
}
for(var type:BuiltInRegistries.MENU) {
boolean supported=world.getServer().computeOnServer(server -> {
var p=server.getPlayerList().getPlayers().getFirst();
var ext=(SanctuaryExtendedInventoryMenu)type.create(99,p.getInventory());ext.sanctuary$ensureExtendedInventory();
return ext.sanctuary$hasExtendedInventory();
});
if(!supported)continue;
c.runOnClient(m -> Inventory012RenderProbe.reset());
String name=BuiltInRegistries.MENU.getKey(type).getPath();
world.getServer().runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();p.openMenu(new SimpleMenuProvider((id,inv,owner)->type.create(id,inv),Component.literal(name)));
if(p.containerMenu instanceof ChestMenu chest) {
for(int i=0;i<chest.getRowCount()*9;i++) chest.getSlot(i).setByPlayer(new ItemStack(i%2==0 ? Items.WHEAT : Items.COAL,i%5+1));
chest.broadcastChanges();
}
});
c.waitFor(m->m.gui.screen() instanceof AbstractContainerScreen<?> screen && screen.getMenu().getType()==type,200);
c.runOnClient(m -> {
var screen=(AbstractContainerScreen<?>)m.gui.screen();bounds(screen);
var ext=(SanctuaryExtendedInventoryMenu)screen.getMenu();
if(ext.sanctuary$hasExtendedInventory()) {
var canvas=(fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor)screen;
screen.mouseScrolled(canvas.sanctuary$getLeftPos()+88,screen.height-30,0,10);
bounds(screen);
}
});
c.waitTick();c.takeScreenshot("sanctuary-beta012-"+name+"-minimum");
c.runOnClient(m -> {
var canvas=(fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor)m.gui.screen();
require(Inventory012RenderProbe.captured && Inventory012RenderProbe.x==canvas.sanctuary$getLeftPos()
&& Inventory012RenderProbe.y==canvas.sanctuary$getTopPos(),"Native background origin matches actual slot origin: "+name);
});
c.runOnClient(m->m.player.closeContainer());c.waitForScreen(null);
}
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().setItem(60,new ItemStack(Items.DIAMOND,23));p.inventoryMenu.broadcastChanges();});
c.waitFor(m->m.player.getInventory().getItem(60).getCount()==23,200);
c.runOnClient(m->{m.gui.setScreen(new InventoryScreen(m.player));bounds((AbstractContainerScreen<?>)m.gui.screen());});
c.takeScreenshot("sanctuary-beta012-last-row-synced");
c.getInput().pressKey(InputConstants.KEY_TAB);
c.waitFor(m->m.player.getInventory().getItem(60).isEmpty() && m.player.getInventory().getItem(51).getCount()==23,200);
c.runOnClient(m -> {
var screen=m.gui.screen();
var backwards=new KeyEvent(InputConstants.KEY_TAB,0,1);
net.fabricmc.fabric.api.client.screen.v1.ScreenKeyboardEvents.allowKeyPress(screen).invoker().allowKeyPress(screen,backwards);
net.fabricmc.fabric.api.client.screen.v1.ScreenKeyboardEvents.afterKeyRelease(screen).invoker().afterKeyRelease(screen,backwards);
});
c.waitFor(m->m.player.getInventory().getItem(60).getCount()==23,200);
c.runOnClient(m -> {
var key=net.minecraft.client.KeyMapping.get("screen.sanctuary.inventory.rotate_key");
key.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_E));net.minecraft.client.KeyMapping.resetMapping();
});
c.getInput().pressKey(InputConstants.KEY_E);
c.waitFor(m->m.gui.screen() instanceof InventoryScreen && m.player.getInventory().getItem(51).getCount()==23,200);
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitForScreen(null);
c.runOnClient(m -> {
var key=net.minecraft.client.KeyMapping.get("screen.sanctuary.inventory.rotate_key");key.setKey(key.getDefaultKey());net.minecraft.client.KeyMapping.resetMapping();
});
world.getServer().runOnServer(server -> {var p=server.getPlayerList().getPlayers().getFirst();p.setExperienceLevels(0);ProgressionService.send(p,"");});
c.runOnClient(m -> ProgressionClient.open(null));c.waitTick();
c.runOnClient(m -> require(buttons(m.gui.screen())
.noneMatch(b -> b.visible && b.getMessage().getString().equals("+")),"No unaffordable plus button"));
c.takeScreenshot("sanctuary-beta012-progression-no-xp");
world.getServer().runOnServer(server -> {
var p=server.getPlayerList().getPlayers().getFirst();server.getPlayerList().op(p.nameAndId());ProgressionService.send(p,"");
});
c.waitFor(m -> ProgressionClient.operator,200);
world.getServer().runOnServer(server -> {var p=server.getPlayerList().getPlayers().getFirst();p.giveExperienceLevels(30);ProgressionService.send(p,"");});
c.waitFor(m -> m.player.experienceLevel==30,200);c.waitTick();c.takeScreenshot("sanctuary-beta012-progression-ready");
c.runOnClient(m -> buttons(m.gui.screen())
.filter(b -> b.visible && b.active && b.getMessage().getString().equals("+")).findFirst().orElseThrow().onPress(new KeyEvent(InputConstants.KEY_RETURN,0,0)));
c.waitFor(m -> ProgressionClient.progress.vitality()==1 && m.player.experienceLevel==29,200);
c.takeScreenshot("sanctuary-beta012-progression-operator");
c.runOnClient(m -> buttons(m.gui.screen())
.filter(b -> b.visible && b.active && b.getMessage().getString().equals("")).findFirst().orElseThrow().onPress(new KeyEvent(InputConstants.KEY_RETURN,0,0)));
c.waitFor(m -> ProgressionClient.progress.vitality()==0 && m.player.experienceLevel==30,200);
c.runOnClient(m -> require(ProgressionClient.progress.history().getLast().target().equals("remove_vitality"),"Native UI refund is recorded by the server"));
c.runOnClient(m->m.player.closeContainer());
} finally {armed.set(false);c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();});}
}
private static java.util.stream.Stream<Button> buttons(net.minecraft.client.gui.components.events.GuiEventListener widget) {
if(widget instanceof Button button)return java.util.stream.Stream.of(button);
if(widget instanceof net.minecraft.client.gui.components.events.ContainerEventHandler parent)
return parent.children().stream().flatMap(Inventory010ClientChecks::buttons);
return java.util.stream.Stream.empty();
}
private static void bounds(AbstractContainerScreen<?> screen) {
var canvas=(fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor)screen;
for(var slot:screen.getMenu().slots) if(slot.isActive() && slot.x>-1000 && slot.y>-1000) {
int x=canvas.sanctuary$getLeftPos()+slot.x,y=canvas.sanctuary$getTopPos()+slot.y;
require(x>=0 && y>=0 && x+16<=screen.width && y+16<=screen.height,"Visible slot stays on screen: "+screen.getClass().getSimpleName()+" #"+slot.index+" at "+x+","+y);
}
}
private static void require(boolean condition,String message){if(!condition)throw new AssertionError(message);}
}
@@ -0,0 +1,89 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.ProgressionClient;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.gametest.mixin.Inventory013MerchantAccess;
import fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.inventory.*;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.inventory.MenuType;
import net.minecraft.world.inventory.MerchantMenu;
import net.minecraft.world.item.*;
import net.minecraft.world.item.trading.*;
/** Native wheel dispatch distinguishes inventory rows, selected slots and merchant offers. */
final class Inventory013WheelChecks {
static void run(ClientGameTestContext c,TestServerContext server) {
server.runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();p.setExperienceLevels(100);
while(ProgressEffects.get(p).inventory()<5)require(ProgressionService.purchase(p,"inventory",ProgressEffects.get(p).revision()),"Unlock all wheel rows");
p.getInventory().clearContent();
int[] slots={0,9,18,27,43,52};Item[] items={Items.STONE,Items.DIRT,Items.COAL,Items.GOLD_INGOT,Items.IRON_INGOT,Items.DIAMOND};
for(int i=0;i<slots.length;i++)p.getInventory().setItem(slots[i],new ItemStack(items[i],i+1));
p.getInventory().setSelectedSlot(0);p.inventoryMenu.broadcastChanges();ProgressionService.send(p,"");
});
c.waitFor(m -> ProgressionClient.progress.inventory()==5 && m.player.getInventory().getItem(52).is(Items.DIAMOND),200);
c.runOnClient(m -> {m.gui.setScreen(new InventoryScreen(m.player));overSlot(m,0,-1);});
c.waitFor(m -> m.player.getInventory().getSelectedSlot()==1,200);
// Selection is predicted locally; wait for the native packet before checking the server.
c.waitTicks(3);
require(server.computeOnServer(s -> s.getPlayerList().getPlayers().getFirst().getInventory().getSelectedSlot()==1),"Hotbar wheel selection reaches the server");
c.runOnClient(m -> {require(m.player.getInventory().getItem(0).is(Items.STONE),"Hotbar wheel never rotates rows");overSlot(m,9,-1);});
c.waitFor(m -> m.player.getInventory().getItem(0).is(Items.DIRT),200);c.waitTick();
c.runOnClient(m -> overSlot(m,9,1));
c.waitFor(m -> m.player.getInventory().getItem(0).is(Items.STONE),200);c.waitTick();
c.runOnClient(m -> overSlot(m,9,-2));
c.waitFor(m -> m.player.getInventory().getItem(0).is(Items.COAL),200);c.waitTick();
c.runOnClient(m -> overSlot(m,9,2));
c.waitFor(m -> m.player.getInventory().getItem(0).is(Items.STONE),200);c.waitTick();
c.getInput().pressKey(InputConstants.KEY_TAB);
c.waitFor(m -> m.player.getInventory().getItem(0).is(Items.DIAMOND),200);c.waitTick();
c.runOnClient(m -> {
var screen=m.gui.screen();var reverse=new KeyEvent(InputConstants.KEY_TAB,0,1);
net.fabricmc.fabric.api.client.screen.v1.ScreenKeyboardEvents.allowKeyPress(screen).invoker().allowKeyPress(screen,reverse);
net.fabricmc.fabric.api.client.screen.v1.ScreenKeyboardEvents.afterKeyRelease(screen).invoker().afterKeyRelease(screen,reverse);
});
c.waitFor(m -> m.player.getInventory().getItem(0).is(Items.STONE),200);c.waitTick();
c.runOnClient(m -> m.player.closeContainer());
// Let the close packet reach the server before opening the next menu.
c.waitTicks(3);
server.runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();
p.openMenu(new SimpleMenuProvider((id,inv,owner)->MenuType.MERCHANT.create(id,inv),Component.literal("Villager")));
var offers=new MerchantOffers();
for(int i=0;i<20;i++)offers.add(new MerchantOffer(new ItemCost(Items.EMERALD,i+1),new ItemStack(Items.BREAD,i+1),12,1,0.05F));
((MerchantMenu)p.containerMenu).setOffers(offers);p.sendMerchantOffers(p.containerMenu.containerId,offers,1,0,false,false);
require(p.containerMenu.stillValid(p),"Merchant fixture stays valid for its player");
});
c.waitFor(m -> m.gui.screen() instanceof MerchantScreen screen && screen.getMenu().getOffers().size()==20,200);
c.runOnClient(m -> {var canvas=(AbstractContainerScreenAccessor)m.gui.screen();scroll(m,canvas.sanctuary$getLeftPos()+20,canvas.sanctuary$getTopPos()+30,-1);});
int offset=c.computeOnClient(m -> ((Inventory013MerchantAccess)m.gui.screen()).sanctuary$offerOffset());
require(offset>0,"Wheel over merchant offers scrolls their native list");
c.runOnClient(m -> {require(m.player.getInventory().getItem(0).is(Items.STONE),"Offers do not change hotbar rows");overSlot(m,9,-1);});
c.waitFor(m -> m.player.getInventory().getItem(0).is(Items.DIRT),200);
c.runOnClient(m -> {
require(((Inventory013MerchantAccess)m.gui.screen()).sanctuary$offerOffset()==offset,"Wheel over player storage never scrolls offers");
overSlot(m,0,-1);
});
c.waitFor(m -> m.player.getInventory().getSelectedSlot()==2,200);
c.takeScreenshot("sanctuary-beta013-merchant-wheel-zones");
c.runOnClient(m -> {require(((Inventory013MerchantAccess)m.gui.screen()).sanctuary$offerOffset()==offset,"Hotbar selection never scrolls offers");m.player.closeContainer();});
}
private static void overSlot(Minecraft m,int index,double amount) {
var screen=(AbstractContainerScreen<?>)m.gui.screen();var canvas=(AbstractContainerScreenAccessor)screen;
var slot=screen.getMenu().slots.stream().filter(s -> s.container==m.player.getInventory() && s.getContainerSlot()==index).findFirst().orElseThrow();
scroll(m,canvas.sanctuary$getLeftPos()+slot.x+8,canvas.sanctuary$getTopPos()+slot.y+8,amount);
}
private static void scroll(Minecraft m,double x,double y,double amount) {
var window=m.getWindow();
m.mouseHandler.onMove(window.handle(),x*window.getScreenWidth()/window.getGuiScaledWidth(),y*window.getScreenHeight()/window.getGuiScaledHeight(),0,0);
m.mouseHandler.handleAccumulatedMovement();m.mouseHandler.onScroll(window.handle(),0,amount);
}
private static void require(boolean ok,String message) {if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,224 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.cycle.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor;
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.KeyMapping;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.inventory.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.inventory.MenuType;
import net.minecraft.world.item.*;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
/** Actual UI purchase, middle-click packets and keyboard remapping in a fresh local test world. */
public final class Inventory014ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var armed=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m -> {
if(armed.get() && m.gui.screen() instanceof HelloWorldScreen 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());armed.set(false);
}
});
String language=c.computeOnClient(m -> m.getLanguageManager().getSelected());
int gui=c.computeOnClient(m -> m.options.guiScale().get());
var previousKey=c.computeOnClient(m -> InputConstants.getKey(KeyMapping.get("screen.sanctuary.inventory.sort_key").saveString()));
try(var world=c.worldBuilder().adjustSettings(s -> {
s.setName("Sanctuary beta014 sorting "+UUID.randomUUID());
s.setWorldType(s.getNormalPresetList().stream().filter(e -> e.preset()!=null && e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());
s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
}).create()) {
world.getConnection().waitForChunksDownload(2400);
c.waitFor(m -> m.player!=null && ProgressionClient.active() && m.gui.screen()==null,1200);
var server=world.getServer();
c.runOnClient(m -> {KeyMapping.get("screen.sanctuary.inventory.sort_key").setKey(InputConstants.Type.MOUSE.getOrCreate(2));KeyMapping.resetMapping();});
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();p.setExperienceLevels(3);ProgressionService.send(p,"");});
c.waitFor(m -> m.player.experienceLevel==3,200);
c.runOnClient(m -> {m.options.guiScale().set(1);m.resizeGui();ProgressionClient.open(null);});c.waitTick();
c.runOnClient(m -> require(!sortingButton(m.gui.screen()).active,"Sorting aptitude cannot be bought with only three levels"));
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();p.setExperienceLevels(4);ProgressionService.send(p,"");});
c.waitFor(m -> m.player.experienceLevel==4,200);c.waitTick();
for(String locale:new String[]{"en_us","fr_fr"}) {
c.runOnClient(m -> {
m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());ProgressionClient.open(null);
});c.waitTick();
c.runOnClient(m -> {
require(sortingButton(m.gui.screen()).active,"Four levels enable the new aptitude in both languages");
for(String aptitude:ProgressRecord.APTITUDES)require(!Component.translatable("sanctuary.progression."+aptitude+"_description").getString().contains("Game+"),"Persistence is a global rule, absent from aptitude descriptions");
});
c.takeScreenshot("sanctuary-beta014-aptitude-"+locale);
}
c.runOnClient(m -> sortingButton(m.gui.screen()).onPress(enter()));
c.waitFor(m -> ProgressionClient.progress.sorting() && m.player.experienceLevel==0,200);
server.runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();var progress=ProgressEffects.get(p);
for(int i=0;i<5;i++)progress=progress.buy("inventory",1);
p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);
p.getInventory().clearContent();p.getInventory().setItem(0,new ItemStack(Items.STONE,5));p.getInventory().setItem(8,new ItemStack(Items.APPLE,2));
p.getInventory().setItem(9,new ItemStack(Items.STONE,11));p.getInventory().setItem(43,new ItemStack(Items.COAL,40));p.getInventory().setItem(60,new ItemStack(Items.COAL,30));
p.inventoryMenu.broadcastChanges();ProgressionService.send(p,"");
});
c.waitFor(m -> ProgressionClient.progress.inventory()==5 && m.player.getInventory().getItem(60).getCount()==30,200);
c.runOnClient(m -> {m.gui.setScreen(new InventoryScreen(m.player));click(m,9,2);});
c.waitFor(m -> m.player.getInventory().getItem(9).is(Items.COAL) && m.player.getInventory().getItem(9).getCount()==64,200);c.waitTicks(3);
c.runOnClient(m -> {
require(m.player.getInventory().getItem(0).is(Items.STONE) && m.player.getInventory().getItem(8).is(Items.APPLE),"Storage sort preserves arranged hotbar");
click(m,0,2);
});
c.waitFor(m -> m.player.getInventory().getItem(0).is(Items.APPLE),200);c.waitTicks(3);
c.takeScreenshot("sanctuary-beta014-middle-click-sorted");
c.runOnClient(m -> m.player.closeContainer());c.waitTicks(3);
server.runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().setItem(9,new ItemStack(Items.STONE,8));p.getInventory().setItem(60,new ItemStack(Items.APPLE,6));
p.openMenu(new SimpleMenuProvider((id,inv,owner) -> MenuType.GENERIC_9x3.create(id,inv),Component.literal("Sorting chest")));
p.containerMenu.getSlot(0).set(new ItemStack(Items.GOLD_INGOT,31));p.containerMenu.broadcastChanges();
});
c.waitFor(m -> m.gui.screen() instanceof ContainerScreen && m.player.getInventory().getItem(60).is(Items.APPLE),200);
c.runOnClient(m -> {
var key=KeyMapping.get("screen.sanctuary.inventory.sort_key");key.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_E));KeyMapping.resetMapping();
point(m,9);
});
c.getInput().pressKey(InputConstants.KEY_E);
c.waitFor(m -> m.player.getInventory().getItem(9).is(Items.APPLE),200);c.waitTicks(3);
c.runOnClient(m -> {
require(m.gui.screen() instanceof ContainerScreen,"Remapped E sorts instead of closing the inventory");
require(m.player.containerMenu.getSlot(0).getItem().is(Items.GOLD_INGOT) && m.player.containerMenu.getSlot(0).getItem().getCount()==31,"Opened chest contents are untouched");
require(m.player.getInventory().getItem(0).is(Items.APPLE) && m.player.getInventory().getItem(0).getCount()==2,"Separate hotbar keeps its own apple count");
});
c.takeScreenshot("sanctuary-beta014-remapped-key-chest");
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitFor(m -> m.gui.screen()==null,200);
require(server.computeOnServer(s -> s.getPlayerList().getPlayers().getFirst().getInventory().getItem(9).is(Items.APPLE)),"The final sorted inventory is authoritative on the server");
mobileHotbar(c,server);
factionProfile(c,server);
} finally {
armed.set(false);c.runOnClient(m -> {
KeyMapping.get("screen.sanctuary.inventory.sort_key").setKey(previousKey);KeyMapping.resetMapping();
m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(gui);m.resizeGui();
});
}
}
private static void mobileHotbar(ClientGameTestContext c,TestServerContext server) {
c.waitTicks(3);
server.runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();
for(int i=0;i<54;i++) {
var stack=new ItemStack(Items.STONE,1+i%4);
stack.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME,Component.literal("fixed-"+i));
p.getInventory().setItem(HotbarRows.address(i),stack);
}
p.getInventory().setSelectedSlot(0);p.inventoryMenu.broadcastFullState();ProgressionService.send(p,"");
});
c.waitFor(m -> m.player.getInventory().getItem(60).getHoverName().getString().equals("fixed-53"),200);
c.runOnClient(m -> m.gui.setScreen(new InventoryScreen(m.player)));c.waitTick();
var fixed=c.computeOnClient(Inventory014ClientChecks::grid);
int baseY=c.computeOnClient(m -> nativeSlot(m,0).y);
c.takeScreenshot("sanctuary-beta014-hotbar-base");
for(int step=1;step<=6;step++) {
int expected=Math.floorMod(-step,6);
c.getInput().pressKey(InputConstants.KEY_TAB);
c.waitFor(m -> HotbarRows.selected(m.player)==expected,200);c.waitTick();
c.runOnClient(m -> {
require(fixed.equals(grid(m)),"Tab moves only the frame: all 54 item coordinates remain exact at row "+expected);
require(m.player.getInventory().getItem(0).getHoverName().getString().equals("fixed-"+expected*9),"Selected row supplies the native held item");
if(expected==5)require(nativeSlot(m,0).y==baseY-18,"First Tab moves the frame one row up");
});
if(expected==5 || expected==3)c.takeScreenshot("sanctuary-beta014-hotbar-row-"+expected);
}
c.runOnClient(m -> {point(m,9);m.mouseHandler.onScroll(m.getWindow().handle(),0,1);});
c.waitFor(m -> HotbarRows.selected(m.player)==5,200);c.waitTick();
c.runOnClient(m -> {require(fixed.equals(grid(m)),"Wheel moves the frame without moving items");point(m,0);m.mouseHandler.onScroll(m.getWindow().handle(),0,-1);});
c.waitFor(m -> m.player.getInventory().getSelectedSlot()==1,200);
c.runOnClient(m -> {require(HotbarRows.selected(m.player)==5,"Wheel over active row selects a column only");m.player.closeContainer();});c.waitTicks(3);
server.runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();
p.openMenu(new SimpleMenuProvider((id,inv,owner) -> MenuType.GENERIC_9x3.create(id,inv),Component.literal("Mobile hotbar chest")));
p.containerMenu.getSlot(0).set(new ItemStack(Items.GOLD_INGOT,31));p.containerMenu.broadcastFullState();
});
c.waitFor(m -> m.gui.screen() instanceof ContainerScreen,200);c.waitTick();
var chestFixed=c.computeOnClient(Inventory014ClientChecks::grid);
c.getInput().pressKey(InputConstants.KEY_TAB);c.waitFor(m -> HotbarRows.selected(m.player)==4,200);c.waitTick();
c.runOnClient(m -> {
require(chestFixed.equals(grid(m)),"Chest inventory has the same fixed-grid selection");
require(m.player.containerMenu.getSlot(0).getItem().getCount()==31,"Selecting a row never touches chest slots");
m.options.guiScale().set(2);m.resizeGui();
});c.waitTick();
c.runOnClient(m -> require(nativeSlot(m,0).y>=0,"Active hotbar stays reachable in a smaller GUI"));
c.takeScreenshot("sanctuary-beta014-mobile-hotbar-chest");
c.runOnClient(m -> {m.player.closeContainer();m.options.guiScale().set(1);m.resizeGui();});c.waitTicks(3);
require(server.computeOnServer(s -> HotbarRows.selected(s.getPlayerList().getPlayers().getFirst())==4),"Closing the menu keeps the active row on the server");
c.runOnClient(m -> m.gui.setScreen(new InventoryScreen(m.player)));c.waitTick();
c.runOnClient(m -> {require(fixed.equals(grid(m)),"Reopening preserves every item position and the selected row");m.player.closeContainer();});c.waitTicks(3);
}
private static void factionProfile(ClientGameTestContext c,TestServerContext server) {
server.runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();var progress=ProgressEffects.get(p);
for(String skill:ProgressRecord.SKILLS)while(progress.rank(skill)<ProgressRecord.cap(skill))progress=progress.buy(skill,1);
p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);
require(cycle(p,"ngp","").equals("done"),"Complete skills allow New Game+");
require(HotbarRows.selected(p)==0,"New Game+ resets the frame to the base");
require(cycle(p,"create","Les Arpenteurs").equals("done"),"Earned charge creates faction");CycleService.send(p,"");
});
c.waitFor(m -> ProgressionClient.progress.cycle()==1 && CycleClient.view!=null && CycleClient.view.faction()!=null,200);
c.waitFor(m -> m.player.getDisplayName().getString().startsWith("Les Arpenteurs · "),200);
c.runOnClient(m -> {ProgressionClient.open(null,"identity");});c.waitTick();
String name=c.computeOnClient(m -> ProgressionClient.identity.name()+" I");
c.runOnClient(m -> require(shows(m.gui.screen(),"Les Arpenteurs · "+name),"Inhabitant profile uses faction prefix and Roman prestige"));
c.takeScreenshot("sanctuary-beta014-faction-inhabitant");
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();require(cycle(p,"dissolve","").equals("done"),"Faction dissolves");CycleService.send(p,"");});
c.waitFor(m -> CycleClient.view.faction()==null && shows(m.gui.screen(),name) && !shows(m.gui.screen(),"Les Arpenteurs · "+name),200);
c.runOnClient(m -> require(m.player.getDisplayName().getString().endsWith(" I") && !m.player.getDisplayName().getString().contains("Les Arpenteurs"),"Live display loses faction but keeps prestige"));
c.takeScreenshot("sanctuary-beta014-inhabitant-after-dissolution");
}
private static String cycle(net.minecraft.server.level.ServerPlayer p,String action,String value) {
return CycleService.perform(p,new CyclePayloads.Action(action,"",value,ProgressionService.session(p.level().getServer()).cycles.revision(),ProgressEffects.get(p).revision()));
}
private static boolean shows(Screen screen,String text) {return widgets(screen).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w -> w.getMessage().getString().equals(text));}
private static net.minecraft.world.inventory.Slot nativeSlot(Minecraft m,int index) {
return ((AbstractContainerScreen<?>)m.gui.screen()).getMenu().slots.stream().filter(s -> s.container==m.player.getInventory() && s.getContainerSlot()==index).findFirst().orElseThrow();
}
private static java.util.Map<String,String> grid(Minecraft m) {
var result=new java.util.TreeMap<String,String>();
for(var slot:((AbstractContainerScreen<?>)m.gui.screen()).getMenu().slots) {
if(slot.container==m.player.getInventory() && slot.getContainerSlot()<36 || slot.container instanceof SanctuaryOverflowContainer) {
require(slot.x>=0 && slot.y>=0,"All six rows must be visible for the coordinate assertion");
var stack=slot.getItem();result.put(slot.x+","+slot.y,stack.getHoverName().getString()+"/"+stack.getCount());
}
}
require(result.size()==54,"Exactly 54 distinct physical cells");return result;
}
private static Button sortingButton(Screen screen) {
var name=widgets(screen).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w -> key(w,"sanctuary.progression.sorting")).findFirst().orElseThrow();
return widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(b -> b.getX()==name.getX() && b.getY()>name.getY())
.min(java.util.Comparator.comparingInt(Button::getY)).orElseThrow();
}
private static void click(Minecraft m,int index,int button) {point(m,index);m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(button,0),1);m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(button,0),0);}
private static void point(Minecraft m,int index) {
var screen=(AbstractContainerScreen<?>)m.gui.screen();var canvas=(AbstractContainerScreenAccessor)screen;
var slot=screen.getMenu().slots.stream().filter(s -> s.container==m.player.getInventory() && s.getContainerSlot()==index).findFirst().orElseThrow();
var window=m.getWindow();double x=canvas.sanctuary$getLeftPos()+slot.x+8,y=canvas.sanctuary$getTopPos()+slot.y+8;
m.mouseHandler.onMove(window.handle(),x*window.getScreenWidth()/window.getGuiScaledWidth(),y*window.getScreenHeight()/window.getGuiScaledHeight(),0,0);m.mouseHandler.handleAccumulatedMovement();
}
private static Stream<GuiEventListener> widgets(GuiEventListener widget) {return Stream.concat(Stream.of(widget),widget instanceof ContainerEventHandler parent ? parent.children().stream().flatMap(Inventory014ClientChecks::widgets) : Stream.empty());}
private static boolean key(AbstractWidget widget,String key) {return widget.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key);}
private static Button button(Screen screen,String key) {return widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(b -> key(b,key)).findFirst().orElseThrow();}
private static KeyEvent enter() {return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean ok,String message) {if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,102 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.ProgressionClient;
import fr.koka.sanctuary.cosmetics.AccessoryService;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor;
import fr.koka.sanctuary.progression.ProgressionService;
import java.util.List;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.minecraft.client.*;
import net.minecraft.client.gui.screens.inventory.*;
import net.minecraft.client.input.MouseButtonInfo;
import net.minecraft.network.chat.Component;
import net.minecraft.world.*;
import net.minecraft.world.inventory.*;
import net.minecraft.world.item.*;
/** Uses the real mouse/keyboard pipeline and observes authoritative server inventories. */
final class InventoryControls018Checks {
static void run(ClientGameTestContext c,TestServerContext server) {
var previous=c.computeOnClient(m -> InputConstants.getKey(rotationKey().saveString()));
try {
c.runOnClient(m -> {rotationKey().setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_TAB));KeyMapping.resetMapping();});
server.runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();HotbarRows.select(p,0);p.getInventory().clearContent();p.getInventory().setSelectedSlot(2);
var types=List.of(Items.STONE,Items.DIRT,Items.COAL,Items.COPPER_INGOT,Items.IRON_INGOT,Items.GOLD_INGOT);
for(int row=0;row<6;row++)for(int col=0;col<9;col++)p.getInventory().setItem(HotbarRows.address(row*9+col),new ItemStack(types.get(row),row+1));
p.inventoryMenu.broadcastFullState();HotbarService.send(p);ProgressionService.send(p,"");
});
c.waitFor(m -> m.gui.screen()==null && HotbarRows.selected(m.player)==0 && m.player.getInventory().getItem(0).is(Items.STONE),200);
c.runOnClient(m -> {m.player.getInventory().setSelectedSlot(2);m.player.connection.send(new net.minecraft.network.protocol.game.ServerboundSetCarriedItemPacket(2));});
c.getInput().holdKey(o -> rotationKey());
c.waitFor(m -> HotbarRows.selected(m.player)==5 && m.player.getInventory().getSelectedItem().is(Items.GOLD_INGOT),200);
c.waitTicks(8);c.runOnClient(m -> require(HotbarRows.selected(m.player)==5 && !m.options.keyPlayerList.isDown(),"Holding Tab neither repeats the row nor opens its overlapping player list"));
c.getInput().releaseKey(o -> rotationKey());c.waitTicks(2);
c.getInput().holdKey(o -> o.keyShift);c.getInput().pressKey(InputConstants.KEY_TAB);
c.waitFor(m -> HotbarRows.selected(m.player)==0 && m.player.getInventory().getSelectedItem().is(Items.STONE),200);c.getInput().releaseKey(o -> o.keyShift);c.waitTicks(2);
c.runOnClient(m -> {rotationKey().setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_G));KeyMapping.resetMapping();});
c.getInput().pressKey(InputConstants.KEY_G);c.waitFor(m -> HotbarRows.selected(m.player)==5,200);
c.runOnClient(m -> m.gui.setScreen(new InventoryScreen(m.player)));c.waitTicks(3);
c.runOnClient(m -> {
var types=List.of(Items.STONE,Items.DIRT,Items.COAL,Items.COPPER_INGOT,Items.IRON_INGOT,Items.GOLD_INGOT);
for(int row=0;row<6;row++)for(int col=0;col<9;col++)require(m.player.getInventory().getItem(HotbarRows.physicalAddress(m.player,row*9+col)).is(types.get(row)),"Reopening shows every item in its original physical row");
require(m.player.getInventory().getSelectedSlot()==2,"Column selection is conserved when switching rows");
});c.takeScreenshot("sanctuary-beta018-hotbar-from-world");close(c);
server.runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();HotbarRows.select(p,0);p.getInventory().clearContent();
p.getInventory().setItem(9,new ItemStack(Items.STONE,8));p.getInventory().setItem(10,new ItemStack(Items.DIRT,9));p.getInventory().setItem(11,new ItemStack(Items.DIAMOND,10));
p.openMenu(new SimpleMenuProvider((id,inv,owner) -> ChestMenu.threeRows(id,inv,new SimpleContainer(27)),Component.literal("Shift drag chest")));p.containerMenu.broadcastChanges();HotbarService.send(p);
});
c.waitFor(m -> m.gui.screen() instanceof ContainerScreen && m.player.getInventory().getItem(11).is(Items.DIAMOND),200);
int first=c.computeOnClient(m -> playerSlot(m,9)),last=c.computeOnClient(m -> playerSlot(m,11));
begin(c,first);move(c,last);c.waitFor(m -> m.player.getInventory().getItem(9).isEmpty() && m.player.getInventory().getItem(10).isEmpty() && m.player.getInventory().getItem(11).isEmpty(),200);
require(server.computeOnServer(s -> {var m=s.getPlayerList().getPlayers().getFirst().containerMenu;return m.getSlot(0).getItem().getCount()==8 && m.getSlot(1).getItem().getCount()==9 && m.getSlot(2).getItem().getCount()==10;}),"One fast sweep transfers all three slots, including the intermediate slot");
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().setItem(9,new ItemStack(Items.EMERALD,2));p.containerMenu.broadcastChanges();});
c.waitFor(m -> m.player.getInventory().getItem(9).is(Items.EMERALD),200);move(c,first);end(c);c.waitTicks(3);
c.runOnClient(m -> require(m.player.getInventory().getItem(9).is(Items.EMERALD),"Revisiting a source in the same gesture does not transfer a server refill"));
begin(c,0);move(c,2);end(c);c.waitFor(m -> m.player.containerMenu.getSlot(0).getItem().isEmpty() && m.player.containerMenu.getSlot(1).getItem().isEmpty() && m.player.containerMenu.getSlot(2).getItem().isEmpty(),200);
c.takeScreenshot("sanctuary-beta018-shift-drag-chest");
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();for(int i=0;i<27;i++)p.containerMenu.getSlot(i).set(new ItemStack(Items.STONE,64));p.getInventory().setItem(9,new ItemStack(Items.EMERALD,2));p.containerMenu.broadcastChanges();});
c.waitFor(m -> m.player.containerMenu.getSlot(26).getItem().getCount()==64,200);begin(c,first);move(c,last);end(c);c.waitTicks(3);
require(server.computeOnServer(s -> s.getPlayerList().getPlayers().getFirst().getInventory().getItem(9).is(Items.EMERALD)),"Full destination preserves source stack");close(c);
server.runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();
p.getInventory().setItem(9,new ItemStack(Items.IRON_ORE,8));p.getInventory().setItem(10,new ItemStack(Items.COAL,9));p.getInventory().setItem(11,new ItemStack(Items.STONE,10));
p.openMenu(new SimpleMenuProvider((id,inv,owner) -> new FurnaceMenu(id,inv),Component.literal("Shift drag furnace")));p.containerMenu.broadcastChanges();
});
c.waitFor(m -> m.gui.screen() instanceof FurnaceScreen && m.player.getInventory().getItem(9).is(Items.IRON_ORE),200);
first=c.computeOnClient(m -> playerSlot(m,9));last=c.computeOnClient(m -> playerSlot(m,11));
begin(c,first);move(c,last);end(c);c.waitFor(m -> m.player.containerMenu.getSlot(0).getItem().is(Items.IRON_ORE) && m.player.containerMenu.getSlot(1).getItem().is(Items.COAL),200);
require(server.computeOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();int stone=0;for(int i=0;i<36;i++)if(p.getInventory().getItem(i).is(Items.STONE))stone+=p.getInventory().getItem(i).getCount();return stone==10 && p.containerMenu.getSlot(0).getItem().getCount()==8 && p.containerMenu.getSlot(1).getItem().getCount()==9;}),"Furnace native routing separates ingredient, fuel and unsuitable items without loss");
c.takeScreenshot("sanctuary-beta018-shift-drag-furnace");close(c);
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();p.getInventory().setItem(9,new ItemStack(Items.COW_SPAWN_EGG));p.getInventory().setItem(10,new ItemStack(AccessoryService.ZERO_CAPE));p.getInventory().setItem(11,new ItemStack(Items.DIAMOND));p.inventoryMenu.broadcastChanges();});
c.waitFor(m -> m.player.getInventory().getItem(11).is(Items.DIAMOND),200);c.runOnClient(m -> m.gui.setScreen(new InventoryScreen(m.player)));c.waitTick();
begin(c,9);move(c,10);c.getInput().releaseKey(o -> o.keyShift);move(c,11);end(c);c.waitTicks(3);
require(server.computeOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();var a=AccessoryAccess.of(p);return a.getItem(0).is(Items.COW_SPAWN_EGG) && a.getItem(1).is(AccessoryService.ZERO_CAPE) && a.getItem(2).isEmpty() && p.getInventory().getItem(11).is(Items.DIAMOND);}),"Paint equips eggs/capes; releasing Shift stops it; ordinary items never auto-equip as cosmetics");close(c);
System.out.println("InventoryControls018 native client passed: world Tab/reverse/remap, stable physical rows, continuous chest/furnace transfers, fast sweep, full destination, one visit per gesture and equipment rules");
} finally {
c.getInput().releaseKey(o -> rotationKey());c.getInput().releaseKey(o -> o.keyShift);
c.runOnClient(m -> {rotationKey().setKey(previous);KeyMapping.resetMapping();});
}
}
private static KeyMapping rotationKey() {return KeyMapping.get("screen.sanctuary.inventory.rotate_key");}
private static int playerSlot(Minecraft m,int index) {return m.player.containerMenu.slots.stream().filter(s -> s.container==m.player.getInventory() && s.getContainerSlot()==index).findFirst().orElseThrow().index;}
private static void close(ClientGameTestContext c) {c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitForScreen(null);c.waitTicks(3);}
private static void begin(ClientGameTestContext c,int slot) {c.getInput().holdKey(o -> o.keyShift);move(c,slot);c.runOnClient(m -> m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,InputConstants.MOD_SHIFT),1));c.waitTick();}
private static void end(ClientGameTestContext c) {c.runOnClient(m -> m.mouseHandler.onButton(m.getWindow().handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,InputConstants.MOD_SHIFT),0));c.getInput().releaseKey(o -> o.keyShift);c.waitTick();}
private static void move(ClientGameTestContext c,int index) {
c.runOnClient(m -> {
var screen=(AbstractContainerScreen<?>)m.gui.screen();var a=(AbstractContainerScreenAccessor)screen;var slot=screen.getMenu().getSlot(index);var w=m.getWindow();
double x=a.sanctuary$getLeftPos()+slot.x+8,y=a.sanctuary$getTopPos()+slot.y+8;
double sx=(double)w.getScreenWidth()/w.getGuiScaledWidth(),sy=(double)w.getScreenHeight()/w.getGuiScaledHeight();
m.mouseHandler.onMove(w.handle(),x*sx,y*sy,(x-m.mouseHandler.getScaledXPos(w))*sx,(y-m.mouseHandler.getScaledYPos(w))*sy);m.mouseHandler.handleAccumulatedMovement();
});c.waitTick();
}
private static void require(boolean value,String message) {if(!value)throw new AssertionError(message);}
}
@@ -0,0 +1,94 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.cosmetics.CarryService;
import fr.koka.sanctuary.operator.OperatorService;
import fr.koka.sanctuary.progression.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
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.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;
public final class LanCarry042ClientChecks 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);
widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
click(s,"Enter Sanctuary");onboarding.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sanctuary LAN042 "+UUID.randomUUID());s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setAllowCommands(false);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();
c.runOnClient(m->{m.gui.setScreen(new WorldOptionsScreen(null,m.level));System.out.println("LAN042_WIDGETS "+widgets(m.gui.screen()).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).map(w->w.getMessage().getString()).toList());click(m.gui.screen(),"LAN");click(m.gui.screen(),"Apply Changes");});
c.waitFor(m->m.getSingleplayerServer().isPublished(),150);
require(server.computeOnServer(s->!s.getWorldData().isAllowCommands()),"LAN opens with commands disabled");
c.runOnClient(m->{m.gui.setScreen(new WorldOptionsScreen(null,m.level));click(m.gui.screen(),"Allow Commands");click(m.gui.screen(),"Apply Changes");});
c.waitFor(m->ProgressionClient.operator,150);
System.out.println("LAN042_PERMISSION "+server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();return "world="+s.getWorldData().isAllowCommands()+" owner="+s.isSingleplayerOwner(p.nameAndId())+" permissions="+p.permissions()+" sanctuary="+OperatorService.allowed(p);}));
c.runOnClient(m->{System.out.println("LAN042_TREE "+m.getConnection().getCommands().getRoot().getChildren().stream().map(n->n.getName()).toList());require(m.getConnection().getCommands().getRoot().getChild("experience")!=null,"Experience command visible after late LAN activation");var parsed=m.getConnection().getCommands().parse("experience set @s 17 levels",m.getConnection().getSuggestionsProvider());require(!parsed.getReader().canRead(),"Command parses with current client permissions");require(m.player.chatAbilities().canSendCommands(),"Host chat permits commands");m.gui.openChatScreen(ChatComponent.ChatMethod.COMMAND);require(m.gui.screen() instanceof ChatScreen,"Native command chat opens");widgets(m.gui.screen()).filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow().setValue("/experience set @s 17 levels");});
c.waitTick();c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN);
c.waitFor(m->m.player.experienceLevel==17,150);
require(server.computeOnServer(s->s.getPlayerList().getPlayers().getFirst().experienceLevel==17),"Real client command changes server XP");
c.runOnClient(m->{m.gui.openChatScreen(ChatComponent.ChatMethod.COMMAND);widgets(m.gui.screen()).filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow().setValue("/sanctuary time vanilla");});
c.waitTick();c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN);c.waitTicks(15);
require(server.computeOnServer(s->!fr.koka.sanctuary.realtime.RealtimeService.active(s.overworld())),"Native chat /sanctuary time vanilla reaches the server after LAN activation");
System.out.println("LAN044_TIME_PASS: native chat Sanctuary command bypasses no server authorization");
require(server.computeOnServer(s->!s.getProfilePermissions(new net.minecraft.server.players.NameAndId(UUID.randomUUID(),"Guest042")).hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_GAMEMASTER)),"Host activation alone does not promote guests");
c.runOnClient(m->{m.gui.setScreen(new WorldOptionsScreen(null,m.level));click(m.gui.screen(),"Allow Commands");click(m.gui.screen(),"Apply Changes");});
c.waitFor(m->!ProgressionClient.operator&&m.getConnection().getCommands().getRoot().getChild("experience")==null,150);
c.runOnClient(m->m.getConnection().sendCommand("experience set @s 29 levels"));c.waitTicks(5);
require(server.computeOnServer(s->s.getPlayerList().getPlayers().getFirst().experienceLevel==17),"Commands disabled again are rejected by the server");
c.runOnClient(m->{m.gui.openChatScreen(ChatComponent.ChatMethod.COMMAND);widgets(m.gui.screen()).filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow().setValue("/sanctuary intro");});
c.waitTick();c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN);
c.waitFor(m->m.gui.screen() instanceof IntroScreen,150);c.getInput().pressKey(com.mojang.blaze3d.platform.InputConstants.KEY_ESCAPE);c.waitTicks(3);
System.out.println("LAN044_INTRO_PASS: ordinary inhabitant replays intro without intercepting server commands");
System.out.println("LAN042_COMMAND_PASS: native LAN publication, late host activation, native chat XP command, guest isolation and revocation");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();try{fr.koka.sanctuary.realtime.RealtimeService.setMode(s,false);}catch(Exception e){throw new RuntimeException(e);}s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");p.setGameMode(net.minecraft.world.level.GameType.CREATIVE);p.getAbilities().flying=true;p.onUpdateAbilities();});
var display=new ArrayList<net.minecraft.client.player.RemotePlayer>();
c.runOnClient(m->{
m.gui.setScreen(null);m.player.setYRot(0);m.player.setXRot(-10);
var at=m.player.position().add(0,0,6);
for(int i=0;i<3;i++){
var p=new net.minecraft.client.player.RemotePlayer(m.level,new com.mojang.authlib.GameProfile(UUID.randomUUID(),"Carry042_"+i));
p.setId(-420-i);p.setPos(at);p.setYRot(180);p.yBodyRot=p.yBodyRotO=180;p.setYHeadRot(180);p.yHeadRotO=180;m.level.addEntity(p);
if(i>0){p.setAttached(CarryService.CARRIED,true);require(p.startRiding(display.get(i-1),true,true),"Native visual passenger mounts");display.get(i-1).positionRider(p);}
display.add(p);
}
validateModels(m,display);
});
c.waitTicks(10);c.takeScreenshot("sanctuary-beta042-seated-stack");
c.runOnClient(m->{display.getFirst().setPose(net.minecraft.world.entity.Pose.CROUCHING);display.getFirst().getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.SCALE).setBaseValue(1.5);display.get(1).getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.SCALE).setBaseValue(.75);
for(int i=1;i<display.size();i++)display.get(i-1).positionRider(display.get(i));validateModels(m,display);
});
c.waitTicks(6);c.takeScreenshot("sanctuary-beta042-scaled-crouched-stack");
c.runOnClient(m->display.forEach(net.minecraft.world.entity.Entity::discard));
System.out.println("CARRY042_CLIENT_PASS: native avatar models seat pelvis on head, complete stack and mixed scales");
}
}
private static void validateModels(net.minecraft.client.Minecraft m,List<net.minecraft.client.player.RemotePlayer> players){
for(int i=0;i<players.size();i++){
var p=players.get(i);var state=(net.minecraft.client.renderer.entity.state.AvatarRenderState)m.getEntityRenderDispatcher().extractEntity(p,1);
var renderer=m.getEntityRenderDispatcher().getRenderer(state);var model=renderer.getModel();model.setupAnim(state);
if(i>0){require(state.isPassenger&&!state.isCrouching,"Carried player uses the native seated pose");require(model.rightLeg.xRot<-1.4,"Native bent leg pose is active");}
if(i+1<players.size()){
double head=p.getY()+renderer.getRenderOffset(state).y()+(1.501-(model.head.y-8)/16.0)*.9375*state.scale;
var rider=players.get(i+1);var next=(net.minecraft.client.renderer.entity.state.AvatarRenderState)m.getEntityRenderDispatcher().extractEntity(rider,1);
var nextRenderer=m.getEntityRenderDispatcher().getRenderer(next);var nextModel=nextRenderer.getModel();nextModel.setupAnim(next);
double pelvis=rider.getY()+nextRenderer.getRenderOffset(next).y()+(1.501-(nextModel.body.y+12)/16.0)*.9375*next.scale;
require(Math.abs(head-pelvis)<.0001,"Native model pelvis touches the head: gap="+(pelvis-head));
}
}
}
private static Stream<GuiEventListener> widgets(ContainerEventHandler root){return root.children().stream().flatMap(w->w instanceof ContainerEventHandler child?Stream.concat(Stream.of(w),widgets(child)):Stream.of(w));}
private static void click(Screen s,String prefix){var w=widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(b->b.getMessage().getString().startsWith(prefix)).findFirst().orElseThrow(()->new AssertionError("Missing "+prefix));require(w.visible&&w.active,"Available "+prefix);if(w instanceof Button b)b.onPress(enter());else if(w instanceof CycleButton<?> b)b.onPress(enter());else throw new AssertionError(prefix);}
private static KeyEvent enter(){return new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);}
private static void require(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,103 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.knowledge.*;
import fr.koka.sanctuary.recipes.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.cosmetics.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
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.minecraft.client.CameraType;
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.inventory.InventoryScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.core.component.DataComponents;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.*;
import net.minecraft.world.level.block.*;
/** Actual pause navigation, restricted server state, GUI sizes, tooltips and head-cake rendering. */
public final class Menus033ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var onboarding=new AtomicBoolean(true);
boolean quick=Boolean.getBoolean("sanctuary.test.quick");long began=System.nanoTime();
var introSeen=new AtomicBoolean(false);
var quickDefault=new AtomicBoolean(false);
net.fabricmc.fabric.api.client.screen.v1.ScreenEvents.AFTER_INIT.register((m,screen,w,h)->{
if(screen instanceof net.minecraft.client.gui.screens.worldselection.CreateWorldScreen creation){var type=creation.getUiState().getWorldType();quickDefault.set(type.preset()!=null&&type.preset().getRegisteredName().equals("sanctuary_test:flat"));}
});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(m.gui.screen() instanceof IntroScreen)introSeen.set(true);});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(onboarding.get()&&m.gui.screen() instanceof HelloWorldScreen 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());onboarding.set(false);
}});
int scale=c.computeOnClient(m->m.options.guiScale().get());
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sanctuary menus033 "+UUID.randomUUID());s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals(quick?"sanctuary_test:flat":"sanctuary:sanctuary")).findFirst().orElseThrow());}).create()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&RecipeClient.ready&&SanctuaryJeiPlugin.runtime()!=null&&m.gui.screen()==null,1200);
var server=world.getServer();
if(quick){require(quickDefault.get(),"Quick profile is the default on the native world creation screen");require(!introSeen.get(),"Quick profile completes the real intro exchange without rendering its sequence");require(server.computeOnServer(s->s.overworld().getChunkSource().getGenerator() instanceof net.minecraft.world.level.levelgen.FlatLevelSource && ProgressionService.session(s)!=null),"Flat native world has the real Sanctuary progression session");System.out.println("QUICK033 admitted with identity on native flat world in "+((System.nanoTime()-began)/1_000_000)+" ms");}
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var at=p.blockPosition().above(80);
for(int x=-10;x<=10;x++)for(int z=-10;z<=10;z++)for(int y=-1;y<7;y++)p.level().setBlockAndUpdate(at.offset(x,y,z),(y==-1?Blocks.STONE:Blocks.AIR).defaultBlockState());
p.teleportTo(at.getX()+.5,at.getY(),at.getZ()+.5);p.getInventory().clearContent();p.getInventory().setItem(0,new ItemStack(Items.OAK_PLANKS,8));
BlockKnowledgeService.observeInventory(p);BlockKnowledgeService.recordSeen(p,Blocks.OBSIDIAN);RecipeService.refresh(p);p.inventoryMenu.broadcastChanges();});
c.waitTicks(20);c.runOnClient(m->{require(!RecipeClient.operatorAllowed&&!RecipeClient.operator,"Survival without commands is not an operator");m.player.setXRot(-25);});
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitFor(m->m.gui.screen() instanceof PauseScreen,100);
for(int value:List.of(1,2,3,4)){
c.runOnClient(m->{m.options.guiScale().set(value);m.resizeGui();var screen=m.gui.screen();
for(String key:List.of("gui.advancements","gui.stats","sanctuary.progression.blocks","sanctuary.progression.progress","sanctuary.progression.history","sanctuary.progression.identity","menu.options","options.worldOptions.button"))button(screen,key);
for(String key:List.of("menu.reportBugs","menu.sendFeedback","menu.playerReporting","sanctuary.progression.title","sanctuary.cycle.faction","sanctuary.cycle.log"))require(!hasButton(screen,key),"Removed pause entry: "+key);
require(widgets(screen).noneMatch(w->w.getClass().getSimpleName().equals("FriendsButton")),"No Friends icon row");
for(var w:widgets(screen).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->w.visible).toList())require(w.getX()>=0&&w.getY()>=0&&w.getX()+w.getWidth()<=screen.width&&w.getY()+w.getHeight()<=screen.height,"Pause controls fit GUI scale "+value);
int above=button(screen,"gui.advancements").getY(),below=button(screen,"menu.options").getY();
for(String key:List.of("blocks","progress","history","identity")){int y=button(screen,"sanctuary.progression."+key).getY();require(y>above&&y<below,"Pages sit between achievements and options");}
});c.waitTicks(3);if(value==2)c.takeScreenshot("sanctuary-beta033-pause");
}
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();button(m.gui.screen(),"sanctuary.progression.history").onPress(enter());});
c.waitFor(m->m.gui.screen() instanceof SanctuaryAtlasScreen,100);c.waitTicks(5);c.takeScreenshot("sanctuary-beta033-world-direct");done(c);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.progression.progress").onPress(enter()));c.waitFor(m->m.gui.screen() instanceof ProgressionScreen,100);
c.runOnClient(m->{require(button(m.gui.screen(),"sanctuary.cycle.entry").getMessage().getString().equals("Prestige"),"Prestige has a stable, readable name");require(!hasButton(m.gui.screen(),"sanctuary.progression.blocks"),"Pages no longer repeat section navigation");});c.takeScreenshot("sanctuary-beta033-progression");done(c);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.progression.blocks").onPress(enter()));c.waitFor(m->m.gui.screen() instanceof SanctuaryBlocodexScreen,100);c.waitTicks(12);
c.runOnClient(m->{var filter=widgets(m.gui.screen()).filter(CycleButton.class::isInstance).map(CycleButton.class::cast).findFirst().orElseThrow();require(filter.getValue().toString().equals("POSSESSED"),"Discovery defaults to Owned");
for(int i=0;i<12;i++){filter.onPress(enter());require(!filter.getValue().toString().equals("ALL"),"All Blocks is absent before purchase");}
require(discoveries(m.gui.screen()).noneMatch(t->t.contains("Obsidian")||t.contains("Diamond")),"Unowned and unknown entries are hidden");
button(m.gui.screen(),"sanctuary.recipes.title").onPress(enter());});c.waitFor(m->m.gui.screen() instanceof RecipeScreen,100);
c.runOnClient(m->{require(!hasButton(m.gui.screen(),"sanctuary.recipes.show_operator"),"Discovery does not offer operator inspection");RecipeClient.request("operator");});c.waitTicks(25);
c.runOnClient(m->require(!RecipeClient.operator&&!RecipeClient.operatorAllowed,"A direct non-operator request cannot enable operator mode"));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.giveExperienceLevels(4-p.experienceLevel);RecipeService.refresh(p);});c.waitFor(m->RecipeClient.xp>=4,100);
c.runOnClient(m->button(m.gui.screen(),"sanctuary.recipes.buy").onPress(enter()));c.waitFor(m->RecipeClient.knowledge.learned(),100);done(c);c.waitTicks(12);
c.runOnClient(m->{var filter=widgets(m.gui.screen()).filter(CycleButton.class::isInstance).map(CycleButton.class::cast).findFirst().orElseThrow();for(int i=0;i<9&&!filter.getValue().toString().equals("ALL");i++)filter.onPress(enter());require(filter.getValue().toString().equals("ALL"),"Paid Catalogue unlocks All Blocks");widgets(m.gui.screen()).filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().orElseThrow().setValue("obsidian");});c.waitTicks(45);
c.runOnClient(m->require(discoveries(m.gui.screen()).anyMatch(t->t.contains("Obsidian")),"Seen-only block appears after the paid unlock"));c.takeScreenshot("sanctuary-beta033-discovery-unlocked");
for(String locale:List.of("en_us","fr_fr")){
c.runOnClient(m->{m.gui.setScreen(null);m.getLanguageManager().setSelected(locale);m.options.languageCode=locale;m.reloadResourcePacks();});c.waitTicks(60);
c.runOnClient(m->{for(String key:List.of("jei.tooltip.bookmarks.enable","jei.tooltip.bookmarks.disable","jei.tooltip.lookupHistory.enable","jei.tooltip.lookupHistory.disable")){String value=Component.translatable(key).getString();require(!value.contains("JEI")&&!value.contains("Just Enough Items")&&!value.equals(key),"Clean native tooltip: "+locale+"/"+key);}
require(Component.translatable("sanctuary.cycle.log").getString().equals(locale.equals("fr_fr")?"Historique":"History"),"History label matches the game language");
require(!mezz.jei.api.sanctuary.CatalogueAccess.configButtonVisible.getAsBoolean(),"Configuration has no drawn or interactive overlay entry");m.gui.setScreen(new InventoryScreen(m.player));});c.waitTicks(8);c.takeScreenshot("sanctuary-beta033-catalogue-"+locale);
}
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var cake=new ItemStack(Items.CAKE);
CustomData.update(DataComponents.CUSTOM_DATA,cake,tag->tag.putString(HeadCosmeticData.CANDLE,"minecraft:blue_candle"));cake.set(DataComponents.BLOCK_STATE,BlockItemStateProperties.EMPTY.with(CandleBlock.LIT,true));AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,cake);});
c.waitFor(m->HeadCosmeticData.lit(HeadCosmeticService.appearance(m.player)),100);
c.runOnClient(m->{m.gui.setScreen(null);m.options.setCameraType(CameraType.THIRD_PERSON_FRONT);m.player.setXRot(0);m.options.fov().set(35);});c.waitTicks(20);c.takeScreenshot("sanctuary-beta033-candle-cake-flame");
c.runOnClient(m->{m.options.setCameraType(CameraType.FIRST_PERSON);m.options.fov().set(70);m.options.guiScale().set(scale);m.resizeGui();});
System.out.println("UI033 client passed: native pause layout, direct pages, paid discovery, denied operator request, FR/EN tooltips and cake flame rendered.");
}
}
private static void done(ClientGameTestContext c){c.runOnClient(m->button(m.gui.screen(),"gui.done").onPress(enter()));c.waitTicks(4);}
private static Stream<String> discoveries(Screen screen){return widgets(screen).filter(ObjectSelectionList.class::isInstance).map(ObjectSelectionList.class::cast).flatMap(list->((List<ObjectSelectionList.Entry<?>>)list.children()).stream()).map(e->e.getNarration().getString());}
private static Stream<GuiEventListener> widgets(ContainerEventHandler c){return c.children().stream().flatMap(w->w instanceof ContainerEventHandler nested?Stream.concat(Stream.of(w),widgets(nested)):Stream.of(w));}
private static Button button(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(()->new AssertionError("Missing button: "+key));}
private static boolean hasButton(Screen s,String key){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).anyMatch(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key));}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,104 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.mining.*;
import fr.koka.sanctuary.progression.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestServerContext;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.input.MouseButtonInfo;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.stats.Stats;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
/** Real player packets and native input; no direct call to MiningClient or the server's mining handler. */
final class Mining008ClientChecks {
static void run(ClientGameTestContext c,TestServerContext server) {
server.runOnServer(s -> s.getPlayerList().getPlayers().getFirst().giveExperienceLevels(2));
c.waitFor(m -> m.player.experienceLevel==3,400);
for (int rank=1;rank<=2;rank++) {
c.runOnClient(m -> {
var button=m.gui.screen().children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals("sanctuary.progression.upgrade_cost"))
.sorted(java.util.Comparator.comparingInt(Button::getY)).skip(2).findFirst().orElseThrow();
require(button.active,"The mining purchase is available in the skills column");
double x=(button.getX()+button.getWidth()/2.)*m.getWindow().getScreenWidth()/m.getWindow().getGuiScaledWidth();
double y=(button.getY()+button.getHeight()/2.)*m.getWindow().getScreenHeight()/m.getWindow().getGuiScaledHeight();
m.mouseHandler.onMove(m.getWindow().handle(),x,y,0,0);m.mouseHandler.handleAccumulatedMovement();
var mouse=new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,0);
m.mouseHandler.onButton(m.getWindow().handle(),mouse,InputConstants.PRESS);
m.mouseHandler.onButton(m.getWindow().handle(),mouse,InputConstants.RELEASE);
});
int expected=rank;c.waitFor(m -> ProgressionClient.progress.mining()==expected,400);
}
c.runOnClient(m -> require(m.player.experienceLevel==0,"Both ranks cost exactly three levels"));
Blocodex004ClientChecks.language(c,"fr_fr");c.takeScreenshot("sanctuary-beta008-minage-fr");
Blocodex004ClientChecks.language(c,"en_us");
c.clickScreenButton("gui.done");c.waitForScreen(null);
BlockPos origin=server.computeOnServer(s -> {
var player=s.getPlayerList().getPlayers().getFirst();var base=player.blockPosition().above(14);
for (int x=-2;x<=6;x++) for (int z=-2;z<=7;z++) {
player.level().setBlockAndUpdate(base.offset(x,-1,z),Blocks.BEDROCK.defaultBlockState());
for (int y=0;y<=4;y++) player.level().setBlockAndUpdate(base.offset(x,y,z),Blocks.AIR.defaultBlockState());
}
var target=base.offset(0,1,3);
for (int i=0;i<8;i++) player.level().setBlockAndUpdate(target.offset(i%4,0,i/4),Blocks.STONE.defaultBlockState());
player.teleportTo(base.getX()+.5,base.getY(),base.getZ()+.5);
player.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.DIAMOND_PICKAXE));player.inventoryMenu.broadcastChanges();
return target;
});
c.waitFor(m -> m.level.getBlockState(origin).is(Blocks.STONE) && m.player.getMainHandItem().is(Items.DIAMOND_PICKAXE)
&& Math.abs(m.player.getY()-(origin.getY()-1))<.1,600);
c.getInput().lookAt(origin);c.waitTicks(3);
c.getInput().holdKey(InputConstants.KEY_R);
c.waitFor(m -> MiningClient.previewSize()==8,200);
c.takeScreenshot("sanctuary-beta008-vein-preview-eight");
c.runOnClient(m -> {
int slot=m.player.getInventory().getSelectedSlot();
m.mouseHandler.onScroll(m.getWindow().handle(),0,-1);
require(MiningClient.selectedRank()==1 && m.player.getInventory().getSelectedSlot()==slot,"Modifier wheel selects a smaller vein without changing the held tool");
});
c.waitFor(m -> MiningClient.previewSize()==4,100);
c.runOnClient(m -> m.mouseHandler.onScroll(m.getWindow().handle(),0,1));
c.waitFor(m -> MiningClient.previewSize()==8,100);
c.getInput().holdKey(o -> o.keyAttack);
server.waitFor(s -> MiningManager.active(s.getPlayerList().getPlayers().getFirst()),200);
c.waitTicks(3);c.getInput().releaseKey(o -> o.keyAttack);
server.waitFor(s -> !MiningManager.active(s.getPlayerList().getPlayers().getFirst()),200);
server.runOnServer(s -> require(s.overworld().getBlockState(origin).is(Blocks.STONE),"Releasing the physical attack gesture cancels before the vein breaks"));
c.getInput().holdKey(o -> o.keyAttack);
c.waitFor(m -> m.level.getBlockState(origin).isAir(),800);
c.getInput().releaseKey(o -> o.keyAttack);c.getInput().releaseKey(InputConstants.KEY_R);c.waitTicks(5);
server.runOnServer(s -> {
var player=s.getPlayerList().getPlayers().getFirst();
require(player.getStats().getValue(Stats.BLOCK_MINED.get(Blocks.STONE))==8 && player.getMainHandItem().getDamageValue()==8,
"The real client mines the full vein exactly once through server vanilla actions");
for (int i=0;i<8;i++) require(player.level().getBlockState(origin.offset(i%4,0,i/4)).isAir(),"Every selected block is removed");
for (int i=0;i<8;i++) player.level().setBlockAndUpdate(origin.offset(i%4,0,i/4),Blocks.STONE.defaultBlockState());
});
c.waitFor(m -> m.level.getBlockState(origin).is(Blocks.STONE),200);c.getInput().lookAt(origin);
c.runOnClient(m -> {miningKey(m).setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_G));KeyMapping.resetMapping();});
try {
c.getInput().holdKey(InputConstants.KEY_G);c.waitFor(m -> MiningClient.previewSize()==8,200);
c.getInput().holdKey(o -> o.keyAttack);
server.waitFor(s -> MiningManager.active(s.getPlayerList().getPlayers().getFirst()),200);
c.getInput().pressKey(InputConstants.KEY_B);c.waitForScreen(ProgressionScreen.class);
server.waitFor(s -> !MiningManager.active(s.getPlayerList().getPlayers().getFirst()),200);
c.getInput().releaseKey(o -> o.keyAttack);c.getInput().releaseKey(InputConstants.KEY_G);
c.takeScreenshot("sanctuary-beta008-mining-progression");
} finally {
c.getInput().releaseKey(o -> o.keyAttack);c.getInput().releaseKey(InputConstants.KEY_R);c.getInput().releaseKey(InputConstants.KEY_G);
c.runOnClient(m -> {miningKey(m).setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_R));KeyMapping.resetMapping();});
}
System.out.println("Mining beta008 native client passed: two real purchases, eight/four block previews, native wheel, release, repeated mining, configurable key and Blocodex interruption.");
}
private static KeyMapping miningKey(net.minecraft.client.Minecraft m) {
return java.util.Arrays.stream(m.options.keyMappings).filter(k -> k.getName().equals("sanctuary.mining.key")).findFirst().orElseThrow();
}
private static void require(boolean value,String message) {if (!value) throw new AssertionError(message);}
}
@@ -0,0 +1,116 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.cosmetics.*;
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.*;
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.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.level.*;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.entity.item.PrimedTnt;
import net.minecraft.world.inventory.ChestMenu;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.*;
import net.minecraft.world.level.storage.*;
import net.minecraft.world.phys.*;
/** Real client packets and integrated-server lifecycle; no dedicated-server EULA. */
public final class MobHead060ClientChecks implements FabricClientGameTest {
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("MobHead060 "+UUID.randomUUID());s.setSeed("42");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&&ProgressionClient.active()&&m.gui.screen()==null,1200);var server=world.getServer();c.waitTicks(40);
int id=server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.getInventory().clearContent();s.getWorldData().setAllowCommands(false);fr.koka.sanctuary.progression.ProgressionService.send(p,"");p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.REDSTONE_TORCH,3));p.setShiftKeyDown(true);p.inventoryMenu.broadcastChanges();
var mob=EntityTypes.CHICKEN.create(p.level(),EntitySpawnReason.COMMAND);mob.setPos(p.position().add(0,0,2));mob.setCustomName(Component.literal("Circuit chicken"));p.level().addFreshEntity(mob);check(!mob.isNoAi(),"Chicken keeps its native AI");mob.setNoAi(true);var at=BlockPos.containing(mob.getEyePosition().add(0,.25,0));p.level().setBlock(at.east(),Blocks.REDSTONE_LAMP.defaultBlockState(),3);p.level().setBlock(at.offset(1,0,3),Blocks.REDSTONE_LAMP.defaultBlockState(),3);return mob.getId();});
c.waitFor(m->m.level.getEntity(id)!=null&&m.player.getMainHandItem().is(Items.REDSTONE_TORCH),200);c.getInput().holdKey(o->o.keyShift);c.waitTicks(5);
click(c,id);c.waitFor(m->HeadCosmeticService.appearance((Mob)m.level.getEntity(id)).is(Items.REDSTONE_TORCH),200);
server.runOnServer(s->{var p=player(s);var mob=mob(s,id);check(HeadEquipment.get(mob).getCount()==1&&p.getMainHandItem().getCount()==2,"Real shift-right-click transfers exactly one item");check(!mob.isPassenger(),"Equipment gesture never carries the chicken");check(mob.requiresCustomPersistence(),"Equipped mob cannot naturally despawn");
var at=HeadBlockService.session(mob).pos();check(p.level().hasNeighborSignal(at.east()),"Chicken torch energizes actual neighboring cell");});
c.getInput().releaseKey(o->o.keyShift);c.waitTicks(10);
server.runOnServer(s->{var m=mob(s,id);var at=HeadBlockService.session(m).pos();check(m.level().getBlockState(at.east()).getValue(RedstoneLampBlock.LIT),"Native lamp lights from mobile source");m.setPos(m.position().add(0,0,3));});c.waitTicks(12);
server.runOnServer(s->{var m=mob(s,id);var at=HeadBlockService.session(m).pos();check(!m.level().getBlockState(at.offset(1,0,-3)).getValue(RedstoneLampBlock.LIT),"Movement removes the old source and native lamp turns off");check(((ServerLevel)m.level()).hasNeighborSignal(at.east()),"New cell powers without scanning chunks");m.setPos(player(s).position().add(0,0,2));});c.waitTicks(8);
c.runOnClient(m->{var mob=(Mob)m.level.getEntity(id);var state=m.getEntityRenderDispatcher().getRenderer(mob).createRenderState(mob,1);var hat=((MobHeadState.Access)state).sanctuary$mobHat();check(hat.active&&!hat.head.block.isEmpty(),"Native chicken renderer extracts torch model");m.getWindow().setWindowed(1280,800);m.options.guiScale().set(2);m.resizeGui();});
c.getInput().lookAt(0,12);c.takeScreenshot("sanctuary-beta060-chicken-redstone");
// Removal uses the same native interaction and restores empty-head carry behavior.
server.runOnServer(s->{var p=player(s);p.setItemInHand(InteractionHand.MAIN_HAND,ItemStack.EMPTY);p.setShiftKeyDown(true);p.inventoryMenu.broadcastChanges();});
c.waitFor(m->m.player.getMainHandItem().isEmpty(),200);c.getInput().holdKey(o->o.keyShift);c.waitTicks(5);click(c,id);c.waitTicks(10);
server.runOnServer(s->{var p=player(s);var mob=mob(s,id);check(HeadEquipment.get(mob).isEmpty(),"Empty-hand shift-right-click retrieves equipment");check(CarryService.interact(p,mob),"Empty-headed mob remains carryable");CarryService.release(mob,true);mob.setPos(p.position().add(0,0,2));p.setShiftKeyDown(false);});c.getInput().releaseKey(o->o.keyShift);
// Shared native menus, persistence and item conservation use the actual mob slot/session.
server.runOnServer(s->storage(s,id));c.waitTicks(10);
c.runOnClient(m->{check(m.gui.screen() instanceof net.minecraft.client.gui.screens.inventory.ContainerScreen,"Mob chest opens a real client chest screen");});c.takeScreenshot("sanctuary-beta060-mob-chest");
server.runOnServer(s->{var p=player(s);var mob=mob(s,id);HeadEquipment.set(mob,ItemStack.EMPTY);check(p.containerMenu==p.inventoryMenu,"Removing storage closes its visitor menu");check(drops(p,Items.DIAMOND)==7,"Ordinary chest content returned exactly once");});c.waitFor(m->m.gui.screen()==null,200);
server.runOnServer(s->lifecycle(s,id));c.waitTicks(8);
server.runOnServer(s->interactions(s,id));c.waitTicks(12);
server.runOnServer(s->{var p=player(s);var m=mob(s,id);check(((Container)HeadBlockService.session(m).entity).getItem(0).getCount()==1,"A hit fires one native dispenser arrow");
HeadEquipment.set(m,new ItemStack(Items.FURNACE));var furnace=(Container)HeadBlockService.session(m).entity;furnace.setItem(0,new ItemStack(Items.RAW_IRON));furnace.setItem(1,new ItemStack(Items.COAL));});
c.waitTicks(210);server.runOnServer(s->{var f=(Container)HeadBlockService.session(mob(s,id)).entity;check(f.getItem(2).is(Items.IRON_INGOT),"Carried furnace smelts with real recipe and fuel");});
// Adult, baby, hostile, fish and non-block items all pass native model extraction.
c.runOnClient(m->{for(var type:List.of(EntityTypes.CHICKEN,EntityTypes.COW,EntityTypes.ZOMBIE,EntityTypes.BEE,EntityTypes.SLIME,EntityTypes.COD,EntityTypes.WITHER,EntityTypes.ENDER_DRAGON)){
var e=(Mob)type.create(m.level,EntitySpawnReason.COMMAND);e.setId(200000+type.hashCode()%100000);e.setPos(m.player.position());e.xo=e.xOld=e.getX();e.yo=e.yOld=e.getY();e.zo=e.zOld=e.getZ();
for(boolean baby:List.of(false,true)){e.setBaby(baby);e.setAttached(AccessoryService.APPEARANCE,new AccessoryService.Appearance(ItemStack.EMPTY,new ItemStack(Items.CHEST)));var state=m.getEntityRenderDispatcher().getRenderer(e).createRenderState(e,1);check(((MobHeadState.Access)state).sanctuary$mobHat().active,"Mob and baby model supports equipment: "+type);}
}
var e=(Mob)m.level.getEntity(id);e.setAttached(AccessoryService.APPEARANCE,new AccessoryService.Appearance(ItemStack.EMPTY,new ItemStack(Items.DIAMOND)));var state=m.getEntityRenderDispatcher().getRenderer(e).createRenderState(e,1);check(!((MobHeadState.Access)state).sanctuary$mobHat().head.item.isEmpty(),"Any ordinary item has a native head render");});
for(String language:List.of("fr_fr","en_us"))c.runOnClient(m->{m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());check(!Component.translatable("sanctuary.mob_head.equipped").getString().contains("sanctuary."),"Localized equipment hints");});
System.out.println("MOB_HEAD060_PASS: native input, mobile redstone, inventory, persistence, death, Shulker, shared player blocks, models and FR/EN");
}finally{intro.set(false);c.getInput().releaseKey(o->o.keyShift);}
}
private static void storage(net.minecraft.server.MinecraftServer s,int id){
var p=player(s);var m=mob(s,id);clearDrops(p);p.setShiftKeyDown(false);p.setItemInHand(InteractionHand.MAIN_HAND,ItemStack.EMPTY);HeadEquipment.set(m,new ItemStack(Items.CHEST));
var session=HeadBlockService.session(m);((Container)session.entity).setItem(0,new ItemStack(Items.DIAMOND,7));
check(HeadCosmeticService.interact(p,m)&&p.containerMenu instanceof ChestMenu chest&&chest.getRowCount()==3,"Mob chest is the native three-row public container");
var tag=save(m);HeadBlockService.suspend(m);check(HeadBlockService.session(m).entity instanceof ChestBlockEntity chest&&chest.getItem(0).getCount()==7,"Suspend and resume preserve contents");
var copy=EntityTypes.CHICKEN.create(p.level(),EntitySpawnReason.COMMAND);copy.load(TagValueInput.create(ProblemReporter.DISCARDING,p.level().registryAccess(),tag));
check(HeadEquipment.get(copy).is(Items.CHEST)&&((Container)HeadBlockService.session(copy).entity).getItem(0).getCount()==7,"Native entity save/load retains head and contents");HeadBlockService.suspend(copy);
HeadBlockService.use(p,m,false);p.inventoryMenu.broadcastChanges();
}
private static void lifecycle(net.minecraft.server.MinecraftServer s,int id){
var p=player(s);var m=mob(s,id);clearDrops(p);HeadEquipment.set(m,new ItemStack(Items.SHULKER_BOX));var ses=HeadBlockService.session(m);((Container)ses.entity).setItem(0,new ItemStack(Items.EMERALD,5));
p.setShiftKeyDown(true);p.setItemInHand(InteractionHand.MAIN_HAND,ItemStack.EMPTY);check(MobHeadService.equip(p,m),"Retrieve Shulker from mob");
check(drops(p,Items.EMERALD)==0,"Shulker retrieval does not also drop contents");
var boxes=new ArrayList<ItemStack>();for(int i=0;i<p.getInventory().getContainerSize();i++)if(p.getInventory().getItem(i).is(Items.SHULKER_BOX))boxes.add(p.getInventory().getItem(i));check(boxes.size()==1&&boxes.getFirst().get(DataComponents.CONTAINER).itemCopies().mapToInt(ItemStack::getCount).sum()==5,"Exactly one Shulker with five emeralds");
var dying=EntityTypes.CHICKEN.create(p.level(),EntitySpawnReason.COMMAND);dying.setPos(p.position().add(1,0,1));p.level().addFreshEntity(dying);HeadEquipment.set(dying,new ItemStack(Items.BARREL));((Container)HeadBlockService.session(dying).entity).setItem(0,new ItemStack(Items.GOLD_INGOT,4));dying.kill(p.level());
check(drops(p,Items.BARREL)==1&&drops(p,Items.GOLD_INGOT)==4,"Death returns one barrel and its exact contents");MobHeadService.drop(dying);check(drops(p,Items.BARREL)==1,"Repeated cleanup cannot duplicate equipment");
var invalid=new CompoundTag();invalid.putInt("schema",999);var tag=new CompoundTag();tag.put(MobHeadEquipment.KEY,invalid);var bad=EntityTypes.CHICKEN.create(p.level(),EntitySpawnReason.COMMAND);HeadEquipment.mob(bad).read(TagValueInput.create(ProblemReporter.DISCARDING,p.level().registryAccess(),tag));check(!HeadEquipment.mob(bad).readable(),"Invalid data is blocked");var out=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,p.level().registryAccess());HeadEquipment.mob(bad).write(out);check(out.buildResult().getCompound(MobHeadEquipment.KEY).orElseThrow().equals(invalid),"Invalid data is preserved verbatim");
var loaded=EntityTypes.CHICKEN.create(p.level(),EntitySpawnReason.COMMAND);loaded.setPos(p.position().add(-2,0,1));loaded.setNoAi(true);p.level().addFreshEntity(loaded);HeadEquipment.set(loaded,new ItemStack(Items.REDSTONE_TORCH));HeadBlockService.session(loaded).tick();var source=HeadBlockService.session(loaded).pos();var saved=save(loaded);
check(HeadRedstoneService.signal(p.level(),source)==15,"Loaded mob source active");loaded.remove(Entity.RemovalReason.UNLOADED_TO_CHUNK);check(HeadRedstoneService.signal(p.level(),source)==0,"Native entity unload removes its power immediately");
var restored=EntityTypes.CHICKEN.create(p.level(),EntitySpawnReason.COMMAND);restored.load(TagValueInput.create(ProblemReporter.DISCARDING,p.level().registryAccess(),saved));p.level().addFreshEntity(restored);HeadBlockService.session(restored).tick();check(HeadRedstoneService.signal(p.level(),source)==15,"Loaded saved mob restores its source");MobHeadService.drop(restored);restored.discard();
p.setShiftKeyDown(false);
}
private static void interactions(net.minecraft.server.MinecraftServer s,int id){
var p=player(s);var m=mob(s,id);p.setShiftKeyDown(false);p.setItemInHand(InteractionHand.MAIN_HAND,ItemStack.EMPTY);
HeadEquipment.set(m,new ItemStack(Items.LEVER));check(HeadBlockService.use(p,m,false),"Mob lever is usable");check(HeadBlockService.session(m).state.getValue(LeverBlock.POWERED),"Mob lever changes block state");
HeadEquipment.set(m,new ItemStack(Items.STONE_BUTTON));check(HeadBlockService.use(p,m,false)&&HeadBlockService.session(m).emitsPower(),"Mob button emits native timed power");
HeadEquipment.set(m,new ItemStack(Items.FLOWER_POT));p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.POPPY));check(HeadBlockService.use(p,m,false)&&HeadBlockService.session(m).state.is(Blocks.POTTED_POPPY),"Native transformations retain mob session");
HeadEquipment.set(m,new ItemStack(Items.DISPENSER));((Container)HeadBlockService.session(m).entity).setItem(0,new ItemStack(Items.ARROW,2));check(m.hurtServer(p.level(),p.damageSources().generic(),1),"Real mob damage triggers its head block");
// Player path still retains its original slot and native menu behavior after generalization.
AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,new ItemStack(Items.CHEST));p.setItemInHand(InteractionHand.MAIN_HAND,ItemStack.EMPTY);check(HeadBlockService.use(p,p,false)&&p.containerMenu instanceof ChestMenu,"Player head chest remains functional");AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,ItemStack.EMPTY);
var bomb=EntityTypes.CHICKEN.create(p.level(),EntitySpawnReason.COMMAND);bomb.setPos(p.position().add(-1,0,2));p.level().addFreshEntity(bomb);HeadEquipment.set(bomb,new ItemStack(Items.TNT));var tnt=HeadTntService.ignite(bomb,p.damageSources().generic());check(tnt!=null&&HeadEquipment.get(bomb).isEmpty(),"Mob TNT becomes one real fused entity");check(HeadTntService.ignite(bomb,p.damageSources().generic())==null,"TNT cannot prime twice");tnt.discard();bomb.discard();
}
private static CompoundTag save(Mob m){var out=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,m.level().registryAccess());m.saveWithoutId(out);return out.buildResult();}
private static long drops(ServerPlayer p,Item item){return p.level().getEntitiesOfClass(ItemEntity.class,p.getBoundingBox().inflate(12)).stream().filter(e->e.getItem().is(item)).mapToLong(e->e.getItem().getCount()).sum();}
private static void clearDrops(ServerPlayer p){p.level().getEntitiesOfClass(ItemEntity.class,p.getBoundingBox().inflate(12)).forEach(Entity::discard);}
private static ServerPlayer player(net.minecraft.server.MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();}
private static Mob mob(net.minecraft.server.MinecraftServer s,int id){return (Mob)s.overworld().getEntity(id);}
private static void click(ClientGameTestContext c,int id){c.runOnClient(m->{var mob=m.level.getEntity(id);check(m.player.isShiftKeyDown(),"Native interaction packet carries held sneak modifier");m.gameMode.interact(m.player,mob,new EntityHitResult(mob,mob.getEyePosition()),InteractionHand.MAIN_HAND);});}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,184 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor;
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.KeyMapping;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.inventory.*;
import net.minecraft.client.gui.screens.recipebook.RecipeBookComponent;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.Pose;
import net.minecraft.world.inventory.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
public final class Movement017ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var armed=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m -> {
if(armed.get() && m.gui.screen() instanceof HelloWorldScreen 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());armed.set(false);
}
});
String language=c.computeOnClient(m -> m.getLanguageManager().getSelected());int scale=c.computeOnClient(m -> m.options.guiScale().get());
var previous=c.computeOnClient(m -> InputConstants.getKey(KeyMapping.get("sanctuary.progression.lying_key").saveString()));
var forward=c.computeOnClient(m -> InputConstants.getKey(m.options.keyUp.saveString()));
int letterW=c.computeOnClient(m -> {
var binding=KeyMapping.get("sanctuary.progression.lying_key");
require(binding.getDefaultKey().getDisplayName().getString().equals("W"),"Default posture key is the letter W on the active keyboard layout");
return binding.getDefaultKey().getValue();
});
var xpSounds=new java.util.concurrent.atomic.AtomicInteger();
net.minecraft.client.sounds.SoundEventListener soundListener=(sound,event,distance) -> {
if(sound.getIdentifier().equals(net.minecraft.sounds.SoundEvents.EXPERIENCE_ORB_PICKUP.location()))xpSounds.incrementAndGet();
};
c.runOnClient(m -> m.getSoundManager().addListener(soundListener));
try(var world=c.worldBuilder().adjustSettings(s -> {
s.setName("Sanctuary beta017 movement "+UUID.randomUUID());
s.setWorldType(s.getNormalPresetList().stream().filter(e -> e.preset()!=null && e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());
s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
}).create()) {
world.getConnection().waitForChunksDownload(2400);
c.waitFor(m -> m.player!=null && ProgressionClient.active() && m.gui.screen()==null,1200);
var server=world.getServer();
// Isolate fixture movement from W without changing or depending on saved player controls.
c.runOnClient(m -> {m.options.keyUp.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_UP));KeyMapping.get("sanctuary.progression.lying_key").setKey(InputConstants.Type.KEYBOARD.getOrCreate(letterW));KeyMapping.resetMapping();});
BlockPos floor=server.computeOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();var base=p.blockPosition().above(8);
for(int x=-4;x<=4;x++)for(int z=-4;z<=4;z++) {
p.level().setBlockAndUpdate(base.offset(x,0,z),Blocks.STONE.defaultBlockState());
for(int y=1;y<=5;y++)p.level().setBlockAndUpdate(base.offset(x,y,z),Blocks.AIR.defaultBlockState());
}
p.teleportTo(base.getX()+.5,base.getY()+1,base.getZ()+.5);
var progress=ProgressRecord.INITIAL.buy("hunger",1);p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);p.getFoodData().setFoodLevel(8);ProgressionService.send(p,"");return base;
});
c.waitFor(m -> m.player.onGround() && m.player.getY()>=floor.getY()+1 && ProgressionClient.progress.hunger()==1,200);
c.getInput().pressKey(letterW);c.waitTicks(5);
c.runOnClient(m -> require(LyingService.posture(m.player)==0,"W cannot bypass an unpurchased aptitude"));
// Real water input before purchasing the aptitude: slow movement and upward buoyancy remain.
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();for(int x=-2;x<=2;x++)for(int z=-2;z<=2;z++)for(int y=1;y<=4;y++)p.level().setBlockAndUpdate(floor.offset(x,y,z),Blocks.WATER.defaultBlockState());p.teleportTo(floor.getX()+.5,floor.getY()+1,floor.getZ()+.5);});
c.waitFor(m -> m.player.isInWater(),200);var start=c.computeOnClient(m -> m.player.position());
c.getInput().holdKey(o -> o.keyUp);c.getInput().holdKey(o -> o.keySprint);c.waitTicks(8);
c.runOnClient(m -> require(!m.player.isSprinting() && !m.player.isSwimming() && Math.hypot(m.player.getX()-start.x,m.player.getZ()-start.z)>0.01,"Untrained player moves slowly without aquatic sprint"));
c.getInput().releaseKey(o -> o.keyUp);c.getInput().releaseKey(o -> o.keySprint);
double oldY=c.computeOnClient(m -> m.player.getY());c.getInput().holdKey(o -> o.keyJump);c.waitTicks(8);c.getInput().releaseKey(o -> o.keyJump);
c.runOnClient(m -> require(m.player.getY()>oldY,"Jump can still bring an untrained player toward the surface"));
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();for(int x=-4;x<=4;x++)for(int z=-4;z<=4;z++)for(int y=1;y<=5;y++)p.level().setBlockAndUpdate(floor.offset(x,y,z),Blocks.AIR.defaultBlockState());p.teleportTo(floor.getX()+.5,floor.getY()+1,floor.getZ()+.5);p.setExperienceLevels(8);p.setExperiencePoints(0);ProgressionService.send(p,"");});
c.waitFor(m -> m.player.onGround() && !m.player.isInWater() && m.player.experienceLevel==8,200);
c.runOnClient(m -> {m.options.guiScale().set(1);m.resizeGui();ProgressionClient.open(null);});c.waitTick();
for(String aptitude:List.of("swimming","resting")) {
c.runOnClient(m -> {
var buy=purchase(m.gui.screen(),aptitude);require(buy.active,"Four-level purchase available: "+aptitude);
buy.onPress(enter());
});c.waitFor(m -> ProgressionClient.progress.rank(aptitude)==1,200);c.waitTick();
}
c.runOnClient(m -> require(m.player.experienceLevel==0,"Two paid aptitudes cost eight levels; lying is free"));
for(String locale:List.of("en_us","fr_fr")) {
c.runOnClient(m -> {m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());ProgressionClient.open(null);m.gui.screen().mouseScrolled(m.gui.screen().width-40,m.gui.screen().height/2.0,0,-40);});c.waitTick();
c.runOnClient(m -> prices(m.gui.screen()));
c.takeScreenshot("sanctuary-beta017-aptitudes-"+locale);
}
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitForScreen(null);c.waitTicks(3);
c.getInput().pressKey(letterW);c.waitFor(m -> LyingService.posture(m.player)==1 && m.player.getPose()==Pose.SWIMMING,200);
require(server.computeOnServer(s -> LyingService.posture(s.getPlayerList().getPlayers().getFirst())==1),"W posture is confirmed by server attachment");
c.takeScreenshot("sanctuary-beta017-lying-W");c.getInput().pressKey(letterW);c.waitFor(m -> LyingService.posture(m.player)==0,200);
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();p.setHealth(2);p.getFoodData().setFoodLevel(6);p.getFoodData().setSaturation(0);});
c.waitFor(m -> m.player.getHealth()==2,200);
c.getInput().holdKey(o -> o.keyShift);c.getInput().pressKey(letterW);c.getInput().releaseKey(o -> o.keyShift);
c.waitFor(m -> LyingService.posture(m.player)==2,200);
c.waitFor(m -> m.player.getHealth()==3 && m.player.getFoodData().getFoodLevel()==5,400);
c.getInput().holdKey(o -> o.keyUp);c.waitFor(m -> LyingService.posture(m.player)==0,200);c.getInput().releaseKey(o -> o.keyUp);
c.runOnClient(m -> {KeyMapping.get("sanctuary.progression.lying_key").setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_G));KeyMapping.resetMapping();});c.waitTicks(5);
c.getInput().pressKey(InputConstants.KEY_G);c.waitFor(m -> LyingService.posture(m.player)==1,200);c.getInput().pressKey(InputConstants.KEY_G);c.waitFor(m -> LyingService.posture(m.player)==0,200);
c.getInput().holdKey(o -> o.keyShift);c.getInput().pressKey(InputConstants.KEY_G);c.getInput().releaseKey(o -> o.keyShift);
c.waitFor(m -> LyingService.posture(m.player)==2,200);
c.getInput().holdKey(o -> o.keyShift);c.getInput().pressKey(InputConstants.KEY_G);c.getInput().releaseKey(o -> o.keyShift);
c.waitFor(m -> LyingService.posture(m.player)==0,200);c.waitTicks(5);
c.runOnClient(m -> require(!m.player.isPassenger(),"Repeated Shift+posture does not trigger the separate double-Sneak sitting gesture"));
// Confirm the purchased aptitude restores actual native swimming, not just its server flag.
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();p.getFoodData().setFoodLevel(8);for(int x=-2;x<=2;x++)for(int z=-2;z<=2;z++)for(int y=1;y<=4;y++)p.level().setBlockAndUpdate(floor.offset(x,y,z),Blocks.WATER.defaultBlockState());p.teleportTo(floor.getX()+.5,floor.getY()+1,floor.getZ()+.5);});
c.waitFor(m -> m.player.isUnderWater() && m.player.getFoodData().getFoodLevel()==8,200);
c.getInput().holdKey(o -> o.keyUp);c.getInput().holdKey(o -> o.keySprint);c.waitFor(m -> m.player.isSwimming() && m.player.isSprinting(),200);
c.getInput().releaseKey(o -> o.keyUp);c.getInput().releaseKey(o -> o.keySprint);
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();for(int x=-4;x<=4;x++)for(int z=-4;z<=4;z++)for(int y=1;y<=5;y++)p.level().setBlockAndUpdate(floor.offset(x,y,z),Blocks.AIR.defaultBlockState());p.teleportTo(floor.getX()+.5,floor.getY()+1,floor.getZ()+.5);});
c.waitFor(m -> m.player.onGround() && !m.player.isInWater(),200);c.waitTicks(5);
// Native recipe data can remember an open book; every actual screen still omits it.
xpSounds.set(0);
c.runOnClient(m -> {for(var type:RecipeBookType.values())m.player.getRecipeBook().setOpen(type,true);m.gui.setScreen(new InventoryScreen(m.player));});c.waitTick();
c.runOnClient(m -> bookRemoved(m.gui.screen()));c.takeScreenshot("sanctuary-beta017-inventory-no-book");
c.waitFor(m -> (TutorialClient.completedMask&8)!=0 && xpSounds.get()==1,200);
c.runOnClient(m -> {for(int i=0;i<20;i++)net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking.send(new fr.koka.sanctuary.tutorial.TutorialPayloads.InventoryOpened());});
c.waitTicks(5);require(xpSounds.get()==1,"Replaying the first-step signal never replays its XP sound");
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();p.inventoryMenu.getSlot(1).set(new ItemStack(Items.OAK_LOG));p.inventoryMenu.broadcastChanges();});
c.waitFor(m -> m.player.inventoryMenu.getSlot(0).getItem().is(Items.OAK_PLANKS),200);
c.runOnClient(m -> {
var screen=(AbstractContainerScreen<?>)m.gui.screen();var a=(AbstractContainerScreenAccessor)screen;var slot=screen.getMenu().getSlot(0);var w=m.getWindow();
m.mouseHandler.onMove(w.handle(),(a.sanctuary$getLeftPos()+slot.x+8.0)*w.getScreenWidth()/w.getGuiScaledWidth(),(a.sanctuary$getTopPos()+slot.y+8.0)*w.getScreenHeight()/w.getGuiScaledHeight(),0,0);m.mouseHandler.handleAccumulatedMovement();
System.out.println("Recipe result pointer: "+(a.sanctuary$getLeftPos()+slot.x+8)+","+(a.sanctuary$getTopPos()+slot.y+8)+" slot="+slot.getItem());
});c.waitTick();c.takeScreenshot("sanctuary-beta017-craft-hover");
c.runOnClient(m -> {
var w=m.getWindow();m.mouseHandler.onButton(w.handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,0),1);m.mouseHandler.onButton(w.handle(),new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,0),0);
});
c.waitTicks(3);c.takeScreenshot("sanctuary-beta017-craft-click");
c.runOnClient(m -> System.out.println("Recipe click: carried="+m.player.inventoryMenu.getCarried()+" result="+m.player.inventoryMenu.getSlot(0).getItem()+" input="+m.player.inventoryMenu.getSlot(1).getItem()));
c.waitFor(m -> m.player.inventoryMenu.getCarried().is(Items.OAK_PLANKS),200);
c.waitFor(m -> xpSounds.get()==2,200);
c.runOnClient(m -> m.player.closeContainer());c.waitTicks(3);
for(var type:List.of(MenuType.CRAFTING,MenuType.FURNACE,MenuType.BLAST_FURNACE,MenuType.SMOKER)) {
server.runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();var at=floor.east();p.level().setBlockAndUpdate(at,Blocks.CRAFTING_TABLE.defaultBlockState());
p.openMenu(new SimpleMenuProvider((id,inv,owner) -> type==MenuType.CRAFTING ? new CraftingMenu(id,inv,ContainerLevelAccess.create(p.level(),at)) : type.create(id,inv),Component.literal("Recipe screen")));});
c.waitFor(m -> m.gui.screen() instanceof AbstractRecipeBookScreen<?>,200);
for(int gui:new int[]{0,1}) {c.runOnClient(m -> {m.options.guiScale().set(gui);m.resizeGui();});c.waitTick();c.runOnClient(m -> bookRemoved(m.gui.screen()));}
c.takeScreenshot("sanctuary-beta017-no-book-"+type.toString().replaceAll("[^a-zA-Z0-9_-]","_"));
c.runOnClient(m -> m.player.closeContainer());c.waitForScreen(null);c.waitTicks(3);
}
System.out.println("Movement017 native client passed: skill prices, slow water movement, buoyancy, purchases, layout-aware W/Shift+W, ten-second rest, remapping, five bookless screens, manual crafting and once-only first-step XP sounds");
} finally {
armed.set(false);c.getInput().releaseKey(o -> o.keyUp);c.getInput().releaseKey(o -> o.keySprint);c.getInput().releaseKey(o -> o.keyJump);c.getInput().releaseKey(o -> o.keyShift);
c.runOnClient(m -> m.getSoundManager().removeListener(soundListener));
c.runOnClient(m -> {m.options.keyUp.setKey(forward);KeyMapping.get("sanctuary.progression.lying_key").setKey(previous);KeyMapping.resetMapping();m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(scale);m.resizeGui();});
}
}
private static void prices(Screen screen) {
var all=widgets(screen).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).toList();
for(String skill:ProgressRecord.SKILLS) {
var name=all.stream().filter(w -> key(w,"sanctuary.progression."+skill)).findFirst().orElseThrow();
var price=all.stream().filter(w -> w.getY()==name.getY() && (key(w,"sanctuary.progression.next_cost_short") || key(w,"sanctuary.progression.rank_max_short"))).findFirst().orElseThrow();
int cost=ProgressionClient.progress.cost(skill,ProgressionClient.config);
require(price.getMessage().getString().equals(ProgressionService.label(cost<0 ? "rank_max_short" : "next_cost_short",cost).getString()),"Each skill displays its next server price");
require(all.stream().noneMatch(w -> w.getY()==name.getY() && w.getMessage().getString().matches("\\d+/\\d+")),"No redundant skill fraction");
}
}
private static void bookRemoved(Screen screen) {
require(screen instanceof AbstractRecipeBookScreen<?>,"Native recipe screen expected");
require(widgets(screen).noneMatch(RecipeBookComponent.class::isInstance),"No focusable recipe panel");
require(widgets(screen).noneMatch(ImageButton.class::isInstance),"No native recipe toggle button");
try {var field=AbstractRecipeBookScreen.class.getDeclaredField("recipeBookComponent");field.setAccessible(true);var book=(RecipeBookComponent<?>)field.get(screen);book.toggleVisibility();require(!book.isVisible(),"Old state or toggle cannot reopen the native book");}
catch(ReflectiveOperationException e) {throw new AssertionError(e);}
}
private static Button purchase(Screen screen,String aptitude) {
var name=widgets(screen).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w -> key(w,"sanctuary.progression."+aptitude)).findFirst().orElseThrow();
return widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(b -> b.getX()==name.getX() && b.getY()>name.getY()).min(Comparator.comparingInt(Button::getY)).orElseThrow();
}
private static Stream<GuiEventListener> widgets(GuiEventListener widget) {return Stream.concat(Stream.of(widget),widget instanceof ContainerEventHandler parent ? parent.children().stream().flatMap(Movement017ClientChecks::widgets) : Stream.empty());}
private static boolean key(AbstractWidget widget,String key) {return widget.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key);}
private static Button button(Screen screen,String key) {return widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(b -> key(b,key)).findFirst().orElseThrow();}
private static KeyEvent enter() {return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean ok,String message) {if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,112 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.knowledge.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.recipes.*;
import fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor;
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.minecraft.client.Minecraft;
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.inventory.*;
import net.minecraft.client.gui.screens.options.OptionsScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.*;
import net.minecraft.network.chat.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
/** Real HUD, options persistence, native advancement progress, and enlarged hotbar input. */
public final class Notifications034ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var armed=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(armed.get()&&m.gui.screen() instanceof HelloWorldScreen 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());armed.set(false);
}});
c.runOnClient(m->{var config=NotificationClient.options();config.discoveries=true;config.collections=true;config.advancements=true;config.seconds=5;});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sanctuary notifications034 "+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()&&RecipeClient.ready&&m.gui.screen()==null,1200);
var server=world.getServer();c.runOnClient(m->{m.player.setXRot(-90);m.options.guiScale().set(2);m.resizeGui();});c.waitTicks(25);c.runOnClient(m->NotificationClient.clearFeed());
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();BlockKnowledgeService.recordSeen(p,Blocks.AMETHYST_BLOCK);});
c.waitFor(m->NotificationClient.messages().stream().anyMatch(e->e.key().equals("block:minecraft:amethyst_block")),100);
c.runOnClient(m->{require(NotificationClient.visible(),"Discovery HUD is visible without F3");m.gui.setScreen(new PauseScreen(true));
require(!hasButton(m.gui.screen(),"sanctuary.cycle.faction")&&!hasButton(m.gui.screen(),"sanctuary.cycle.log"),"Factions and History removed from pause");
button(m.gui.screen(),"sanctuary.progression.progress").onPress(enter());
button(m.gui.screen(),"sanctuary.cycle.entry").onPress(enter());});
c.waitFor(m->m.gui.screen() instanceof CycleFactionScreen&&hasButton(m.gui.screen(),"sanctuary.cycle.faction"),100);
c.runOnClient(m->{button(m.gui.screen(),"sanctuary.cycle.faction").onPress(enter());button(m.gui.screen(),"sanctuary.cycle.log").onPress(enter());require(!button(m.gui.screen(),"sanctuary.cycle.log").active,"Both remain reachable through Progression");});
c.waitTicks(125);c.runOnClient(m->{require(!NotificationClient.messages().isEmpty(),"Unread messages wait while a menu is open");m.gui.setScreen(null);});
c.getInput().pressKey(InputConstants.KEY_F1);c.runOnClient(m->require(!NotificationClient.visible(),"F1 hides discovery HUD"));c.getInput().pressKey(InputConstants.KEY_F1);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().setItem(0,new ItemStack(Items.AMETHYST_BLOCK,3));p.getInventory().setItem(1,new ItemStack(Items.OAK_PLANKS,32));p.getInventory().setItem(2,new ItemStack(Items.DIAMOND));BlockKnowledgeService.observeInventory(p);RecipeService.refresh(p);p.inventoryMenu.broadcastChanges();});
c.waitFor(m->NotificationClient.messages().stream().anyMatch(e->e.key().equals("recipes")),150);
c.runOnClient(m->{require(NotificationClient.messages().size()<=5,"HUD line count is bounded");require(NotificationClient.messages().stream().anyMatch(e->e.key().equals("item:minecraft:diamond")),"Non-block item has its discovery notice");});
c.takeScreenshot("sanctuary-beta034-discoveries-en");
// The real native advancement criteria feed the tracker; no synthetic quest is installed.
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();
for(String id:List.of("husbandry/root","husbandry/plant_seed")){var holder=s.getAdvancements().get(Identifier.withDefaultNamespace(id));for(String criterion:holder.value().criteria().keySet())p.getAdvancements().award(holder,criterion);}
var holder=s.getAdvancements().get(Identifier.withDefaultNamespace("husbandry/balanced_diet"));holder.value().criteria().keySet().stream().sorted().limit(2).forEach(criterion->p.getAdvancements().award(holder,criterion));
});
c.waitFor(m->!NotificationClient.tracked().isEmpty(),200);c.runOnClient(m->require(NotificationClient.tracked().stream().allMatch(t->t.fraction()>0&&t.fraction()<1),"Tracker only shows native partially completed objectives"));c.takeScreenshot("sanctuary-beta034-advancements");
c.runOnClient(m->m.gui.setScreen(new OptionsScreen(null,m.options)));
for(int scale:List.of(1,2,3,4)){c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();var b=button(m.gui.screen(),"sanctuary.notifications.title");require(b.getY()>=0&&b.getY()+b.getHeight()<=m.gui.screen().height,"Options entry fits GUI scale");});c.waitTick();}
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();button(m.gui.screen(),"sanctuary.notifications.title").onPress(enter());});c.takeScreenshot("sanctuary-beta034-options-en");
c.runOnClient(m->{option(m.gui.screen(),"sanctuary.notifications.discoveries").onPress(enter());require(!NotificationClient.options().discoveries&&!NotificationOptions.load().discoveries,"Option persists to disk");m.gui.setScreen(null);});
server.runOnServer(s->BlockKnowledgeService.recordSeen(s.getPlayerList().getPlayers().getFirst(),Blocks.DIAMOND_BLOCK));c.waitTicks(30);
c.runOnClient(m->{require(NotificationClient.messages().isEmpty(),"Disabled feed discards live notices");m.gui.setScreen(new NotificationOptionsScreen(null));option(m.gui.screen(),"sanctuary.notifications.discoveries").onPress(enter());m.gui.setScreen(null);});c.waitTicks(15);
c.runOnClient(m->require(NotificationClient.messages().isEmpty(),"Re-enabling does not replay disabled notices"));
for(String locale:List.of("fr_fr","en_us")){
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.options.languageCode=locale;m.reloadResourcePacks();});c.waitTicks(60);
c.runOnClient(m->{m.gui.setScreen(new NotificationOptionsScreen(null));for(var widget:widgets(m.gui.screen()).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).toList())require(!widget.getMessage().getString().contains("sanctuary.notifications."),"No untranslated options");});c.takeScreenshot("sanctuary-beta034-options-"+locale);c.runOnClient(m->m.gui.setScreen(null));
}
c.runOnClient(m->{NotificationClient.options().seconds=3;NotificationClient.clearFeed();});
server.runOnServer(s->BlockKnowledgeService.recordSeen(s.getPlayerList().getPlayers().getFirst(),Blocks.EMERALD_BLOCK));c.waitFor(m->!NotificationClient.messages().isEmpty(),100);c.waitTicks(80);c.runOnClient(m->require(NotificationClient.messages().isEmpty(),"Visible messages automatically expire"));
hotbar(c,server);
System.out.println("HUD034 native passed: discoveries, grouped recipes, expiry, F1/menu hiding, persisted options, native advancement tracker, compact pause, enlarged hotbar input and six rows.");
}finally{c.runOnClient(m->{var config=NotificationClient.options();config.discoveries=true;config.collections=true;config.advancements=true;config.seconds=5;config.save();});}
}
private static void hotbar(ClientGameTestContext c,TestServerContext server){
c.runOnClient(m->m.gui.setScreen(new InventoryScreen(m.player)));c.waitTick();
c.runOnClient(m->geometry(m));c.takeScreenshot("sanctuary-beta034-hotbar-one-row");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var progress=ProgressEffects.get(p);while(progress.inventory()<5)progress=progress.buy("inventory",1);p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);p.getInventory().setItem(0,new ItemStack(Items.STONE,5));p.getInventory().setItem(8,new ItemStack(Items.APPLE,16));p.inventoryMenu.broadcastChanges();ProgressionService.send(p,"");});
c.waitFor(m->ProgressionClient.progress.inventory()==5,200);c.runOnClient(m->m.gui.setScreen(new InventoryScreen(m.player)));c.waitTicks(3);c.runOnClient(m->geometry(m));c.takeScreenshot("sanctuary-beta034-hotbar-six-rows");
// Click the newly enlarged right edge of the last cell, beyond the old 16px item box.
c.runOnClient(m->edgeClick(m,44,0));c.waitFor(m->m.player.containerMenu.getCarried().is(Items.APPLE),100);c.runOnClient(m->edgeClick(m,44,0));c.waitFor(m->m.player.containerMenu.getCarried().isEmpty(),100);
// Shift-click uses the same enlarged target and native quick transfer.
c.runOnClient(m->edgeClick(m,44,1));c.waitFor(m->m.player.getInventory().getItem(8).isEmpty(),100);
c.getInput().pressKey(InputConstants.KEY_TAB);c.waitTicks(12);c.runOnClient(m->geometry(m));c.takeScreenshot("sanctuary-beta034-hotbar-moved");
c.runOnClient(m->{var screen=m.gui.screen();var reverse=new KeyEvent(InputConstants.KEY_TAB,0,1);
net.fabricmc.fabric.api.client.screen.v1.ScreenKeyboardEvents.allowKeyPress(screen).invoker().allowKeyPress(screen,reverse);
net.fabricmc.fabric.api.client.screen.v1.ScreenKeyboardEvents.afterKeyRelease(screen).invoker().afterKeyRelease(screen,reverse);});
c.waitFor(m->fr.koka.sanctuary.inventory.HotbarRows.selected(m.player)==0,100);
Inventory013WheelChecks.run(c,server);
}
private static void geometry(Minecraft m){
var screen=(InventoryScreen)m.gui.screen();var menu=screen.getMenu();var hot=menu.slots.get(36);
require(menu.slots.get(44).x-hot.x==160,"Nine hotbar cells use the native 20px spacing");
var canvas=(AbstractContainerScreenAccessor)screen;
require(hot.x>=0&&menu.slots.get(44).x+18<=208,"Expanded hotbar fits the inventory width");
for(var slot:menu.slots)if(slot.x>=0&&slot.y>=120&&slot!=hot&&slot.y!=hot.y)require(slot.y+16<=hot.y-1||slot.y>=hot.y+19,"Moving row reserves space for the taller hotbar");
}
private static void edgeClick(Minecraft m,int index,int mods){
var screen=(InventoryScreen)m.gui.screen();var canvas=(AbstractContainerScreenAccessor)screen;var slot=screen.getMenu().slots.get(index);
var event=new MouseButtonEvent(canvas.sanctuary$getLeftPos()+slot.x+17.5,canvas.sanctuary$getTopPos()+slot.y+8,new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT,mods));screen.mouseClicked(event,false);screen.mouseReleased(event);
}
private static CycleButton<?> option(Screen s,String key){String prefix=Component.translatable(key).getString();return widgets(s).filter(CycleButton.class::isInstance).map(CycleButton.class::cast).filter(b->b.getMessage().getString().startsWith(prefix)).findFirst().orElseThrow();}
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 Button button(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(()->new AssertionError("Missing "+key));}
private static boolean hasButton(Screen s,String key){return widgets(s).filter(Button.class::isInstance).map(Button.class::cast).anyMatch(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key));}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,122 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.client.shader.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.operator.OperatorService;
import fr.koka.sanctuary.stars.*;
import fr.koka.sanctuary.inventory.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
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.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.network.chat.Component;
import net.minecraft.core.component.DataComponents;
import net.minecraft.world.item.*;
public final class Operator040ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
// Reproduce the report's exact cold-client state before any integrated server binds components.
c.runOnClient(m->{
require(m.level==null&&m.player==null,"Cold welcome precedes world admission");
boolean unbound=false;try{new ItemStack(Items.COW_SPAWN_EGG);}catch(NullPointerException expected){unbound=expected.getMessage().equals("Components not bound yet");}
require(unbound,"Cold client reproduces the old ItemStack failure");
var choices=List.of(new StarterCompanions.Choice("cow",fr.koka.sanctuary.companion.CompanionType.Rarity.COMMON),new StarterCompanions.Choice("chicken",fr.koka.sanctuary.companion.CompanionType.Rarity.COMMON),new StarterCompanions.Choice("bee",fr.koka.sanctuary.companion.CompanionType.Rarity.UNCOMMON));
var colors=java.util.stream.IntStream.range(0,20).map(i->0x604040+i*0x050709).boxed().toList();
m.gui.setScreen(new HelloWorldScreen(new StarterPayloads.Welcome(new ProgressionPayloads.Hello("Cold multiplayer",colors,""),"",false,choices),()->{}));
});
c.waitTicks(12);c.takeScreenshot("sanctuary-beta040-cold-multiplayer-welcome");
c.runOnClient(m->{require(m.gui.screen() instanceof HelloWorldScreen,"Unbound welcome renders without ItemStack access");Starter039TestSupport.selectFirst((HelloWorldScreen)m.gui.screen());m.gui.setScreen(new TitleScreen());});
var onboarding=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(onboarding.get()&&m.gui.screen() instanceof HelloWorldScreen s){
Starter039TestSupport.selectFirst(s);
widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
widgets(s).filter(EditBox.class::isInstance).map(EditBox.class::cast).findFirst().ifPresent(b->b.setValue("Explorer les îles avec mon compagnon."));
click(s,"Enter Sanctuary");onboarding.set(false);
}});
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
int[] remembered={0};String[] rememberedRecipes={null};
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sanctuary operator040 "+UUID.randomUUID());s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setAllowCommands(false);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){
saved=world.getWorldSave();world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&RecipeClient.ready&&m.gui.screen()==null,1200);
var server=world.getServer();
c.waitFor(m->StarClient.starCount()>=1,100);c.runOnClient(m->{require(!ProgressionClient.operator&&!RecipeClient.operator,"Commands OFF starts in normal mode");m.gui.setScreen(new ProgressionScreen(null));});
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var r=ProgressRecord.INITIAL.buy("vitality",1);p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);ProgressionService.send(p,"");p.setExperienceLevels(20);});
c.waitTicks(6);c.runOnClient(m->require(widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).noneMatch(b->b.visible&&b.getMessage().getString().equals("")),"No operator refund controls in normal play"));
c.takeScreenshot("sanctuary-beta040-normal-progression");
c.runOnClient(m->m.gui.setScreen(new WorldOptionsScreen(new PauseScreen(true),m.level)));c.waitTicks(4);
c.runOnClient(m->{require(!widget(m.gui.screen(),"Default Game Mode").visible,"Normal World Options hides operator game mode");click(m.gui.screen(),"Allow Commands");click(m.gui.screen(),"Apply Changes");});
c.waitFor(m->ProgressionClient.operator&&RecipeClient.operator,150);
require(server.computeOnServer(s->OperatorService.allowed(s.getPlayerList().getPlayers().getFirst())),"Native toggle authorizes server operator actions");
c.runOnClient(m->m.gui.setScreen(new ProgressionScreen(null)));c.waitTicks(6);
c.runOnClient(m->require(widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).anyMatch(b->b.visible&&b.active&&b.getMessage().getString().equals("")),"Operator refund control available immediately"));
c.takeScreenshot("sanctuary-beta040-operator-progression");
c.runOnClient(m->m.gui.setScreen(new SanctuaryBlocodexScreen(null)));c.waitTicks(30);c.takeScreenshot("sanctuary-beta040-operator-discovery");
c.runOnClient(m->m.gui.setScreen(new SanctuaryAtlasScreen(null)));c.waitTicks(35);c.takeScreenshot("sanctuary-beta040-operator-atlas");
c.runOnClient(m->m.gui.setScreen(new WorldOptionsScreen(m.gui.screen(),m.level)));c.waitTicks(4);
c.runOnClient(m->{click(m.gui.screen(),"Allow Commands");click(m.gui.screen(),"Apply Changes");});
c.waitFor(m->!ProgressionClient.operator&&!RecipeClient.operator,150);c.waitTicks(6);
require(server.computeOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();return !OperatorService.allowed(p)&&!ProgressionService.refund(p,"vitality",ProgressEffects.get(p).revision());}),"Revoked operator cannot refund server-side");
c.takeScreenshot("sanctuary-beta040-normal-atlas");
for(var item:List.of(Items.CAT_SPAWN_EGG,Items.PARROT_SPAWN_EGG,Items.GHAST_SPAWN_EGG,Items.ENDER_DRAGON_SPAWN_EGG)){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var egg=new ItemStack(item);egg.set(DataComponents.CUSTOM_NAME,Component.literal("Azimut"));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.inventoryMenu.broadcastChanges();});
c.waitFor(m->AccessoryAccess.of(m.player).getItem(AccessoryContainer.FAMILIAR).is(item),150);
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();CompanionClient.openInhabitant(new PauseScreen(true));});c.waitTicks(8);
c.runOnClient(m->{var s=m.gui.screen();require(widgets(s).anyMatch(InhabitantCompanionWidget.class::isInstance),"Native companion preview exists");var skin=widgets(s).filter(PlayerSkinWidget.class::isInstance).map(PlayerSkinWidget.class::cast).findFirst().orElseThrow();require(skin.getHeight()<=skin.getWidth()*4/3,"Portrait fit includes width, no cropped arms");var pet=widgets(s).filter(InhabitantCompanionWidget.class::isInstance).map(InhabitantCompanionWidget.class::cast).findFirst().orElseThrow();require(skin.getRight()<s.width/2&&pet.getX()>s.width/2,"Character left, familiar right, profile sections between");require(widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).noneMatch(w->w.getMessage().getString().contains("Rename its egg")),"No extra help prose on inhabitant page");require(widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w->w.getMessage().getString().contains("Azimut")),"Custom familiar name appears on inhabitant sheet");});
c.takeScreenshot("sanctuary-beta040-inhabitant-"+net.minecraft.core.registries.BuiltInRegistries.ITEM.getKey(item).getPath());
}
c.runOnClient(m->{m.options.guiScale().set(1);m.resizeGui();m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());CompanionClient.openInhabitant(null);});c.waitTicks(8);c.takeScreenshot("sanctuary-beta040-inhabitant-fr-scale1");
c.runOnClient(m->m.gui.setScreen(null));
for(int armor:new int[]{1,7,20}){
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,ItemStack.EMPTY);
p.setItemSlot(net.minecraft.world.entity.EquipmentSlot.HEAD,new ItemStack(armor==20?Items.DIAMOND_HELMET:Items.LEATHER_HELMET));
p.setItemSlot(net.minecraft.world.entity.EquipmentSlot.CHEST,armor==1?ItemStack.EMPTY:new ItemStack(armor==20?Items.DIAMOND_CHESTPLATE:Items.IRON_CHESTPLATE));
p.setItemSlot(net.minecraft.world.entity.EquipmentSlot.LEGS,armor==20?new ItemStack(Items.DIAMOND_LEGGINGS):ItemStack.EMPTY);
p.setItemSlot(net.minecraft.world.entity.EquipmentSlot.FEET,armor==20?new ItemStack(Items.DIAMOND_BOOTS):ItemStack.EMPTY);p.inventoryMenu.broadcastChanges();});
c.waitFor(m->m.player.getArmorValue()==armor,100);c.runOnClient(m->Inventory012RenderProbe.reset());c.waitTicks(5);
c.runOnClient(m->{var sprites=Inventory012RenderProbe.sprites;require(sprites.stream().noneMatch(p->p.id().equals("minecraft:hud/armor_empty")),"No empty armor icons");require(sprites.stream().filter(p->p.id().equals("minecraft:hud/armor_full")).count()==armor/2,"Exactly the full armor points");require(sprites.stream().filter(p->p.id().equals("minecraft:hud/armor_half")).count()==armor%2,"Half armor preserved");});
c.takeScreenshot("sanctuary-beta040-armor-"+armor);
}
server.runOnServer(s->{try{fr.koka.sanctuary.realtime.RealtimeService.setMode(s,false);}catch(Exception e){throw new RuntimeException(e);}s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");var p=s.getPlayerList().getPlayers().getFirst();var at=p.blockPosition().south(7);
var blocks=List.of("red_wool","oak_leaves","oak_log","white_wool","blue_wool").stream().map(id->net.minecraft.core.registries.BuiltInRegistries.BLOCK.getValue(net.minecraft.resources.Identifier.withDefaultNamespace(id))).toList();
for(int i=0;i<blocks.size();i++)for(int y=0;y<3;y++)p.level().setBlockAndUpdate(at.offset(i-2,y,0),blocks.get(i).defaultBlockState());
});
c.runOnClient(m->{m.player.setXRot(5);m.player.setYRot(0);VanillaLight.setEnabled(true);m.gui.setScreen(new net.minecraft.client.gui.screens.options.OptionsScreen(null,m.options));});c.waitTicks(6);
c.runOnClient(m->click(m.gui.screen(),"Shaders"));c.waitTicks(5);c.takeScreenshot("sanctuary-beta040-shader-options");
c.runOnClient(m->{click(m.gui.screen(),"Sanctuary · Vanilla Light");require(!VanillaLight.enabled(),"Native shader option toggles off");m.gui.setScreen(null);});
c.waitFor(m->!m.gameRenderer.getAppliedPostEffects().contains(VanillaLight.EFFECT),100);c.waitTicks(6);c.takeScreenshot("sanctuary-beta040-shader-off");
c.runOnClient(m->{m.gui.setScreen(new ShaderOptionsScreen(null));click(m.gui.screen(),"Sanctuary · Vanilla Light");require(VanillaLight.enabled(),"Native shader option toggles on");m.gui.setScreen(null);});
c.waitFor(m->m.gameRenderer.getAppliedPostEffects().contains(VanillaLight.EFFECT),100);c.waitTicks(6);c.takeScreenshot("sanctuary-beta040-shader-on");
var reload=c.computeOnClient(m->m.reloadResourcePacks());c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();
c.waitFor(m->m.gameRenderer.getAppliedPostEffects().contains(VanillaLight.EFFECT),100);
server.runOnServer(s->{s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set midnight");var p=s.getPlayerList().getPlayers().getFirst();for(var holder:s.getAdvancements().getAllAdvancements())if(holder.value().display().isPresent())for(var key:holder.value().criteria().keySet())p.getAdvancements().award(holder,key);fr.koka.sanctuary.recipes.RecipeService.refresh(p);rememberedRecipes[0]=fr.koka.sanctuary.recipes.RecipeService.knowledge(p).json();remembered[0]=StarService.ledger(s).starCount();s.saveEverything(true,true,true);});
c.waitFor(m->StarClient.starCount()==remembered[0],200);
c.runOnClient(m->{m.player.setXRot(-75);m.player.setYRot(30);Star040RenderProbe.reset();});c.getInput().pressKey(InputConstants.KEY_F1);c.waitTicks(15);c.takeScreenshot("sanctuary-beta040-earned-stars");
c.runOnClient(m->require(Star040RenderProbe.draws>0&&Star040RenderProbe.indices==StarClient.starCount()*6,"Native shader star pass renders exactly the earned geometry"));
long revision=c.computeOnClient(m->StarClient.revision());c.waitTicks(35);c.runOnClient(m->{require(StarClient.revision()==revision,"Unchanged sky does not rebuild its mesh every frame");});
c.runOnClient(m->{Star040RenderProbe.reset();StarClient.accept(new StarPayload(true,System.currentTimeMillis(),List.of()));});c.waitTicks(5);c.takeScreenshot("sanctuary-beta040-empty-sky");
c.runOnClient(m->require(Star040RenderProbe.draws>0&&Star040RenderProbe.indices==0,"An empty sky keeps native pass hooks but draws no decorative stars"));c.getInput().pressKey(InputConstants.KEY_F1);
}
try(var world=saved.open()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&StarClient.starCount()==remembered[0],1200);
c.runOnClient(m->require(!ProgressionClient.operator,"Commands OFF survives world reload"));
require(world.getServer().computeOnServer(s->StarService.ledger(s).starCount()==remembered[0]),"Reconnect and advancement backfill do not duplicate stars");
require(world.getServer().computeOnServer(s->fr.koka.sanctuary.recipes.RecipeService.knowledge(s.getPlayerList().getPlayers().getFirst()).json().equals(rememberedRecipes[0])),"Complete recipe catalogue survives native player save and reconnect");
}
System.out.println("OPERATOR040_CLIENT_PASS: native World Options toggle, immediate authorization/revocation, normal hidden controls, companion previews and saved shared sky");
System.out.println("SHADER040_CLIENT_PASS: native options, GPU post effect on/off, resource reload, earned/empty native star pass");
System.out.println("MULTIPLAYER040_CLIENT_PASS: cold unbound-component welcome; integrated reconnect preserves full recipe catalogue; dedicated network test not run");
}
private static Stream<GuiEventListener> widgets(ContainerEventHandler root){return root.children().stream().flatMap(w->w instanceof ContainerEventHandler child?Stream.concat(Stream.of(w),widgets(child)):Stream.of(w));}
private static AbstractWidget widget(Screen s,String prefix){return widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->w.getMessage().getString().startsWith(prefix)).findFirst().orElseThrow(()->new AssertionError("Missing "+prefix+": "+widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).map(w->w.getMessage().getString()).toList()));}
private static void click(Screen s,String prefix){var w=widget(s,prefix);require(w.visible&&w.active,"Control available: "+prefix);if(w instanceof Button b)b.onPress(enter());else if(w instanceof CycleButton<?> b)b.onPress(enter());else throw new AssertionError("Not a button: "+prefix);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,108 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.boats.*;
import fr.koka.sanctuary.client.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
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.minecraft.client.CameraType;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.Vec3;
public final class Poultry048ClientChecks 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);
widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b,"sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());onboarding.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{
s.setName("Poultry048 "+UUID.randomUUID());s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setAllowCommands(true);
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);
for(String locale:new String[]{"en_us","fr_fr"})c.runOnClient(m->{
m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
for(var v:CrewBoats.variants()){
var boat=v.type().create(m.level,EntitySpawnReason.COMMAND);
require(m.getEntityRenderDispatcher().getRenderer(boat)!=null,"Native renderer for "+v.wood());
var mesh=TiledBoatModel.hull(v.columns(),v.rows(),v.raft()).bakeRoot();
require(mesh.getAllParts().size()==v.tiles()+3+(v.raft()?0:2*(v.columns()+v.rows())+4),"One floor per cell, only exterior rails and two paddles");
require(mesh.getAllParts().stream().allMatch(part->part.xScale==1&&part.yScale==1&&part.zScale==1),"No stretched model part");
require(mesh.hasChild("left_paddle")&&mesh.hasChild("right_paddle"),"Exactly one native pair of paddles");
require(TiledBoatModel.waterMask(v.columns(),v.rows()).bakeRoot().getAllParts().size()==v.tiles()+1,"Water mask covers every floor tile");
require(!new ItemStack(v.item()).getHoverName().getString().startsWith("item.sanctuary"),"Localized item name "+locale);
}
});
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(),"sanctuary time vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");
var boat=CrewBoats.variant("oak",2).type().create(s.overworld(),EntitySpawnReason.COMMAND);boat.setPos(p.position().add(0,30,0));boat.setYRot(0);
s.overworld().addFreshEntity(boat);var chicken=EntityTypes.CHICKEN.create(s.overworld(),EntitySpawnReason.COMMAND);chicken.setPos(boat.position());chicken.setNoAi(true);s.overworld().addFreshEntity(chicken);chicken.startRiding(boat);p.startRiding(boat,true,true);
});
c.waitFor(m->m.player.getVehicle() instanceof CrewBoat b&&b.columns()==2&&b.rows()==2,200);
c.getInput().holdKey(o->o.keyJump);c.getInput().holdKey(o->o.keyUp);c.waitTicks(35);
Vec3 initial=c.computeOnClient(m->m.player.getVehicle().position());c.waitTicks(110);Vec3 top=c.computeOnClient(m->m.player.getVehicle().position());
require(top.y-initial.y>12&&top.y-initial.y<23,"Collective chicken boat climbs after native key inputs and sustained flight validation");
require(top.subtract(initial).horizontalDistance()>25,"Native forward key moves server-authoritative boat");
require(server.computeOnServer(s->s.getPlayerList().getPlayers().getFirst().getVehicle() instanceof CrewBoat b&&b.getControllingPassenger()==null&&b.orders().forward()==1),"Server receives direct pilot input without a client owner");
c.runOnClient(m->m.options.setCameraType(CameraType.THIRD_PERSON_BACK));c.getInput().lookAt(25,20);c.waitTicks(8);c.takeScreenshot("sanctuary-beta048-2x2-flight");
c.getInput().releaseKey(o->o.keyUp);c.getInput().releaseKey(o->o.keyJump);c.waitTicks(30);
double downStart=c.computeOnClient(m->m.player.getVehicle().getY());c.waitTicks(70);double bottom=c.computeOnClient(m->m.player.getVehicle().getY());
require(downStart-bottom>17,"Releasing Space descends under native server ticks");
c.getInput().holdKey(o->o.keyJump);c.getInput().holdKey(o->o.keyRight);float yaw=c.computeOnClient(m->m.player.getVehicle().getYRot());c.waitTicks(30);
require(Math.abs(c.computeOnClient(m->m.player.getVehicle().getYRot())-yaw)>45,"Native right key steers collective boat");c.getInput().releaseKey(o->o.keyRight);
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var old=p.getVehicle();Vec3 at=old.position();p.stopRiding();old.getSelfAndPassengers().toList().reversed().forEach(Entity::discard);
var boat=CrewBoats.variant("bamboo",3).type().create(s.overworld(),EntitySpawnReason.COMMAND);boat.setPos(at);s.overworld().addFreshEntity(boat);
for(var type:List.of(EntityTypes.CHICKEN,EntityTypes.BAT,EntityTypes.PARROT,EntityTypes.BEE,EntityTypes.BLAZE,EntityTypes.ALLAY,EntityTypes.BREEZE)){
var motor=type.create(s.overworld(),EntitySpawnReason.COMMAND);motor.setPos(at);((Mob)motor).setNoAi(true);s.overworld().addFreshEntity(motor);motor.startRiding(boat);
}p.startRiding(boat,true,true);
});
c.waitFor(m->m.player.getVehicle() instanceof CrewBoat b&&b.columns()==3&&b.rows()==3&&b.getPassengers().size()==8,200);c.waitTicks(20);
double mixedStart=c.computeOnClient(m->m.player.getVehicle().getY());c.waitTicks(40);
require(c.computeOnClient(m->m.player.getVehicle().getY())-mixedStart>15,"Mixed motors lift 3x3 raft");
c.getInput().lookAt(30,35);c.waitTicks(8);c.takeScreenshot("sanctuary-beta048-3x3-seven-motors");
// The ordinary boat still uses native client ownership; exercise the separate motor-control path.
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var old=p.getVehicle();Vec3 at=old.position();p.stopRiding();old.getSelfAndPassengers().toList().reversed().forEach(Entity::discard);
var boat=EntityTypes.OAK_BOAT.create(s.overworld(),EntitySpawnReason.COMMAND);boat.setPos(at);boat.setYRot(0);s.overworld().addFreshEntity(boat);
var bat=EntityTypes.BAT.create(s.overworld(),EntitySpawnReason.COMMAND);bat.setPos(at);bat.setNoAi(true);s.overworld().addFreshEntity(bat);bat.startRiding(boat);p.startRiding(boat,true,true);
});
c.waitFor(m->m.player.getVehicle() instanceof net.minecraft.world.entity.vehicle.boat.AbstractBoat b&&!(b instanceof CrewBoat)&&b.getControllingPassenger()==m.player,200);
c.getInput().holdKey(o->o.keyUp);c.waitTicks(30);Vec3 batStart=c.computeOnClient(m->m.player.getVehicle().position());c.waitTicks(60);Vec3 batEnd=c.computeOnClient(m->m.player.getVehicle().position());
require(batEnd.y-batStart.y>8&&batEnd.subtract(batStart).horizontalDistance()>12,"Native ordinary bat boat receives forward and lift controls");
c.getInput().releaseKey(o->o.keyJump);
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();var old=p.getVehicle();var at=old.position();p.stopRiding();old.getSelfAndPassengers().toList().reversed().forEach(Entity::discard);
var ghast=EntityTypes.HAPPY_GHAST.create(s.overworld(),EntitySpawnReason.COMMAND);ghast.setPos(at);ghast.setYRot(0);
ghast.setItemSlot(EquipmentSlot.BODY,new ItemStack(net.minecraft.core.registries.BuiltInRegistries.ITEM.getValue(net.minecraft.resources.Identifier.withDefaultNamespace("white_harness"))));
s.overworld().addFreshEntity(ghast);p.startRiding(ghast,true,true);
});
c.waitFor(m->m.player.getVehicle() instanceof net.minecraft.world.entity.animal.happyghast.HappyGhast g&&g.getControllingPassenger()==m.player&&CrewBoats.boosted(g),300);
c.getInput().lookAt(0,0);c.waitTicks(60);Vec3 ghastFastStart=c.computeOnClient(m->m.player.getVehicle().position());c.waitTicks(50);double fast=c.computeOnClient(m->m.player.getVehicle().position().subtract(ghastFastStart).horizontalDistance());
server.runOnServer(s->{var g=(net.minecraft.world.entity.animal.happyghast.HappyGhast)s.getPlayerList().getPlayers().getFirst().getVehicle();g.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.FLYING_SPEED).removeModifier(fr.koka.sanctuary.SanctuaryMod.id("happy_ghast_speed"));});
c.waitFor(m->m.player.getVehicle() instanceof net.minecraft.world.entity.animal.happyghast.HappyGhast g&&!CrewBoats.boosted(g),100);c.waitTicks(60);
Vec3 ghastSlowStart=c.computeOnClient(m->m.player.getVehicle().position());c.waitTicks(50);double slow=c.computeOnClient(m->m.player.getVehicle().position().subtract(ghastSlowStart).horizontalDistance());
require(slow>1&&fast/slow>1.85&&fast/slow<2.15,"Happy Ghast native ridden speed doubles after synced modifier: "+fast/slow);
System.out.println("POULTRY048_CLIENT_PASS: 22 tiled native-texture renderers and FR/EN names, actual key packets, collective server-authoritative flight beyond watchdog, forward/steering/ascent/descent, mixed seven-motor raft, ordinary bat boat, Happy Ghast measured x2 speed and screenshots");
}finally{onboarding.set(false);c.getInput().releaseKey(o->o.keyJump);c.getInput().releaseKey(o->o.keyUp);c.getInput().releaseKey(o->o.keyRight);}
}
private static boolean key(Button b,String key){return b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key);}
private static Stream<GuiEventListener> widgets(ContainerEventHandler root){return root.children().stream().flatMap(w->w instanceof ContainerEventHandler child?Stream.concat(Stream.of(w),widgets(child)):Stream.of(w));}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean yes,String message){if(!yes)throw new AssertionError(message);}
}
@@ -0,0 +1,212 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.progression.*;
import java.util.List;
import java.util.Map;
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.ClientGameTestContext;
import net.minecraft.client.gui.components.AbstractWidget;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.PauseScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.EntitySpawnReason;
import net.minecraft.world.phys.EntityHitResult;
/** Native UI, real pre-spawn handshake and progression packets in a fresh ignored Sanctuary world. */
public final class Progression003ClientChecks implements FabricClientGameTest {
private static final KeyEvent PRESS=new KeyEvent(InputConstants.KEY_RETURN,0,0);
public void runTest(ClientGameTestContext context) {
require(InhabitantColorNames.key(0x509DDB).equals("azure") && InhabitantColorNames.key(0xF49CAF).equals("grapefruit"),
"Color names describe the offered hues");
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
int previousScale=context.computeOnClient(client -> client.options.guiScale().get());
Screen previous=context.computeOnClient(client -> client.gui.screen());
var offer=new ProgressionPayloads.Hello("poupoutain",java.util.stream.IntStream.range(0,20)
.map(i -> InhabitantStore.color(42,i)).boxed().toList(),"");
context.runOnClient(client -> {
client.options.guiScale().set(0);client.resizeGui();
client.gui.setScreen(new HelloWorldScreen(offer,() -> { }));
bounds(client.gui.screen());
});
context.waitTick();context.takeScreenshot("sanctuary-beta003-hello-minimum");
context.runOnClient(client -> {
client.options.guiScale().set(2);client.resizeGui();bounds(client.gui.screen());
});
context.waitTick();context.takeScreenshot("sanctuary-beta003-hello-large");
String initialLanguage=context.computeOnClient(client -> client.getLanguageManager().getSelected());
Blocodex004ClientChecks.language(context,"fr_fr");
context.runOnClient(client -> {
var colorButtons=client.gui.screen().children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.getClass().getSimpleName().equals("ColorButton")).toList();
require(colorButtons.size()==20 && colorButtons.stream().allMatch(b -> b.getMessage().getContents()
instanceof net.minecraft.network.chat.contents.TranslatableContents t && t.getKey().startsWith("sanctuary.color.")),
"All twenty native colour buttons display translated hue names");
});
context.takeScreenshot("sanctuary-beta005-couleurs-fr");
Blocodex004ClientChecks.language(context,initialLanguage);
context.runOnClient(client -> client.gui.setScreen(previous));
var armed=new AtomicBoolean(true);var handled=new AtomicBoolean(false);
ClientTickEvents.END_CLIENT_TICK.register(client -> {
if (armed.get() && client.gui.screen() instanceof HelloWorldScreen screen && handled.compareAndSet(false,true)) {
require(client.player==null && client.level==null,"HELLO_WORLD precedes client spawn");
require(client.getSingleplayerServer().getPlayerCount()==0,"No inhabitant has joined the server before validation");
screen.children().stream().filter(EditBox.class::isInstance).map(EditBox.class::cast)
.findFirst().orElseThrow().setValue("Exploratrice du vide");
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(PRESS);
button(screen,"enter").onPress(PRESS);
}
});
try (var world=context.worldBuilder().adjustSettings(settings -> {
settings.setName("Sanctuary beta003 progression "+UUID.randomUUID());
settings.setWorldType(settings.getNormalPresetList().stream()
.filter(e -> e.preset()!=null && e.preset().getRegisteredName().equals("sanctuary:sanctuary"))
.findFirst().orElseThrow());
settings.setSeed("42");settings.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
}).create()) {
saved=world.getWorldSave();
armed.set(false);
require(handled.get(),"The real configuration screen appeared exactly on first arrival");
world.getConnection().waitForChunksDownload(2400);
context.waitFor(client -> client.player != null && client.gui.screen()==null && ProgressionClient.active(),1200);
context.runOnClient(client -> {
require(client.player.getMaxHealth()==6 && client.player.getFoodData().getFoodLevel()==6
&& client.player.getMaxAirSupply()==90,"3/3/3 reaches the real client");
client.player.setSprinting(true);
require(!client.player.isSprinting() && !client.player.getFoodData().hasEnoughFood(),
"A new client cannot sprint at three full food icons");
client.player.getFoodData().eat(20,20);client.player.setSprinting(true);
require(!client.player.isSprinting(),"Eating prediction cannot bypass the initial sprint gate");
require(ProgressionClient.identity.bio().equals("Exploratrice du vide"),"The submitted bio returns from the server");
});
context.waitTick();context.takeScreenshot("sanctuary-beta003-hud-333");
world.getServer().runOnServer(server -> {
var player=server.getPlayerList().getPlayers().getFirst();
player.giveExperienceLevels(10);
});
world.getConnection().waitForClientboundPackets();context.waitTicks(3);
context.getInput().pressKey(InputConstants.KEY_B);context.waitForScreen(ProgressionScreen.class);
context.runOnClient(client -> {
var screen=client.gui.screen();bounds(screen);
long planned=descendants(screen).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.planned") && !b.active).count();
require(planned==0,"All six skills now have real purchases");
var scroll=screen.children().stream().filter(net.minecraft.client.gui.components.AbstractScrollArea.class::isInstance)
.map(net.minecraft.client.gui.components.AbstractScrollArea.class::cast).findFirst().orElseThrow();
require(screen.mouseScrolled(scroll.getX()+10,scroll.getY()+10,0,-2) && scroll.scrollAmount()>0,
"The aptitude list scrolls independently");
scroll.setScrollAmount(0);
button(screen,"upgrade_cost").onPress(PRESS);
});
context.waitFor(client -> ProgressionClient.progress.vitality()==1,400);
context.runOnClient(client -> {
require(client.player.experienceLevel==9,"The heart cost one level");
button(client.gui.screen(),"unlock_cost").onPress(PRESS);
});
context.waitFor(client -> ProgressionClient.progress.names(),400);
Blocodex004ClientChecks.run(context);
Atlas005ClientChecks.run(context,world.getServer());
Mining008ClientChecks.run(context,world.getServer());
Building009ClientChecks.run(context,world.getServer());
context.waitTick();context.takeScreenshot("sanctuary-beta003-first-aptitude");
context.runOnClient(client -> {client.options.guiScale().set(0);client.resizeGui();bounds(client.gui.screen());});
context.waitTick();context.takeScreenshot("sanctuary-beta003-progression-minimum");
context.runOnClient(c -> ProgressionClient.open(null,"identity"));
context.runOnClient(client -> bounds(client.gui.screen()));
context.waitTick();context.takeScreenshot("sanctuary-beta003-inhabitant");
context.runOnClient(c -> ProgressionClient.open(null,"history"));
context.waitTick();context.takeScreenshot("sanctuary-beta003-history");
context.getInput().pressKey(InputConstants.KEY_ESCAPE);context.waitForScreen(null);
context.getInput().pressKey(InputConstants.KEY_ESCAPE);context.waitForScreen(PauseScreen.class);
context.runOnClient(client -> {bounds(client.gui.screen());button(client.gui.screen(),"progress").onPress(PRESS);});
context.waitForScreen(ProgressionScreen.class);
context.runOnClient(c -> ProgressionClient.open(new PauseScreen(true),"blocks"));
context.waitForScreen(SanctuaryBlocodexScreen.class);
context.clickScreenButton("gui.done");
context.waitForScreen(PauseScreen.class);
context.runOnClient(client -> client.gui.setScreen(new SanctuaryAtlasScreen(client.gui.screen())));
context.waitFor(client -> client.gui.screen() instanceof SanctuaryAtlasScreen a && a.isComplete() && a.observedTiles()>0,600);
context.clickScreenButton("gui.done");context.waitForScreen(PauseScreen.class);
context.runOnClient(client -> button(client.gui.screen(),"progress").onPress(PRESS));
context.waitForScreen(ProgressionScreen.class);
context.runOnClient(client -> {
var cow=net.minecraft.core.registries.BuiltInRegistries.ENTITY_TYPE.getValue(net.minecraft.resources.Identifier.withDefaultNamespace("cow")).create(client.level,EntitySpawnReason.TRIGGERED);
client.hitResult=new EntityHitResult(cow);
ProgressionClient.names=new MobNamesConfig(1,true,Map.of("minecraft:cow",new MobNamesConfig.Entry(false,List.of("Marguerite"))));
require(SanctuaryMobNames.name(cow).getString().equals("Marguerite"),"Custom names reach the personal overlay");
cow.setCustomName(Component.literal("Etiquette"));
require(SanctuaryMobNames.name(cow)==null,"A player's name tag remains authoritative");
});
context.runOnClient(client -> client.gui.setScreen(null));
world.getServer().runOnServer(server -> {
var player=server.getPlayerList().getPlayers().getFirst();
require(player.experienceLevel==0,"Server paid all seven purchases once");
require(ProgressEffects.get(player).names() && ProgressEffects.get(player).vitality()==1,"Server owns earned progression");
player.giveExperienceLevels(60);
require(player.getExperienceReward(player.level(),null)==6069,"Vanilla death reward now contains 70 percent of level 60");
player.hurtServer(player.level(),player.damageSources().fellOutOfWorld(),Float.MAX_VALUE);
});
context.waitForScreen(net.minecraft.client.gui.screens.DeathScreen.class);
context.waitTicks(45);
context.clickScreenButton("deathScreen.respawn");
context.waitFor(client -> client.player != null && client.gui.screen()==null && client.player.isAlive() && client.player.getMaxHealth()==8
&& ProgressionClient.active() && ProgressionClient.progress.names(),1200);
context.getInput().pressKey(InputConstants.KEY_B);context.waitForScreen(ProgressionScreen.class);
context.waitTick();context.takeScreenshot("sanctuary-beta003-after-death");
world.getServer().runOnServer(server -> {
var player=server.getPlayerList().getPlayers().getFirst();
require(ProgressEffects.get(player).revision()==7 && ProgressEffects.get(player).mining()==2 && ProgressEffects.get(player).building()==2,"A real respawn preserves the seven purchases including mining and building");
server.saveEverything(true,true,true);
});
} finally {
armed.set(false);
context.runOnClient(client -> {client.options.guiScale().set(previousScale);client.resizeGui();});
}
require(!ProgressionClient.active(),"Disconnect clears the previous server's profile");
try (var reopened=saved.open()) {
reopened.getConnection().waitForChunksDownload(2400);
context.waitFor(client -> client.player != null && ProgressionClient.active(),1200);
context.runOnClient(client -> {
require(!(client.gui.screen() instanceof HelloWorldScreen),"An existing inhabitant bypasses character creation");
require(ProgressionClient.progress.names() && ProgressionClient.progress.vitality()==1
&& ProgressionClient.progress.atlas() && ProgressionClient.progress.mining()==2 && ProgressionClient.progress.building()==2 && ProgressionClient.progress.revision()==7,"Purchases survive server restart");
require(ProgressionClient.identity.bio().equals("Exploratrice du vide"),"Identity survives server restart");
});
reopened.getServer().runOnServer(server -> {
var player=server.getPlayerList().getPlayers().getFirst();
require(fr.koka.sanctuary.atlas.AtlasService.observations(player).size()>0,"Personal exploration survives server restart");
try {
require(!ProgressionService.session(server).inhabitants.arrive(player.getUUID()),"Welcome stays consumed after restart");
} catch (java.io.IOException error) { throw new AssertionError(error); }
});
}
}
private static Button button(Screen screen,String key) {
return descendants(screen).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."+key)).findFirst().orElseThrow();
}
private static java.util.stream.Stream<net.minecraft.client.gui.components.events.GuiEventListener> descendants(
net.minecraft.client.gui.components.events.ContainerEventHandler container) {
return container.children().stream().flatMap(child -> java.util.stream.Stream.concat(java.util.stream.Stream.of(child),
child instanceof net.minecraft.client.gui.components.events.ContainerEventHandler nested
? descendants(nested) : java.util.stream.Stream.empty()));
}
private static void bounds(Screen screen) {
for (var child:screen.children()) if (child instanceof AbstractWidget w)
require(w.getX()>=0 && w.getY()>=0 && w.getRight()<=screen.width && w.getBottom()<=screen.height,
"Widget within screen: "+w.getMessage().getString()+" "+screen.width+"x"+screen.height);
}
private static void require(boolean condition,String message) { if (!condition) throw new AssertionError(message); }
}
@@ -0,0 +1,196 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.inventory.SanctuaryOverflowContainer;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
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.components.events.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.client.gui.screens.inventory.InventoryScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.network.chat.Component;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.inventory.MenuType;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/** Real widgets, fonts and purchase packets, in a fresh ignored development world. */
public final class Progression013ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var armed=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m -> {
if (armed.get() && m.gui.screen() instanceof HelloWorldScreen 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());armed.set(false);
}
});
int scale=c.computeOnClient(m -> m.options.guiScale().get());
String language=c.computeOnClient(m -> m.getLanguageManager().getSelected());
try (var world=c.worldBuilder().adjustSettings(s -> {
s.setName("Sanctuary beta013 skills "+UUID.randomUUID());
s.setWorldType(s.getNormalPresetList().stream().filter(e -> e.preset()!=null && e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());
s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
}).create()) {
world.getConnection().waitForChunksDownload(2400);
c.waitFor(m -> m.player!=null && ProgressionClient.active() && CycleClient.view!=null && m.gui.screen()==null,1200);
world.getServer().runOnServer(server -> server.getPlayerList().getPlayers().getFirst().setExperienceLevels(0));
c.waitFor(m -> m.player.experienceLevel==0,200);
c.runOnClient(m -> ProgressionClient.open(null));c.waitTick();
c.runOnClient(m -> {
aligned(m.gui.screen());
require(widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast)
.noneMatch(b -> b.visible && b.getMessage().getString().equals("+")),"Zero XP hides all skill purchases");
require(ProgressionClient.config.statCosts().getLast()==48,"Server sends the migrated default price");
});
c.takeScreenshot("sanctuary-beta013-skills-start");
world.getServer().runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();s.getPlayerList().op(p.nameAndId());
var progress=ProgressRecord.INITIAL;
for(String skill:ProgressRecord.SKILLS) while(progress.rank(skill)<ProgressRecord.cap(skill)-1)
progress=progress.buy(skill,progress.cost(skill,ProgressionConfig.DEFAULT));
p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);
p.setExperienceLevels(47);ProgressionService.send(p,"");
});
c.waitFor(m -> ProgressionClient.operator && ProgressionClient.progress.vitality()==6 && m.player.experienceLevel==47,200);c.waitTick();
c.runOnClient(m -> require(!adjust(m.gui.screen(),"vitality","+").visible,"47 levels cannot buy the last heart"));
world.getServer().runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();p.setExperienceLevels(48);ProgressionService.send(p,"");});
c.waitFor(m -> m.player.experienceLevel==48,200);c.waitTick();
for(String locale:new String[]{"en_us","fr_fr"}) {
c.runOnClient(m -> {m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());});
for(int gui:new int[]{1,0}) {
c.runOnClient(m -> {m.options.guiScale().set(gui);m.resizeGui();ProgressionClient.open(null);});
c.waitTick();c.runOnClient(m -> aligned(m.gui.screen()));
c.takeScreenshot("sanctuary-beta013-skills-"+locale+"-gui-"+gui);
}
}
c.runOnClient(m -> m.getWindow().setWindowed(640,480));
for(int i=0;i<5;i++)c.waitTick();
c.runOnClient(m -> {m.resizeGui();ProgressionClient.open(null);});c.waitTick();
c.runOnClient(m -> {aligned(m.gui.screen());System.out.println("beta013 compact GUI: "+m.gui.screen().width+"x"+m.gui.screen().height);});
c.takeScreenshot("sanctuary-beta013-skills-fr-compact");
c.runOnClient(m -> {var plus=adjust(m.gui.screen(),"vitality","+");require(plus.visible && plus.active,"48 levels enables the last heart");plus.onPress(enter());});
c.waitFor(m -> ProgressionClient.progress.vitality()==7 && m.player.experienceLevel==0,200);c.waitTick();
c.runOnClient(m -> {
require(!adjust(m.gui.screen(),"vitality","+").visible,"Maximum hides the plus");
var minus=adjust(m.gui.screen(),"vitality","");require(minus.visible && minus.active,"Operator can refund the last heart");minus.onPress(enter());
});
c.waitFor(m -> ProgressionClient.progress.vitality()==6 && m.player.experienceLevel==48,200);
c.runOnClient(m -> require(ProgressionClient.progress.history().getLast().cost()==48,"Exact purchase price refunded through the server"));
c.runOnClient(m -> CycleClient.open(null,"faction"));c.waitTick();
c.runOnClient(m -> {
var screen=m.gui.screen();require(!button(screen,"sanctuary.cycle.create").active,"Founding still requires a charge");
require(widgets(screen).anyMatch(w -> w instanceof AbstractWidget a && key(a,"sanctuary.cycle.founding_path")),"Faction page explains charge acquisition");
});
c.takeScreenshot("sanctuary-beta013-faction-path");
c.runOnClient(m -> button(m.gui.screen(),"sanctuary.cycle.cycle_progress").onPress(enter()));c.waitTick();
c.runOnClient(m -> require(!button(m.gui.screen(),"sanctuary.cycle.ngp").active,"Guidance opens Cycle and keeps eligibility enforced"));
String originalName=c.computeOnClient(m -> ProgressionClient.identity.name());
world.getServer().runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();p.setExperienceLevels(500);
for(String skill:ProgressRecord.SKILLS) while(ProgressEffects.get(p).rank(skill)<ProgressRecord.cap(skill))
require(ProgressionService.purchase(p,skill,ProgressEffects.get(p).revision()),"Complete skills for the prestige test");
p.getInventory().setItem(0,new ItemStack(Items.DIAMOND,1));
p.getInventory().setItem(9,new ItemStack(Items.WHEAT,2));
p.getInventory().setItem(35,new ItemStack(Items.COAL,3));
p.getInventory().setItem(43,new ItemStack(Items.IRON_INGOT,4));
p.getInventory().setItem(60,new ItemStack(Items.EMERALD,5));
p.inventoryMenu.broadcastChanges();ProgressionService.send(p,"");
});
c.waitFor(m -> ProgressionClient.progress.complete() && m.player.getInventory().getItem(60).getCount()==5,200);
c.runOnClient(m -> {CycleClient.open(null,"cycle");});c.waitTick();
c.runOnClient(m -> {button(m.gui.screen(),"sanctuary.cycle.ngp").onPress(enter());button(m.gui.screen(),"sanctuary.cycle.confirm").onPress(enter());});
c.waitFor(m -> ProgressionClient.progress.cycle()==1 && ProgressionClient.progress.inventoryRows()==1 && CycleClient.view.prestige()==1,200);
c.runOnClient(m -> {
require(ProgressionClient.identity.name().equals(originalName),"Prestige never renames the inhabitant record");
require(m.player.getInventory().getItem(9).getCount()==2 && m.player.getInventory().getItem(35).getCount()==3
&& m.player.getInventory().getItem(43).getCount()==4 && m.player.getInventory().getItem(60).getCount()==5,"Recovery objects survive the actual New Game+");
ProgressionClient.open(null,"identity");
require(widgets(m.gui.screen()).anyMatch(w -> w instanceof StringWidget a && a.getMessage().getString().equals(originalName+" I")),"Inhabitant page displays first prestige as Roman I");
});
c.waitTick();c.takeScreenshot("sanctuary-beta013-inhabitant-prestige-I");
for(int gui:new int[]{1,0}) {
c.runOnClient(m -> {m.options.guiScale().set(gui);m.resizeGui();m.gui.setScreen(new InventoryScreen(m.player));Inventory012RenderProbe.reset();});
c.waitTick();c.takeScreenshot("sanctuary-beta013-recovery-inventory-gui-"+gui);
c.runOnClient(m -> {recoveryDrawn((AbstractContainerScreen<?>)m.gui.screen());require(Inventory012RenderProbe.recoveryCells.size()==45,"All 45 locked cells are shaded, including empty overflow cells");m.player.closeContainer();});
}
for(var type:java.util.List.of(MenuType.GENERIC_9x3,MenuType.GENERIC_9x6)) {
world.getServer().runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();p.openMenu(new SimpleMenuProvider((id,inv,owner)->type.create(id,inv),Component.literal("Chest")));
});
c.waitFor(m -> m.gui.screen() instanceof AbstractContainerScreen<?> screen && screen.getMenu().getType()==type,200);
for(int offset:new int[]{0,1}) {
c.runOnClient(m -> {
var screen=(AbstractContainerScreen<?>)m.gui.screen();
var canvas=(fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor)screen;
if(offset==1)for(int i=0;i<5;i++)screen.mouseScrolled(canvas.sanctuary$getLeftPos()+88,screen.height-35,0,-1);
Inventory012RenderProbe.reset();
});
c.waitTick();c.takeScreenshot("sanctuary-beta013-recovery-chest-"+(type==MenuType.GENERIC_9x3 ? 3 : 6)+"-scroll-"+offset);
c.runOnClient(m -> recoveryDrawn((AbstractContainerScreen<?>)m.gui.screen()));
}
c.runOnClient(m -> m.player.closeContainer());c.waitForScreen(null);
}
Atlas013ClientChecks.run(c,world.getServer());
Inventory013WheelChecks.run(c,world.getServer());
} finally {
armed.set(false);c.runOnClient(m -> {
m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
m.options.guiScale().set(scale);m.resizeGui();
});
}
}
private static void aligned(Screen screen) {
var all=widgets(screen).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).toList();
int gaugeX=-1,minusX=-1,plusX=-1;
for(String skill:ProgressRecord.SKILLS) {
var name=all.stream().filter(w -> key(w,"sanctuary.progression."+skill)).findFirst().orElseThrow();
var row=all.stream().filter(w -> Math.abs((w.getY()+w.getHeight()/2)-(name.getY()+name.getHeight()/2))<=1)
.sorted(java.util.Comparator.comparingInt(AbstractWidget::getX)).toList();
var icon=row.stream().filter(ImageWidget.class::isInstance).findFirst().orElseThrow();
var gauge=row.stream().filter(SkillExperienceBar.class::isInstance).findFirst().orElseThrow();
var minus=adjust(screen,skill,"");var plus=adjust(screen,skill,"+");
var columns=java.util.List.of(icon,name,gauge,minus,plus);
for(int i=1;i<columns.size();i++) require(columns.get(i-1).getRight()<columns.get(i).getX(),"Columns never overlap: "+skill);
require(icon.getX()>=12 && plus.getRight()<screen.width-12,"Skill stays inside the GUI: "+skill);
if(gaugeX>=0) require(gauge.getX()==gaugeX && minus.getX()==minusX && plus.getX()==plusX,
"Bar and buttons use identical columns across all six skills: "+skill);
gaugeX=gauge.getX();minusX=minus.getX();plusX=plus.getX();
}
}
private static void recoveryDrawn(AbstractContainerScreen<?> screen) {
var inventory=net.minecraft.client.Minecraft.getInstance().player.getInventory();
var expected=new java.util.HashSet<Inventory012RenderProbe.Cell>();
for(var slot:screen.getMenu().slots) if(slot.x>-1000 && slot.y>-1000) {
var cell=new Inventory012RenderProbe.Cell(slot.x,slot.y);
if(slot.container instanceof SanctuaryOverflowContainer || slot.container==inventory && slot.getContainerSlot()>=9 && slot.getContainerSlot()<36)
expected.add(cell);
else require(!Inventory012RenderProbe.recoveryCells.contains(cell),"Active hotbar, equipment and chest storage are never shaded");
}
require(!expected.isEmpty() && Inventory012RenderProbe.recoveryCells.equals(expected),"Draw calls cover exactly every visible locked cell, empty or occupied");
}
private static Button adjust(Screen screen,String skill,String symbol) {
var name=widgets(screen).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast)
.filter(w -> key(w,"sanctuary.progression."+skill)).findFirst().orElseThrow();
return widgets(screen).filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.getY()==name.getY() && b.getMessage().getString().equals(symbol)).findFirst().orElseThrow();
}
private static Stream<GuiEventListener> widgets(GuiEventListener widget) {
return Stream.concat(Stream.of(widget),widget instanceof ContainerEventHandler parent
? parent.children().stream().flatMap(Progression013ClientChecks::widgets) : Stream.empty());
}
private static boolean key(AbstractWidget widget,String key) {return widget.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key);}
private static Button button(Screen screen,String key) {return widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(b -> key(b,key)).findFirst().orElseThrow();}
private static KeyEvent enter() {return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean ok,String message) {if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,75 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.realtime.*;
import fr.koka.sanctuary.notifications.ObjectiveTracker;
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.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.*;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.core.*;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.clock.WorldClocks;
import net.minecraft.world.attribute.EnvironmentAttributes;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.properties.BedPart;
import java.time.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
public final class Realtime037ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var armed=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(armed.get()&&m.gui.screen() instanceof HelloWorldScreen 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());armed.set(false);}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sanctuary realtime037 "+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->{require(RealtimeService.active(s.overworld()),"Realtime active automatically in quick flat preset");s.setDifficulty(net.minecraft.world.Difficulty.PEACEFUL,true);});
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.player.setYRot(-90);m.player.setXRot(-12);});
var zone=ZoneId.systemDefault();var model=new SolarTime(zone,SolarReference.resolve(zone,"auto",Instant.parse("2026-06-21T12:00:00Z")));
var day=SolarTime.day(LocalDate.of(2026,6,21),SolarReference.resolve(zone,"auto",Instant.parse("2026-06-21T12:00:00Z")),zone);
for(var scene:List.of(new Scene("sunrise",day.sunrise(),270),new Scene("noon",day.noon(),0),new Scene("sunset",day.sunset(),90))){
server.runOnServer(s->at(s,scene.time));double phase=model.sample(scene.time).phase();
c.waitFor(m->{var clock=m.level.clockManager().getInstance(m.level.registryAccess().lookupOrThrow(Registries.WORLD_CLOCK).getOrThrow(WorldClocks.OVERWORLD));return Math.abs(wrap(clock.totalTicks()+clock.partialTick()-phase))<2&&clock.rate()<.1;},150);
c.runOnClient(m->{float angle=m.level.environmentAttributes().getDimensionValue(EnvironmentAttributes.SUN_ANGLE);require(Math.abs(degrees(angle-scene.angle))<.1,"Native sun reaches "+scene.name+" horizon/noon: "+angle);});
c.runOnClient(m->{m.player.setYRot(scene.name.equals("sunset")?90:-90);m.player.setXRot(scene.name.equals("noon")?-80:-12);});c.waitTicks(5);c.takeScreenshot("sanctuary-beta037-"+scene.name);
}
// Synthetic quest source exists only in test code. Its bar shares the production HUD.
c.runOnClient(m->{NotificationClient.clearFeed();NotificationClient.options().seconds=3;NotificationClient.options().advancements=true;NotificationClient.objectives("quest",List.of(objective("Test quest",0)));});
c.runOnClient(m->NotificationClient.objectives("quest",List.of(objective("Test quest",.5F))));
c.waitFor(m->NotificationClient.tracked().stream().anyMatch(t->t.title().getString().equals("Test quest")),80);
c.waitTicks(5);c.runOnClient(m->{require(NotificationClient.visible(),"Objective HUD visible in normal play");require(NotificationObstacles.lastLayout.height()>=23,"Objective has an actual render rectangle: "+NotificationObstacles.lastLayout+" "+NotificationObstacles.boxes());});
c.takeScreenshot("sanctuary-beta037-shared-objective");
c.waitTicks(145);c.runOnClient(m->{NotificationClient.objectives("quest",List.of(objective("Test quest",.5F)));require(NotificationClient.tracked().stream().noneMatch(t->t.title().getString().equals("Test quest")),"Unchanged quest expires in the real HUD");NotificationClient.toggleObjective(new ObjectiveTracker.Key("quest","sanctuary:test_only"));});
c.waitTicks(5);c.runOnClient(m->require(NotificationClient.tracked().size()==1&&NotificationObstacles.lastLayout.height()>=23,"Pinned quest actually rendered"));c.takeScreenshot("sanctuary-beta037-pinned-objective");
c.runOnClient(m->{NotificationClient.objectives("quest",List.of(objective("Test quest",1)));require(NotificationClient.tracked().stream().noneMatch(t->t.title().getString().equals("Test quest")),"Completed quest unpins in native client");NotificationClient.clearFeed();});
var night=day.sunset().plusSeconds(Duration.between(day.sunset(),SolarTime.day(day.date().plusDays(1),SolarReference.resolve(zone,"auto",day.noon()),zone).sunrise()).toSeconds()/2);
server.runOnServer(s->{at(s,night);var p=s.getPlayerList().getPlayers().getFirst();var level=p.level();var foot=p.blockPosition();var head=foot.north();
level.setBlockAndUpdate(foot.below(),Blocks.STONE.defaultBlockState());level.setBlockAndUpdate(head.below(),Blocks.STONE.defaultBlockState());
var bed=Blocks.BED.red().defaultBlockState().setValue(BedBlock.FACING,Direction.NORTH);level.setBlockAndUpdate(foot,bed.setValue(BedBlock.PART,BedPart.FOOT));level.setBlockAndUpdate(head,bed.setValue(BedBlock.PART,BedPart.HEAD));
var bedBlock=(AbstractBedBlock)bed.getBlock();var result=p.startSleepInBed(bedBlock,level.getBlockState(head),bedBlock.getBedRule(level,head),head);require(result.left().isEmpty(),"Native bed entry succeeds at solar night: "+result);
});
c.waitFor(m->m.player.isSleeping(),150);c.waitTicks(135);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();require(p.isSleeping()&&p.isSleepingLongEnough(),"Deep sleep does not skip real night or wake the player");require(Math.abs(wrap(s.overworld().getOverworldClockTime()-model.sample(night).phase()))<2,"Night remains at the wall-clock phase");p.stopSleepInBed(true,true);});
c.waitFor(m->!m.player.isSleeping()&&m.gui.screen()==null,150);c.waitTicks(5);c.takeScreenshot("sanctuary-beta037-night-after-bed");
server.runOnServer(s->{try{var field=RealtimeService.Session.class.getDeclaredField("clock");field.setAccessible(true);field.set(RealtimeService.session(s),Clock.systemUTC());RealtimeService.tick(s);}catch(Exception e){throw new RuntimeException(e);}});
System.out.println("REALTIME037 client passed: automatic solo reference; native sun horizon/noon + fractional clock synchronization; shared quest feed expiry/pinning/completion; deep bed sleep leaves real night intact.");
}finally{armed.set(false);}
}
private record Scene(String name,Instant time,float angle){}
private static void at(net.minecraft.server.MinecraftServer s,Instant time){try{var field=RealtimeService.Session.class.getDeclaredField("clock");field.setAccessible(true);field.set(RealtimeService.session(s),Clock.fixed(time,ZoneOffset.UTC));RealtimeService.tick(s);s.getPlayerList().broadcastAll(s.clockManager().createFullSyncPacket());}catch(Exception e){throw new RuntimeException(e);}}
private static ObjectiveTracker.Objective<NotificationClient.Track> objective(String name,float f){return new ObjectiveTracker.Objective<>("sanctuary:test_only",new NotificationClient.Track(Component.literal(name),Component.literal(Math.round(f*100)+"%"),f,0xF5D176),f,f==1);}
private static double wrap(double ticks){return ((ticks+12000)%24000+24000)%24000-12000;}
private static float degrees(float a){return ((a+180)%360+360)%360-180;}
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 Button button(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);}
private static void require(boolean ok,String why){if(!ok)throw new AssertionError(why);}
}
@@ -0,0 +1,101 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.recipes.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.inventory.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
import mezz.jei.api.constants.RecipeTypes;
import mezz.jei.api.sanctuary.CatalogueAccess;
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.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.inventory.InventoryScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.item.*;
public final class Recipes024ClientChecks implements FabricClientGameTest {
@Override public void runTest(ClientGameTestContext c){
var onboarding=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(onboarding.get()&&m.gui.screen() instanceof HelloWorldScreen 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());onboarding.set(false);
}});
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
int scale=c.computeOnClient(m->m.options.guiScale().get());
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Sanctuary beta024 recipes "+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:sanctuary")).findFirst().orElseThrow());}).create()){
saved=world.getWorldSave();
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&RecipeClient.ready&&SanctuaryJeiPlugin.runtime()!=null&&m.gui.screen()==null,1200);
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.gui.setScreen(new InventoryScreen(m.player));});c.waitTicks(6);
c.runOnClient(m->{require(!RecipeClient.allowed(),"Catalogue initially locked");require(!SanctuaryJeiPlugin.runtime().getIngredientListOverlay().isListDisplayed(),"Locked catalogue has no sidebar");SanctuaryJeiPlugin.runtime().getRecipesGui().showTypes(List.of(RecipeTypes.CRAFTING));require(m.gui.screen() instanceof InventoryScreen,"JEI cannot open while locked");});
c.takeScreenshot("sanctuary-beta024-locked-inventory");
var server=world.getServer();
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.getInventory().clearContent();p.getInventory().setItem(0,new ItemStack(Items.OAK_PLANKS,8));p.giveExperienceLevels(4-p.experienceLevel);RecipeService.refresh(p);});
c.waitFor(m->RecipeClient.knowledge.recipes.contains("minecraft:crafting_table")&&RecipeClient.xp>=4,120);
c.runOnClient(m->{require(!RecipeClient.knowledge.recipes.contains("minecraft:shield"),"Plank discovery does not teach shield");m.gui.setScreen(new RecipeScreen(new net.minecraft.client.gui.screens.PauseScreen(true)));});c.waitTicks(3);c.takeScreenshot("sanctuary-beta024-collections-locked");
c.runOnClient(m->button(m.gui.screen(),"sanctuary.recipes.buy").onPress(enter()));c.waitFor(m->RecipeClient.allowed()&&RecipeClient.knowledge.learned(),120);
c.runOnClient(m->{var r=SanctuaryJeiPlugin.runtime();require(r.getIngredientFilter().getFilteredItemStacks().stream().noneMatch(i->i.is(Items.DIAMOND_BLOCK)),"Unknown item hidden");
require(r.getIngredientFilter().getFilteredIngredients(mezz.jei.api.fabric.constants.FabricTypes.FLUID_STACK).isEmpty(),"Unknown fluids hidden too");
require(r.getRecipeManager().createRecipeCategoryLookup().get().anyMatch(cat->cat.getRecipeType().equals(RecipeTypes.CRAFTING)),"Learned blueprints appear before discovering their crafting station");
require(r.getRecipeManager().createRecipeLookup(RecipeTypes.CRAFTING).get().anyMatch(v->v.id().identifier().toString().equals("minecraft:crafting_table")),"Learned wood recipe visible");
require(r.getRecipeManager().createRecipeLookup(RecipeTypes.CRAFTING).get().noneMatch(v->v.id().identifier().toString().equals("minecraft:shield")),"Unknown recipe hidden");
m.gui.setScreen(new InventoryScreen(m.player));});c.waitTicks(5);c.takeScreenshot("sanctuary-beta024-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-beta024-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");});
c.waitFor(m->RecipeClient.operator,120);c.waitTicks(3);c.takeScreenshot("sanctuary-beta024-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.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);
p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);p.getInventory().clearContent();p.getInventory().setItem(SanctuaryInventoryCapacity.EXTRA_START+17,new ItemStack(Items.OAK_PLANKS,8));p.inventoryMenu.broadcastChanges();ProgressionService.send(p,"");});
c.waitFor(m->ProgressionClient.progress.inventoryRows()==6&&m.player.getInventory().getItem(SanctuaryInventoryCapacity.EXTRA_START+17).getCount()==8,120);
c.runOnClient(m->{m.gui.setScreen(new InventoryScreen(m.player));var menu=m.player.inventoryMenu;var slots=CatalogueAccess.inventory.apply(menu,List.of());require(slots.size()==54,"JEI sees all six unlocked storage rows");
var r=SanctuaryJeiPlugin.runtime();var manager=r.getRecipeManager();var category=manager.getRecipeCategory(RecipeTypes.CRAFTING);
var recipe=manager.createRecipeLookup(RecipeTypes.CRAFTING).get().filter(v->v.id().identifier().toString().equals("minecraft:crafting_table")).findFirst().orElseThrow();
var layout=manager.createRecipeLayoutDrawable(category,recipe,r.getJeiHelpers().getFocusFactory().createFocusGroup(List.of())).orElseThrow();
var handler=r.getRecipeTransferManager().getRecipeTransferHandler(menu,category).orElseThrow();
require(handler.transferRecipe(menu,recipe,layout.getRecipeSlotsView(),m.player,false,true)==null,"Real JEI transfer accepts overflow ingredients");});
c.waitFor(m->m.player.inventoryMenu.getSlot(0).getItem().is(Items.CRAFTING_TABLE),120);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();require(p.getInventory().getItem(SanctuaryInventoryCapacity.EXTRA_START+17).getCount()==4,"Exactly four planks consumed from row six");
for(int i=1;i<=4;i++)require(p.inventoryMenu.getSlot(i).getItem().is(Items.OAK_PLANKS)&&p.inventoryMenu.getSlot(i).getItem().getCount()==1,"Grid receives one plank per slot");});
c.takeScreenshot("sanctuary-beta024-overflow-transfer");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var progress=ProgressEffects.get(p);
for(String skill:ProgressRecord.SKILLS)while(progress.rank(skill)<ProgressRecord.cap(skill))progress=progress.buy(skill,1);
p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);
var action=new fr.koka.sanctuary.cycle.CyclePayloads.Action("ngp","","",ProgressionService.session(s).cycles.revision(),progress.revision());
require(fr.koka.sanctuary.cycle.CycleService.perform(p,action).equals("done"),"Real New Game+ succeeds");RecipeService.refresh(p);});
c.waitFor(m->ProgressionClient.progress.inventoryRows()==1&&ProgressionClient.progress.cycle()==1,120);
c.runOnClient(m->{require(CatalogueAccess.inventory.apply(m.player.inventoryMenu,List.of()).size()==9,"After reset, locked recovery rows excluded from JEI autofill");require(RecipeClient.knowledge.learned(),"Catalogue persists after skill reset");
m.options.guiScale().set(4);m.resizeGui();m.gui.setScreen(new RecipeScreen(null));});c.waitTicks(4);c.takeScreenshot("sanctuary-beta024-compact-blocodex");
c.runOnClient(m->m.gui.setScreen(null));
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.hurtServer(p.level(),p.damageSources().fellOutOfWorld(),Float.MAX_VALUE);});
c.waitForScreen(net.minecraft.client.gui.screens.DeathScreen.class);c.waitTicks(45);c.clickScreenButton("deathScreen.respawn");
c.waitFor(m->m.player!=null&&m.player.isAlive()&&m.gui.screen()==null&&ProgressionClient.active(),1200);c.waitTicks(25);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();require(RecipeKnowledge.parse(p.getAttached(RecipeService.RECORD)).learned(),"Real death preserves catalogue attachment");s.saveEverything(true,true,true);});
System.out.println("JEI024: locked UI, personal discoveries, recipes, operator revocation and real six-row transfer passed.");
}finally{onboarding.set(false);c.runOnClient(m->{m.options.guiScale().set(scale);m.resizeGui();});}
c.runOnClient(m->require(!RecipeClient.ready&&!RecipeClient.allowed(),"Recipe state cleared on world exit"));
try(var reopened=saved.open()){
reopened.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&RecipeClient.ready&&SanctuaryJeiPlugin.runtime()!=null,1200);
c.runOnClient(m->{require(RecipeClient.knowledge.learned()&&RecipeClient.knowledge.recipes.contains("minecraft:crafting_table"),"Catalogue and recipes survive server restart");require(!RecipeClient.operator,"Operator inspection is not persisted");});
System.out.println("JEI024: actual prestige, death and server restart preserve recipe knowledge.");
}
}
private static void hover(ClientGameTestContext c,int index){c.runOnClient(m->{var screen=(net.minecraft.client.gui.screens.inventory.AbstractContainerScreen<?>)m.gui.screen();var a=(fr.koka.sanctuary.mixin.client.AbstractContainerScreenAccessor)screen;var slot=screen.getMenu().getSlot(index);var w=m.getWindow();double x=a.sanctuary$getLeftPos()+slot.x+8.0,y=a.sanctuary$getTopPos()+slot.y+8.0;double sx=(double)w.getScreenWidth()/w.getGuiScaledWidth(),sy=(double)w.getScreenHeight()/w.getGuiScaledHeight();m.mouseHandler.onMove(w.handle(),x*sx,y*sy,(x-m.mouseHandler.getScaledXPos(w))*sx,(y-m.mouseHandler.getScaledYPos(w))*sy);m.mouseHandler.handleAccumulatedMovement();});c.waitTicks(2);}
private static void require(boolean value,String message){if(!value)throw new AssertionError(message);}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static Stream<GuiEventListener> widgets(ContainerEventHandler c){return c.children().stream().flatMap(w->w instanceof ContainerEventHandler nested?Stream.concat(Stream.of(w),widgets(nested)):Stream.of(w));}
private static Button button(Screen screen,String key){return widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(w->w.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow(()->new AssertionError("Missing "+key));}
}
@@ -0,0 +1,163 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.progression.*;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
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.components.events.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.network.chat.Component;
/** Real widgets, fonts and purchase packets, in a fresh ignored development world. */
public final class Skills025ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var armed=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m -> {
if (armed.get() && m.gui.screen() instanceof HelloWorldScreen 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());armed.set(false);
}
});
int scale=c.computeOnClient(m -> m.options.guiScale().get());
String language=c.computeOnClient(m -> m.getLanguageManager().getSelected());
try (var world=c.worldBuilder().adjustSettings(s -> {
s.setName("Sanctuary beta025 skills "+UUID.randomUUID());
s.setWorldType(s.getNormalPresetList().stream().filter(e -> e.preset()!=null && e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());
s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
}).create()) {
world.getConnection().waitForChunksDownload(2400);
c.waitFor(m -> m.player!=null && ProgressionClient.active() && CycleClient.view!=null && m.gui.screen()==null,1200);
// First Steps can already award XP during admission; make the zero-XP case explicit.
world.getServer().runOnServer(server -> server.getPlayerList().getPlayers().getFirst().setExperienceLevels(0));
c.waitFor(m -> m.player.experienceLevel==0,200);
c.runOnClient(m -> ProgressionClient.open(null));c.waitTick();
c.runOnClient(m -> {
aligned(m.gui.screen());
require(widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast)
.noneMatch(b -> b.visible && b.getMessage().getString().equals("+")),"Zero XP hides all skill purchases");
require(ProgressionClient.config.statCosts().getLast()==48,"Server sends the migrated default price");
});
c.runOnClient(m -> {
m.getWindow().setWindowed(1280,800);m.options.guiScale().set(2);m.resizeGui();ProgressionClient.open(null);
});
for(int i=0;i<5;i++)c.waitTick();
c.takeScreenshot("sanctuary-beta025-skills-start");
world.getServer().runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();s.getPlayerList().op(p.nameAndId());
var progress=ProgressRecord.INITIAL;
for(String skill:ProgressRecord.SKILLS) while(progress.rank(skill)<ProgressRecord.cap(skill)-1)
progress=progress.buy(skill,progress.cost(skill,ProgressionConfig.DEFAULT));
p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);
p.setExperienceLevels(47);ProgressionService.send(p,"");
});
c.waitFor(m -> ProgressionClient.operator && ProgressionClient.progress.vitality()==6 && m.player.experienceLevel==47,200);c.waitTick();
c.runOnClient(m -> require(!adjust(m.gui.screen(),"vitality","+").visible,"47 levels cannot buy the last heart"));
world.getServer().runOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();p.setExperienceLevels(48);ProgressionService.send(p,"");});
c.waitFor(m -> m.player.experienceLevel==48,200);c.waitTick();
for(String locale:new String[]{"en_us","fr_fr"}) {
c.runOnClient(m -> {m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());});
for(int gui:new int[]{1,2,0}) {
c.runOnClient(m -> {m.options.guiScale().set(gui);m.resizeGui();ProgressionClient.open(null);});
c.waitTick();c.runOnClient(m -> aligned(m.gui.screen()));
c.takeScreenshot("sanctuary-beta025-skills-"+locale+"-gui-"+gui);
}
}
c.runOnClient(m -> m.getWindow().setWindowed(640,480));
for(int i=0;i<5;i++)c.waitTick();
c.runOnClient(m -> {m.resizeGui();ProgressionClient.open(null);});c.waitTick();
c.runOnClient(m -> {aligned(m.gui.screen());System.out.println("beta025 compact GUI: "+m.gui.screen().width+"x"+m.gui.screen().height);});
c.takeScreenshot("sanctuary-beta025-skills-fr-compact");
c.runOnClient(m -> {
var bar=widgets(m.gui.screen()).filter(SkillExperienceBar.class::isInstance).map(SkillExperienceBar.class::cast).findFirst().orElseThrow();
m.gui.screen().mouseScrolled(bar.getX()+5,bar.getY()+10,0,-20);
});c.waitTick();
c.runOnClient(m -> {
var last=widgets(m.gui.screen()).filter(SkillExperienceBar.class::isInstance).map(SkillExperienceBar.class::cast).toList().getLast();
require(last.getY()>=88 && last.getBottom()<=m.gui.screen().height-53,"Scrolling brings the inventory bar into the visible panel");
});
c.takeScreenshot("sanctuary-beta025-skills-fr-compact-scrolled");
c.runOnClient(m -> ProgressionClient.open(null));c.waitTick();
c.runOnClient(m -> {var plus=adjust(m.gui.screen(),"vitality","+");require(plus.visible && plus.active,"48 levels enables the last heart");plus.onPress(enter());});
c.waitFor(m -> ProgressionClient.progress.vitality()==7 && m.player.experienceLevel==0,200);c.waitTick();
c.runOnClient(m -> {
require(!adjust(m.gui.screen(),"vitality","+").visible,"Maximum hides the plus");
var minus=adjust(m.gui.screen(),"vitality","");require(minus.visible && minus.active,"Operator can refund the last heart");minus.onPress(enter());
});
c.waitFor(m -> ProgressionClient.progress.vitality()==6 && m.player.experienceLevel==48,200);
c.runOnClient(m -> require(ProgressionClient.progress.history().getLast().cost()==48,"Exact purchase price refunded through the server"));
world.getServer().runOnServer(server -> {
var player=server.getPlayerList().getPlayers().getFirst();
var complete=ProgressRecord.INITIAL;
for(String skill:ProgressRecord.SKILLS)while(complete.rank(skill)<ProgressRecord.cap(skill))
complete=complete.buy(skill,complete.cost(skill,ProgressionConfig.DEFAULT));
player.setAttached(ProgressionService.RECORD,complete.json());ProgressEffects.apply(player,complete);
ProgressionService.send(player,"");
});
c.waitFor(m -> ProgressionClient.progress.complete(),200);c.waitTick();
c.runOnClient(m -> {
aligned(m.gui.screen());
require(ProgressionClient.progress.inventoryRows()==6,"Inventory keeps its six rows");
require(widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast)
.noneMatch(b -> b.visible && b.getMessage().getString().equals("+")),"Full bars have no further purchase");
});
c.runOnClient(m -> {m.getWindow().setWindowed(1280,800);m.options.guiScale().set(2);m.resizeGui();ProgressionClient.open(null);});
for(int i=0;i<5;i++)c.waitTick();
c.takeScreenshot("sanctuary-beta025-skills-full");
c.runOnClient(m -> ProgressionClient.open(null,"identity"));c.waitTick();
c.runOnClient(m -> ProgressionClient.open(null,"progress"));c.waitTick();
c.runOnClient(m -> aligned(m.gui.screen()));
System.out.println("beta025 skills: FR/EN, responsive XP bars, 47/48 purchase, refund, caps and tab return passed");
} finally {
armed.set(false);c.runOnClient(m -> {
m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
m.options.guiScale().set(scale);m.resizeGui();
});
}
}
private static void aligned(Screen screen) {
var all=widgets(screen).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).toList();
int gaugeX=-1,minusX=-1,plusX=-1;
require(all.stream().filter(SkillExperienceBar.class::isInstance).count()==6,"All six skills have an XP bar");
require(all.stream().noneMatch(w -> key(w,"sanctuary.progression.rank_max_short") || key(w,"sanctuary.progression.next_cost_short")),"No separate Max or price columns");
for(String skill:ProgressRecord.SKILLS) {
var name=all.stream().filter(w -> key(w,"sanctuary.progression."+skill)).findFirst().orElseThrow();
var row=all.stream().filter(w -> Math.abs((w.getY()+w.getHeight()/2)-(name.getY()+name.getHeight()/2))<=1)
.sorted(java.util.Comparator.comparingInt(AbstractWidget::getX)).toList();
var icon=row.stream().filter(ImageWidget.class::isInstance).findFirst().orElseThrow();
var gauge=row.stream().filter(SkillExperienceBar.class::isInstance).findFirst().orElseThrow();
var minus=adjust(screen,skill,"");var plus=adjust(screen,skill,"+");
require(gauge.getWidth()>=48,"Even compact bars leave room for the price");
System.out.println("beta025 bar "+skill+" gui="+screen.width+"x"+screen.height+" bounds="+gauge.getX()+","+gauge.getY()+","+gauge.getWidth()+","+gauge.getHeight());
var columns=java.util.List.of(icon,name,gauge,minus,plus);
for(int i=1;i<columns.size();i++) require(columns.get(i-1).getRight()<columns.get(i).getX(),"Columns never overlap: "+skill);
require(icon.getX()>=12 && plus.getRight()<screen.width-12,"Skill stays inside the GUI: "+skill);
if(gaugeX>=0) require(gauge.getX()==gaugeX && minus.getX()==minusX && plus.getX()==plusX,
"Bar and buttons use identical columns across all six skills: "+skill);
gaugeX=gauge.getX();minusX=minus.getX();plusX=plus.getX();
}
}
private static Button adjust(Screen screen,String skill,String symbol) {
var name=widgets(screen).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast)
.filter(w -> key(w,"sanctuary.progression."+skill)).findFirst().orElseThrow();
return widgets(screen).filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.getY()==name.getY() && b.getMessage().getString().equals(symbol)).findFirst().orElseThrow();
}
private static Stream<GuiEventListener> widgets(GuiEventListener widget) {
return Stream.concat(Stream.of(widget),widget instanceof ContainerEventHandler parent
? parent.children().stream().flatMap(Skills025ClientChecks::widgets) : Stream.empty());
}
private static boolean key(AbstractWidget widget,String key) {return widget.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key);}
private static Button button(Screen screen,String key) {return widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(b -> key(b,key)).findFirst().orElseThrow();}
private static KeyEvent enter() {return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean ok,String message) {if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,142 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.client.shader.*;
import fr.koka.sanctuary.stars.*;
import fr.koka.sanctuary.stars.ConstellationLedger.Ref;
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.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.item.*;
import org.joml.Vector3f;
public final class Sky056ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
geometry();
c.runOnClient(m->preferences());
var onboarding=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(onboarding.get()&&m.gui.screen() instanceof HelloWorldScreen s){
Starter039TestSupport.selectFirst(s);
widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
widgets(s).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("Sky056 "+UUID.randomUUID());s.setSeed("56");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&&ProgressionClient.active()&&m.gui.screen()==null,1200);
var server=world.getServer();var expected=new AtomicInteger();
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();p.getAbilities().invulnerable=true;p.setExperienceLevels(12);p.setItemInHand(net.minecraft.world.InteractionHand.MAIN_HAND,new ItemStack(Items.SPYGLASS));
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set midnight");
addCatalogue(s,0);expected.set(StarService.ledger(s).starCount());
});
c.waitFor(m->StarClient.starCount()==expected.get()&&m.player.experienceLevel==12,200);
c.runOnClient(m->{VanillaLight.setEnabled(true);VanillaLight.setSaturation(142);m.options.fov().set(90);});
var first=c.computeOnClient(m->Map.copyOf(StarClient.markers()));
c.getInput().lookAt(0,-45);c.waitTicks(8);c.takeScreenshot("sanctuary-beta056-one-catalogue");
server.runOnServer(s->{addCatalogue(s,1);addCatalogue(s,2);expected.set(StarService.ledger(s).starCount());});
c.waitFor(m->StarClient.starCount()==expected.get(),200);c.waitTicks(6);
c.runOnClient(m->{
require(StarClient.markers().entrySet().containsAll(first.entrySet()),"More inhabitants add density without changing previous identities");
var points=StarClient.markers().entrySet().stream().flatMap(e->StarGeometry.points(e.getKey(),e.getValue()).stream()).toList();coverage(points);
require(StarClient.indexCount()==points.size()*6,"Native GPU mesh contains every earned star");
require(m.gameRenderer.getAppliedPostEffects().contains(VanillaLight.EFFECT)&&VanillaLight.saturation()==142,"Live shader uses the requested saturation");
System.out.println("SKY056_CATALOGUE: one="+first.values().stream().mapToInt(Integer::intValue).sum()+", three="+points.size());
});
var mesh=c.computeOnClient(m->StarClient.vertexBuffer(null));long revision=c.computeOnClient(m->StarClient.revision());
for(int yaw:new int[]{0,90,180,270}){c.getInput().lookAt(yaw,-45);c.waitTicks(4);c.takeScreenshot("sanctuary-beta056-three-catalogues-"+yaw);}
c.runOnClient(m->require(StarClient.vertexBuffer(null)==mesh&&StarClient.revision()==revision,"Camera movement reuses the star buffer"));
var refs=c.computeOnClient(m->StarClient.markers().entrySet().stream().filter(e->e.getValue()==5).flatMap(e->java.util.stream.IntStream.range(1,5).mapToObj(i->new Ref(e.getKey(),i))).filter(r->direction(r).y>.4).limit(2).toList());
require(refs.size()==2,"Visible dispersed bonus stars");
aim(c,refs.getFirst());c.runOnClient(m->require(refs.getFirst().equals(StarTargeting.pick(.2)),"Targeting matches a dispersed bonus star"));
c.getInput().pressMouse(InputConstants.MOUSE_BUTTON_LEFT);c.waitTicks(4);
aim(c,refs.getLast());c.getInput().pressMouse(InputConstants.MOUSE_BUTTON_LEFT);
c.waitFor(m->ConstellationClient.edges().size()==1&&m.player.experienceLevel==9,200);
c.getInput().holdMouse(InputConstants.MOUSE_BUTTON_RIGHT);
c.waitFor(m->m.player.isScoping()&&StarInspectionClient.detail()!=null,200);
c.runOnClient(m->{var detail=StarInspectionClient.detail();require(detail.star().equals(refs.getLast())&&detail.total()==1&&detail.name().getString().contains("Aster"),"Native spyglass resolves origin and membership at new positions");});
c.takeScreenshot("sanctuary-beta056-bonus-star-inspection");
c.getInput().releaseMouse(InputConstants.MOUSE_BUTTON_RIGHT);
server.runOnServer(s->{try{
Path data=s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data");
var stars=StarService.ledger(s);var links=ConstellationService.ledger(s);stars.save();
byte[] starBytes=Files.readAllBytes(data.resolve("sanctuary-stars.json")),linkBytes=Files.readAllBytes(data.resolve("sanctuary-constellations.json"));
var loaded=new StarLedger(data.resolve("sanctuary-stars.json"),s.overworld().getSeed());
var loadedLinks=new ConstellationLedger(data.resolve("sanctuary-constellations.json"),loaded.world());
require(loaded.events().equals(stars.events())&&loadedLinks.edges().equals(links.edges()),"Existing ledger schemas retain all memories and links");
for(var event:stars.events())require(loaded.positionSeed(event)==stars.positionSeed(event),"Directions survive reloading their journal");
require(Arrays.equals(starBytes,Files.readAllBytes(data.resolve("sanctuary-stars.json")))&&Arrays.equals(linkBytes,Files.readAllBytes(data.resolve("sanctuary-constellations.json"))),"Reading existing ledgers does not rewrite them");
}catch(Exception e){throw new RuntimeException(e);}});
var reload=c.computeOnClient(m->m.reloadResourcePacks());c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();c.waitTicks(6);
c.runOnClient(m->require(StarClient.starCount()==expected.get()&&StarClient.indexCount()==expected.get()*6&&ConstellationClient.edges().size()==1,"Stars and constellation survive native resource reload"));
for(String locale:List.of("fr_fr","en_us")){
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.gui.setScreen(new ShaderOptionsScreen(null));
var slider=widgets(m.gui.screen()).filter(AbstractSliderButton.class::isInstance).map(AbstractSliderButton.class::cast).findFirst().orElseThrow();
require(slider.getMessage().getString().contains("142"),"Native localized slider shows 142 percent");
require(net.minecraft.network.chat.Component.translatable("sanctuary.shaders.saturation_help").getString().contains("142"),"Localized help identifies the default");});
c.waitTicks(3);c.takeScreenshot("sanctuary-beta056-default-"+locale);
}
c.runOnClient(m->m.gui.setScreen(null));
System.out.println("SKY056_PASS: full native advancement catalogue for three residents, whole sphere, stable references, cached mesh, bonus-star selection, constellation, inspection, ledger/resource reload, default and saved saturation, FR/EN");
}finally{onboarding.set(false);c.getInput().releaseMouse(InputConstants.MOUSE_BUTTON_RIGHT);}
}
private static void addCatalogue(net.minecraft.server.MinecraftServer s,int index){
var ledger=StarService.ledger(s);var actor=UUID.nameUUIDFromBytes(("sky056:"+index).getBytes(java.nio.charset.StandardCharsets.UTF_8));
for(var holder:s.getAdvancements().getAllAdvancements()){
var display=holder.value().display();if(!holder.id().getNamespace().equals("minecraft")||display.isEmpty()||!display.get().announceToChat())continue;
int count=switch(display.get().type()){case TASK->1;case GOAL->3;case CHALLENGE->5;};
ledger.add(new StarLedger.Event("advancement:"+actor+":"+holder.id(),"advancement",actor,"Aster "+index,holder.id().toString(),"2026-09-15T12:00:00Z",count));
}
try{ledger.save();}catch(Exception e){throw new RuntimeException(e);}
}
private static void geometry(){
var all=new ArrayList<StarGeometry.Point>();
for(long seed=0;seed<2000;seed++){
var points=StarGeometry.points(seed,5);require(points.equals(StarGeometry.points(seed,5)),"Reproducible positions");all.addAll(points);
require(!points.equals(StarGeometry.points(seed^(1L<<48),5)),"All seed bits distinguish event directions");
for(int n=1;n<=5;n++)require(points.subList(0,n).equals(StarGeometry.points(seed,n)),"Stable prefix for constellation references");
for(int i=0;i<5;i++){
var a=points.get(i);double norm=Math.sqrt(a.x()*a.x()+a.y()*a.y()+a.z()*a.z());require(Math.abs(norm-100)<.001,"Celestial sphere radius");
require(a.equals(new Ref(seed,i).point()),"Shared renderer and constellation geometry");
for(int j=0;j<i;j++){var b=points.get(j);double dot=(a.x()*b.x()+a.y()*b.y()+a.z()*b.z())/10000.;require(dot<Math.cos(Math.toRadians(29.99)),"Same-event stars no longer form tight clusters");}
}
}
coverage(all);
}
private static void coverage(List<StarGeometry.Point> points){
int[] cells=new int[72];double x=0,y=0,z=0;boolean north=false,south=false;
for(var p:points){int band=Math.clamp((int)((p.y()/100.+1)*3),0,5),sector=Math.clamp((int)((Math.atan2(p.z(),p.x())+Math.PI)/(2*Math.PI)*12),0,11);cells[band*12+sector]++;x+=p.x()/100.;y+=p.y()/100.;z+=p.z()/100.;north|=p.y()>95;south|=p.y()<-95;}
require(Arrays.stream(cells).filter(n->n>0).count()>64&&north&&south,"Stars cover equal-area sectors and both polar caps");
require(Math.abs(x/points.size())<.1&&Math.abs(y/points.size())<.1&&Math.abs(z/points.size())<.1,"No preferred celestial direction");
}
private static void preferences(){
Path path=FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shaders.json");
try{
byte[] prior=Files.exists(path)?Files.readAllBytes(path):null;
try{
Files.deleteIfExists(path);resetPreference();require(VanillaLight.saturation()==142,"Fresh installation uses 142");
Files.createDirectories(path.getParent());Files.writeString(path,"{\"enabled\":false}");resetPreference();require(VanillaLight.saturation()==142&&!VanillaLight.enabled(),"Older preference gets missing saturation without changing enabled");
for(int explicit:new int[]{0,100,170,200}){Files.writeString(path,"{\"enabled\":true,\"saturation\":"+explicit+"}");resetPreference();require(VanillaLight.saturation()==explicit,"An explicit saved preference remains respected");}
VanillaLight.setSaturation(142);resetPreference();require(VanillaLight.saturation()==142,"142 persists on disk");
}finally{if(prior==null)Files.deleteIfExists(path);else Files.write(path,prior);resetPreference();}
}catch(Exception e){throw new RuntimeException(e);}
}
private static void resetPreference()throws Exception{var field=VanillaLight.class.getDeclaredField("preferences");field.setAccessible(true);field.set(null,null);}
private static Vector3f direction(Ref r){var p=r.point();return new Vector3f(p.x(),p.y(),p.z()).normalize().rotateX(StarClient.angle()).rotateY(-(float)Math.PI/2);}
private static void aim(ClientGameTestContext c,Ref ref){var d=c.computeOnClient(m->direction(ref));c.getInput().lookAt((float)Math.toDegrees(Math.atan2(-d.x,d.z)),-(float)Math.toDegrees(Math.asin(d.y)));c.waitTicks(4);}
private static java.util.stream.Stream<GuiEventListener> widgets(ContainerEventHandler root){return root.children().stream().flatMap(w->w instanceof ContainerEventHandler child?java.util.stream.Stream.concat(java.util.stream.Stream.of(w),widgets(child)):java.util.stream.Stream.of(w));}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean yes,String message){if(!yes)throw new AssertionError(message);}
}
@@ -0,0 +1,126 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.stars.*;
import fr.koka.sanctuary.stars.ConstellationLedger.Ref;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.boats.ChickenBoat;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
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.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.vehicle.boat.AbstractBoat;
import net.minecraft.world.item.*;
import org.joml.Vector3f;
public final class SkyFlight046ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var reload=c.computeOnClient(m->{m.getLanguageManager().setSelected("en_us");m.options.languageCode="en_us";return m.reloadResourcePacks();});
c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();
c.runOnClient(m->{
m.updateTitle();
require(org.lwjgl.sdl.SDLVideo.SDL_GetWindowTitle(m.getWindow().handle()).equals(ApplicationBrand.title()),"Native window title uses Sanctuary");
require("Sanctuary".equals(org.lwjgl.sdl.SDLInit.SDL_GetAppMetadataProperty(org.lwjgl.sdl.SDLInit.SDL_PROP_APP_METADATA_NAME_STRING)),"Native app metadata uses Sanctuary");
});
var onboarding=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(onboarding.get()&&m.gui.screen() instanceof HelloWorldScreen s){
Starter039TestSupport.selectFirst(s);
widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b,"sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());onboarding.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{
s.setName("Sky flight046 "+UUID.randomUUID());s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setAllowCommands(true);
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.giveExperienceLevels(20);ProgressionService.send(p,"");});
c.waitFor(m->m.player.experienceLevel==20,100);
c.getInput().holdKey(InputConstants.KEY_C);c.waitTicks(3);
c.runOnClient(m->require(!ZoomClient.active(),"Zoom key does nothing before purchasing aptitude"));c.getInput().releaseKey(InputConstants.KEY_C);
c.runOnClient(m->ProgressionClient.open(null));c.waitTicks(3);
c.runOnClient(m->{
var buy=widgets(m.gui.screen()).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b,"sanctuary.zoom.buy")).findFirst().orElseThrow();
require(buy.active,"Zoom is purchasable in the aptitude list");buy.onPress(enter());
});
c.waitFor(m->ZoomClient.learned()&&m.player.experienceLevel==16,200);
for(String locale:new String[]{"en_us","fr_fr"}){
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());ProgressionClient.open(null);});
c.waitTicks(3);
c.takeScreenshot("sanctuary-beta046-aptitudes-"+locale+"-top");
for(int page=1;page<=14;page++){
c.runOnClient(m->{var screen=m.gui.screen();screen.mouseScrolled(screen.width-30,screen.height/2,0,-12);});
c.waitTicks(3);c.takeScreenshot("sanctuary-beta046-aptitudes-"+locale+"-"+page);
}
}
c.runOnClient(m->m.gui.setScreen(null));c.waitTicks(3);
int normal=c.computeOnClient(m->m.options.fov().get());
c.getInput().holdKey(InputConstants.KEY_C);c.waitFor(m->ZoomClient.active(),100);
c.getInput().scroll(100);c.waitTicks(5);
c.runOnClient(m->require(Math.abs(m.gameRenderer.mainCamera().getFov()-5)<.01,"Wheel reaches actual narrow projection"));
c.takeScreenshot("sanctuary-beta046-zoom-5");
c.getInput().scroll(-200);c.waitTicks(5);
c.runOnClient(m->require(Math.abs(m.gameRenderer.mainCamera().getFov()-110)<.01,"Wheel reaches actual Quake Pro projection"));
c.takeScreenshot("sanctuary-beta046-zoom-110");
c.getInput().releaseKey(InputConstants.KEY_C);c.waitTicks(5);
c.runOnClient(m->require(!ZoomClient.active()&&m.options.fov().get()==normal&&Math.abs(m.gameRenderer.mainCamera().getFov()-normal)<.5,"Releasing restores normal FOV without writing preferences"));
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();p.setItemInHand(net.minecraft.world.InteractionHand.MAIN_HAND,new ItemStack(Items.SPYGLASS));
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set midnight");
var stars=StarService.ledger(s);
for(int i=0;i<24;i++)stars.add(new StarLedger.Event("client046:"+i,"advancement",p.getUUID(),"Test","minecraft:test","2026-09-14T12:00:00Z",1));
try{stars.save();}catch(java.io.IOException e){throw new RuntimeException(e);}
});
c.waitFor(m->StarClient.starCount()>=24&&ConstellationClient.editing(),200);c.waitTicks(5);
var pair=c.computeOnClient(m->{
var refs=StarClient.markers().keySet().stream().map(seed->new Ref(seed,0)).filter(r->direction(r).y>.3).toList();
for(var a:refs)for(var b:refs){float dot=direction(a).dot(direction(b));if(!a.equals(b)&&dot>.4&&dot<.9)return List.of(a,b);}
throw new AssertionError("Two visible stars for the gesture test");
});
aim(c,pair.get(0));c.runOnClient(m->require(pair.get(0).equals(ConstellationClient.pick()),"Cursor finds the mother star without scoping"));
c.getInput().pressMouse(InputConstants.MOUSE_BUTTON_LEFT);c.waitFor(m->pair.get(0).equals(ConstellationClient.root()),100);
aim(c,pair.get(1));c.getInput().pressMouse(InputConstants.MOUSE_BUTTON_LEFT);
c.waitFor(m->ConstellationClient.edges().size()==1&&m.player.experienceLevel==13,200);
require(server.computeOnServer(s->ConstellationService.ledger(s).edges().size()==1),"Native click created shared server link");
var middle=c.computeOnClient(m->direction(pair.get(0)).add(direction(pair.get(1))).normalize());aim(c,middle);c.waitTicks(8);c.takeScreenshot("sanctuary-beta046-constellation");
c.getInput().holdKey(o->o.keyShift);c.getInput().pressMouse(InputConstants.MOUSE_BUTTON_RIGHT);c.getInput().releaseKey(o->o.keyShift);c.waitFor(m->ConstellationClient.edges().isEmpty()&&m.player.experienceLevel==13,200);
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();p.setItemInHand(net.minecraft.world.InteractionHand.MAIN_HAND,ItemStack.EMPTY);
var boat=EntityTypes.OAK_BOAT.create(s.overworld(),EntitySpawnReason.COMMAND);boat.setPos(p.position().add(0,12,0));
var chicken=EntityTypes.CHICKEN.create(s.overworld(),EntitySpawnReason.COMMAND);chicken.setPos(boat.position());
s.overworld().addFreshEntity(boat);s.overworld().addFreshEntity(chicken);chicken.startRiding(boat,true,true);p.startRiding(boat,true,true);
});
c.waitFor(m->m.player.getVehicle() instanceof AbstractBoat b&&b.getControllingPassenger()==m.player,200);
c.getInput().holdKey(o->o.keyJump);c.waitTicks(20);double initial=c.computeOnClient(m->m.player.getVehicle().getY());
c.waitTicks(100);double top=c.computeOnClient(m->m.player.getVehicle().getY());
require(top-initial>20&&top-initial<30,"Chicken-first boat ascends at about five blocks/sec for over the flight watchdog interval");
c.takeScreenshot("sanctuary-beta046-chicken-flight");
c.getInput().releaseKey(o->o.keyJump);c.waitTicks(20);double downStart=c.computeOnClient(m->m.player.getVehicle().getY());
c.waitTicks(80);double bottom=c.computeOnClient(m->m.player.getVehicle().getY());
require(downStart-bottom>16&&downStart-bottom<24,"Released Space descends at the same pace");
require(server.computeOnServer(s->s.getPlayerList().getPlayers().getFirst().getVehicle() instanceof AbstractBoat b&&b.isFlyingVehicle()),"Server retains pilot and accepts sustained native flight");
c.runOnClient(m->{m.updateTitle();require(org.lwjgl.sdl.SDLVideo.SDL_GetWindowTitle(m.getWindow().handle()).equals(ApplicationBrand.title()),"Title stays Sanctuary while playing");});
System.out.println("SKYFLIGHT046_CLIENT_PASS: native app identity, aptitude purchase, held-key FOV limits and release, first-person paid drawing/undo, chicken-first boat ascent/descent and flight watchdog");
}finally{onboarding.set(false);c.getInput().releaseKey(InputConstants.KEY_C);c.getInput().releaseKey(o->o.keyJump);}
}
private static Vector3f direction(Ref r){var p=r.point();return new Vector3f(p.x(),p.y(),p.z()).normalize().rotateX(StarClient.angle()).rotateY(-(float)Math.PI/2);}
private static void aim(ClientGameTestContext c,Ref ref){Vector3f v=c.computeOnClient(m->direction(ref));aim(c,v);}
private static void aim(ClientGameTestContext c,Vector3f direction){
float yaw=(float)Math.toDegrees(Math.atan2(-direction.x,direction.z));float pitch=-(float)Math.toDegrees(Math.asin(direction.y));
c.getInput().lookAt(yaw,pitch);c.waitTicks(4);
}
private static boolean key(Button b,String key){return b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key);}
private static Stream<GuiEventListener> widgets(ContainerEventHandler root){return root.children().stream().flatMap(w->w instanceof ContainerEventHandler child?Stream.concat(Stream.of(w),widgets(child)):Stream.of(w));}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean yes,String message){if(!yes)throw new AssertionError(message);}
}
@@ -0,0 +1,94 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.stars.*;
import fr.koka.sanctuary.stars.ConstellationLedger.Ref;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
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.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.TitleScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.world.item.*;
import org.joml.Vector3f;
public final class StarInspection047ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var reload=c.computeOnClient(m->{m.getLanguageManager().setSelected("en_us");m.options.languageCode="en_us";return m.reloadResourcePacks();});
c.waitFor(m->reload.isDone()&&m.gui.overlay()==null,1200);reload.join();
c.runOnClient(m->m.gui.setScreen(new TitleScreen(false)));c.waitTicks(3);
c.runOnClient(m->{var b=titleButton(m.gui.screen());move(m,b.getX()+b.getWidth()/2,b.getY()+10);});c.waitTicks(4);
c.runOnClient(m->require(titleButton(m.gui.screen()).getMessage().getStyle().getColor().getValue()==0xFFFF00,"Native active button label is vivid yellow on hover"));
c.takeScreenshot("sanctuary-beta047-hover-yellow");
c.runOnClient(m->titleButton(m.gui.screen()).active=false);c.waitTicks(2);
c.runOnClient(m->require(titleButton(m.gui.screen()).getMessage().getStyle().getColor().getValue()!=0xFFFF00,"Disabled button retains its native inactive color"));
c.runOnClient(m->{titleButton(m.gui.screen()).active=true;move(m,2,2);});c.waitTicks(2);
c.runOnClient(m->{var color=titleButton(m.gui.screen()).getMessage().getStyle().getColor();require(color==null||color.getValue()!=0xFFFF00,"Leaving hover restores original label color");});
var onboarding=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m->{if(onboarding.get()&&m.gui.screen() instanceof HelloWorldScreen s){
Starter039TestSupport.selectFirst(s);
widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
widgets(s).filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b,"sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());onboarding.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{
s.setName("Star inspection047 "+UUID.randomUUID());s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setAllowCommands(true);
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.setExperienceLevels(12);p.setItemInHand(net.minecraft.world.InteractionHand.MAIN_HAND,new ItemStack(Items.SPYGLASS));
s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set midnight");
var stars=StarService.ledger(s);
for(int i=0;i<24;i++)stars.add(new StarLedger.Event("client047:"+i,"advancement",p.getUUID(),"Aster "+i,"minecraft:story/mine_stone","2026-09-14T12:34:00Z",3));
try{stars.save();}catch(java.io.IOException e){throw new RuntimeException(e);}ProgressionService.send(p,"");
});
c.waitFor(m->StarClient.starCount()>=72&&ConstellationClient.editing()&&m.player.experienceLevel==12,200);c.waitTicks(5);
var refs=c.computeOnClient(m->StarClient.markers().entrySet().stream().filter(e->e.getValue()==3).map(e->new Ref(e.getKey(),0)).filter(r->direction(r).y>.3).limit(3).toList());
require(refs.size()==3,"Three visible named event stars for inspection");
aim(c,refs.get(0));c.getInput().pressMouse(InputConstants.MOUSE_BUTTON_LEFT);c.waitTicks(4);
aim(c,refs.get(1));c.getInput().pressMouse(InputConstants.MOUSE_BUTTON_LEFT);c.waitFor(m->ConstellationClient.edges().size()==1&&m.player.experienceLevel==9,200);
c.getInput().holdMouse(InputConstants.MOUSE_BUTTON_RIGHT);
try{c.waitFor(m->m.player.isScoping()&&StarInspectionClient.detail()!=null,200);}
catch(AssertionError e){diagnose(c,refs.get(1));server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();System.out.println("STAR047_DIAGNOSTIC server scope="+p.isScoping()+" description="+StarInspectionService.inspect(p,new StarInspectionPayloads.Query(refs.get(1),0)));});c.takeScreenshot("sanctuary-beta047-failure");throw e;}
c.runOnClient(m->{var d=StarInspectionClient.detail();require(d.star().equals(refs.get(1))&&d.name().getString().contains("Aster")&&d.origin().getString().contains("Stone Age")&&d.total()==1,"Scoped star has name, native advancement origin, and actual membership");require(ConstellationClient.edges().size()==1,"Normal right click observes without deleting the selected constellation");});
c.takeScreenshot("sanctuary-beta047-star-en");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();var ledger=ConstellationService.ledger(s);
require(ConstellationService.perform(p,new ConstellationPayloads.Request(false,refs.get(2),refs.get(2),refs.get(1),ledger.revision())).equals("added"),"Add a second real membership");});
try{c.waitFor(m->StarInspectionClient.detail()!=null&&StarInspectionClient.detail().total()==2&&m.player.experienceLevel==6,200);}
catch(AssertionError e){
diagnose(c,refs.get(1));c.takeScreenshot("sanctuary-beta047-failure");
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();System.out.println("STAR047_DIAGNOSTIC server scope="+p.isScoping()+" xp="+p.experienceLevel+" description="+StarInspectionService.inspect(p,new StarInspectionPayloads.Query(refs.get(1),0)));});throw e;
}
c.getInput().scroll(-1);c.waitFor(m->StarInspectionClient.detail()!=null&&StarInspectionClient.detail().page()==1,200);
c.runOnClient(m->require(m.player.isScoping()&&m.player.getMainHandItem().is(Items.SPYGLASS),"Paging the card does not change the selected hotbar item"));
c.runOnClient(m->{m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());});c.waitTicks(4);c.takeScreenshot("sanctuary-beta047-star-fr-page-2");
c.runOnClient(m->{var title=net.minecraft.network.chat.Component.translatable("advancements.story.mine_stone.title").getString();require(!title.equals("Stone Age")&&StarInspectionClient.detail().origin().getString().contains(title),"Native advancement title follows the client language");});
c.getInput().releaseMouse(InputConstants.MOUSE_BUTTON_RIGHT);c.waitFor(m->!m.player.isScoping()&&StarInspectionClient.detail()==null,100);
c.getInput().holdKey(o->o.keyShift);c.waitTicks(2);c.getInput().pressMouse(InputConstants.MOUSE_BUTTON_RIGHT);c.getInput().releaseKey(o->o.keyShift);
c.waitFor(m->ConstellationClient.edges().size()==1,200);
c.runOnClient(m->require(m.player.experienceLevel==6,"Reading and undo never spend extra XP or refund drawing"));
c.getInput().holdMouse(InputConstants.MOUSE_BUTTON_RIGHT);aim(c,refs.get(1));c.waitFor(m->StarInspectionClient.detail()!=null&&StarInspectionClient.detail().total()==1,200);
c.getInput().lookAt(0,45);c.waitFor(m->StarInspectionClient.detail()==null,100);
c.getInput().releaseMouse(InputConstants.MOUSE_BUTTON_RIGHT);
System.out.println("STAR047_CLIENT_PASS: native yellow hover, scope gestures, localized source and star name, live constellation membership, paging, no XP debit and prompt clearing");
}finally{onboarding.set(false);c.getInput().releaseMouse(InputConstants.MOUSE_BUTTON_RIGHT);c.getInput().releaseKey(o->o.keyShift);}
}
private static void diagnose(ClientGameTestContext c,Ref expected){c.runOnClient(m->System.out.println("STAR047_DIAGNOSTIC client scope="+m.player.isScoping()+" use="+m.options.keyUse.isDown()+" hand="+m.player.getMainHandItem()+" observing="+StarInspectionClient.observing()+" xp="+m.player.experienceLevel+" links="+ConstellationClient.edges().size()+" rev="+ConstellationClient.revision()+" target="+StarTargeting.pick(.2)+" detail="+StarInspectionClient.detail()+" focus="+m.isWindowActive()+" hit="+m.hitResult.getType()+" expected="+direction(expected)+" angle="+Math.toDegrees(Math.acos(Math.clamp(direction(expected).dot(new Vector3f((float)m.player.getLookAngle().x,(float)m.player.getLookAngle().y,(float)m.player.getLookAngle().z)),-1,1)))+" wider="+StarTargeting.pick(2.5)+" fov="+m.gameRenderer.mainCamera().getFov()+" look="+m.player.getLookAngle()+" stars="+m.gameRenderer.mainCamera().attributeProbe().getValue(net.minecraft.world.attribute.EnvironmentAttributes.STAR_BRIGHTNESS,1f)));}
private static void move(net.minecraft.client.Minecraft m,double x,double y){var w=m.getWindow();double sx=(double)w.getScreenWidth()/w.getGuiScaledWidth(),sy=(double)w.getScreenHeight()/w.getGuiScaledHeight();m.mouseHandler.onMove(w.handle(),x*sx,y*sy,(x-m.mouseHandler.getScaledXPos(w))*sx,(y-m.mouseHandler.getScaledYPos(w))*sy);m.mouseHandler.handleAccumulatedMovement();}
private static Button titleButton(net.minecraft.client.gui.screens.Screen s){return s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->key(b,"menu.singleplayer")).findFirst().orElseThrow();}
private static Vector3f direction(Ref r){var p=r.point();return new Vector3f(p.x(),p.y(),p.z()).normalize().rotateX(StarClient.angle()).rotateY(-(float)Math.PI/2);}
private static void aim(ClientGameTestContext c,Ref ref){Vector3f d=c.computeOnClient(m->direction(ref));c.getInput().lookAt((float)Math.toDegrees(Math.atan2(-d.x,d.z)),-(float)Math.toDegrees(Math.asin(d.y)));c.waitTicks(4);}
private static boolean key(Button b,String key){return b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key);}
private static Stream<GuiEventListener> widgets(ContainerEventHandler root){return root.children().stream().flatMap(w->w instanceof ContainerEventHandler child?Stream.concat(Stream.of(w),widgets(child)):Stream.of(w));}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean yes,String message){if(!yes)throw new AssertionError(message);}
}
@@ -0,0 +1,19 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.client.HelloWorldScreen;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.input.KeyEvent;
import java.util.*;
/** Existing scenarios select one starter before their normal colour/Enter steps. Tests only. */
public final class Starter039TestSupport implements ClientModInitializer {
public static boolean manual;
private static final Set<HelloWorldScreen> handled=Collections.newSetFromMap(new WeakHashMap<>());
public void onInitializeClient(){ClientTickEvents.END_CLIENT_TICK.register(m->{if(!manual&&m.gui.screen() instanceof HelloWorldScreen s&&!s.starterChoices().isEmpty()&&handled.add(s))selectFirst(s);});}
public static void selectFirst(HelloWorldScreen s){
var enter=new KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);
s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("StarterSlot")).findFirst().orElseThrow().onPress(enter);
}
}
@@ -0,0 +1,71 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.companion.CompanionType;
import fr.koka.sanctuary.cosmetics.FamiliarService;
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.*;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.TitleScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
public final class Starter052ClientChecks implements FabricClientGameTest {
private static final String NAME="Étoile de lune";
public void runTest(ClientGameTestContext c){
Starter039TestSupport.manual=true;
var choices=List.of("cat","chicken","pig").stream().map(id->new StarterCompanions.Choice(id,CompanionType.Rarity.COMMON)).toList();
var welcome=new StarterPayloads.Welcome(new ProgressionPayloads.Hello("Poupoutain",java.util.stream.IntStream.range(0,20).map(i->0xAAAA00+i*100).boxed().toList(),""),"",false,choices);
c.runOnClient(m->{require(m.player==null&&m.level==null,"Cold welcome before any player or level");m.gui.setScreen(new HelloWorldScreen(welcome,()->{}));});
for(String locale:List.of("en_us","fr_fr"))for(int scale:List.of(1,2)){
c.runOnClient(m->{
m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
var screen=(HelloWorldScreen)m.gui.screen();name(screen).setValue(NAME);Starter039TestSupport.selectFirst(screen);color(screen).onPress(enter());
m.options.guiScale().set(scale);m.resizeGui();screen=(HelloWorldScreen)m.gui.screen();
require(name(screen).getValue().equals(NAME),"Draft survives GUI resize");
screen.accept(new StarterPayloads.Welcome(new ProgressionPayloads.Hello("Poupoutain",welcome.identity().colors(),"color_taken"),"",false,choices));
require(name(screen).getValue().equals(NAME)&&screen.starterChoices().equals(choices),"Rejected colour preserves name and original eggs");
screen.accept(welcome);
var slots=screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("StarterSlot")).toList();
require(slots.getFirst().getX()==name(screen).getX()&&slots.getFirst().getX()==color(screen).getX(),"Egg choices align with the name field and colour controls on the left");
require(name(screen).getY()>slots.getFirst().getBottom()&&button(screen,"sanctuary.progression.enter").getY()>name(screen).getBottom(),"Name beneath eggs and above actions");
for(var child:screen.children())if(child instanceof AbstractWidget w)require(w.getX()>=0&&w.getRight()<=screen.width&&w.getY()>=0&&w.getBottom()<=screen.height,"Widget fits "+locale+" "+screen.width+"x"+screen.height);
require(!name(screen).getMessage().getString().startsWith("sanctuary."),"Localized name field");
System.out.println("STARTER052_LAYOUT "+locale+" gui="+screen.width+"x"+screen.height);
});c.waitTicks(5);c.takeScreenshot("sanctuary-beta052-hello-"+locale+"-"+scale);
}
var onboarding=new AtomicBoolean(true);
c.runOnClient(m->{m.gui.setScreen(new TitleScreen(false));m.options.guiScale().set(2);m.resizeGui();});
ClientTickEvents.END_CLIENT_TICK.register(m->{if(onboarding.get()&&m.gui.screen() instanceof HelloWorldScreen s){
require(m.player==null&&m.level==null,"Real naming happens in configuration");name(s).setValue(NAME);Starter039TestSupport.selectFirst(s);color(s).onPress(enter());button(s,"sanctuary.progression.enter").onPress(enter());onboarding.set(false);
}});
net.fabricmc.fabric.api.client.gametest.v1.world.TestWorldSave saved;
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Starter052 "+UUID.randomUUID());s.setSeed("52");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()){
saved=world.getWorldSave();world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();require(ProgressionService.session(s).starters.get(p.getUUID()).name().equals(NAME),"Name stored by real configuration handler");require(AccessoryAccess.of(p).getItem(AccessoryContainer.FAMILIAR).getCustomName().getString().equals(NAME),"Named starter delivered");FamiliarService.tick(p);s.saveEverything(true,true,true);});
c.waitFor(m->AccessoryClientName(m),200);
c.runOnClient(m->ProgressionClient.open(null,"identity"));c.waitTicks(5);c.takeScreenshot("sanctuary-beta052-inhabitant");c.runOnClient(m->m.gui.setScreen(null));
}
try(var world=saved.open()){
world.getConnection().waitForChunksDownload(2400);c.waitFor(m->m.player!=null&&ProgressionClient.active()&&m.gui.screen()==null,1200);
world.getServer().runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();require(ProgressionService.session(s).starters.get(p.getUUID()).name().equals(NAME),"Starter registry survives restart");require(AccessoryAccess.of(p).getItem(AccessoryContainer.FAMILIAR).getCustomName().getString().equals(NAME),"Player save retains custom name");require(!StarterCompanions.deliver(p,ProgressionService.session(s).starters),"No duplicate on reconnect");});
c.waitFor(m->AccessoryClientName(m),200);
}finally{Starter039TestSupport.manual=false;}
System.out.println("STARTER052_CLIENT_PASS: cold welcome, FR/EN, compact layout, draft retention, real named admission and saved reconnect");
}
private static boolean AccessoryClientName(net.minecraft.client.Minecraft m){var egg=AccessoryAccess.of(m.player).getItem(AccessoryContainer.FAMILIAR);return egg.getCustomName()!=null&&egg.getCustomName().getString().equals(NAME);}
private static EditBox name(Screen s){return (EditBox)widget(s,"sanctuary.starter.name");}
private static Button button(Screen s,String key){return (Button)widget(s,key);}
private static AbstractWidget widget(Screen s,String key){return s.children().stream().filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).filter(w->w.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals(key)).findFirst().orElseThrow();}
private static Button color(Screen s){return s.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow();}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean v,String m){if(!v)throw new AssertionError(m);}
}
@@ -0,0 +1,87 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.cosmetics.CarryService;
import fr.koka.sanctuary.progression.*;
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.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.effect.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.level.block.Blocks;
/** Real carry links, potion lifecycle and movement-attribute synchronization in a development client. */
public final class Strength055ClientChecks implements FabricClientGameTest {
private static final net.minecraft.resources.Identifier MODIFIER=fr.koka.sanctuary.SanctuaryMod.id("carrying_speed");
public void runTest(ClientGameTestContext c){
var onboarding=new AtomicBoolean(true);var base=new AtomicReference<Double>();var upper=new ArrayList<ServerPlayer>();
ClientTickEvents.END_CLIENT_TICK.register(m->{if(onboarding.get()&&m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
var enter=new net.minecraft.client.input.KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN,0,0);
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);onboarding.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Strength055 "+UUID.randomUUID());s.setSeed("55");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setAllowCommands(true);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.removeAllEffects();
var origin=p.blockPosition();for(int x=-4;x<=4;x++)for(int z=-4;z<=4;z++)for(int y=-1;y<12;y++)p.level().setBlockAndUpdate(origin.offset(x,y,z),(y==-1?Blocks.STONE:Blocks.AIR).defaultBlockState());
base.set(p.getAttributeValue(Attributes.MOVEMENT_SPEED));
var q=guest(p,"Passenger055");upper.add(q);q.setPos(p.position().add(0,0,1.5));
check(CarryService.interact(q,p),"A player can climb the host");ratio(p,base.get(),.75);
p.addEffect(new MobEffectInstance(MobEffects.STRENGTH,600,0));
});
c.waitTicks(3);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();ratio(p,base.get(),Math.sqrt(.75));p.addEffect(new MobEffectInstance(MobEffects.STRENGTH,600,1));});
c.waitTicks(3);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();ratio(p,base.get(),Math.cbrt(.75));p.addEffect(new MobEffectInstance(MobEffects.STRENGTH,600,255));});
c.waitTicks(3);
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();ratio(p,base.get(),Math.pow(.75,1.0/257));check(p.getAttributeValue(Attributes.MOVEMENT_SPEED)<=base.get(),"Even very high Strength never speeds carrying beyond unladen movement");
p.removeEffect(MobEffects.STRENGTH);var q=upper.getFirst();q.addEffect(new MobEffectInstance(MobEffects.STRENGTH,600,1));
var r=guest(p,"Top055");upper.add(r);r.setPos(q.position().add(0,0,1.5));check(CarryService.interact(r,q),"Second player joins the stack");
ratio(p,base.get(),.5625);ratio(q,base.get(),Math.cbrt(.75));
p.addEffect(new MobEffectInstance(MobEffects.STRENGTH,600,0));
});
c.waitTicks(3);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();ratio(p,base.get(),.75);p.addEffect(new MobEffectInstance(MobEffects.SPEED,600,0));});
c.waitTicks(3);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();ratio(p,base.get(),.75*1.2);check(p.getAttribute(Attributes.MOVEMENT_SPEED).getModifiers().stream().filter(m->m.id().equals(MODIFIER)).count()==1,"Only one carrying modifier, no accumulation across potion changes");});
c.waitFor(m->Math.abs(m.player.getAttributeValue(Attributes.MOVEMENT_SPEED)/base.get()-.9)<.0001,100);
server.runOnServer(s->{var p=s.getPlayerList().getPlayers().getFirst();p.removeEffect(MobEffects.SPEED);p.removeEffect(MobEffects.STRENGTH);p.addEffect(new MobEffectInstance(MobEffects.STRENGTH,8,0));});
c.waitTicks(12);
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();check(!p.hasEffect(MobEffects.STRENGTH),"Temporary Strength naturally expires");ratio(p,base.get(),.5625);
CarryService.detach(p);for(var q:upper){CarryService.detach(q);q.discard();}upper.clear();ratio(p,base.get(),1);
check(p.getAttribute(Attributes.MOVEMENT_SPEED).getModifier(MODIFIER)==null,"Dismount removes the carrying modifier");
p.addEffect(new MobEffectInstance(MobEffects.STRENGTH,600,1));
});
c.waitTicks(3);
server.runOnServer(s->{
var p=s.getPlayerList().getPlayers().getFirst();ratio(p,base.get(),1);
var cow=EntityTypes.COW.create(p.level(),EntitySpawnReason.COMMAND);cow.setNoAi(true);cow.setPos(p.position().add(0,0,1.5));p.level().addFreshEntity(cow);
check(CarryService.interact(p,cow),"Adult animal can be carried");ratio(p,base.get(),.75);CarryService.release(cow,true);
cow.setBaby(true);cow.setPos(p.position().add(0,0,1.5));check(CarryService.interact(p,cow),"Baby animal can be carried");ratio(p,base.get(),1);
CarryService.release(cow,true);cow.discard();p.removeAllEffects();ratio(p,base.get(),1);
System.out.println("STRENGTH055_PASS: carrier Strength I/II/256, whole stack, no rider-effect sharing, native expiry, Speed composition, client sync, adult/baby weight and dismount cleanup");
});
}
}
private static ServerPlayer guest(ServerPlayer host,String name){
var s=host.level().getServer();var profile=new com.mojang.authlib.GameProfile(UUID.randomUUID(),name);
var p=new ServerPlayer(s,host.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);
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.getAbilities().invulnerable=true;
p.connection.handleAcceptPlayerLoad(new net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket());host.level().addNewPlayer(p);return p;
}
private static void ratio(ServerPlayer p,double base,double expected){double actual=p.getAttributeValue(Attributes.MOVEMENT_SPEED)/base;check(Math.abs(actual-expected)<.0001,"Movement ratio for "+p.getName().getString()+": expected="+expected+" actual="+actual);}
private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,78 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import java.util.List;
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.gui.components.AbstractWidget;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.FriendsButton;
import net.minecraft.client.gui.components.SpriteIconButton;
import net.minecraft.client.gui.screens.TitleScreen;
import net.minecraft.client.gui.screens.options.OptionsScreen;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.contents.TranslatableContents;
/** Native menu navigation and sizing, without creating a world or changing friend settings. */
public final class Title045ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
c.runOnClient(m -> m.gui.setScreen(new TitleScreen(false)));
for (String locale : List.of("en_us","fr_fr")) {
String label=locale.equals("fr_fr")?"Amis":"Friends";
var reload=c.computeOnClient(m -> {
m.getLanguageManager().setSelected(locale);
m.options.languageCode=locale;
return m.reloadResourcePacks();
});
c.waitFor(m -> reload.isDone() && m.gui.overlay()==null,1200);
reload.join();
c.waitFor(m -> Component.translatable("sanctuary.menu.friends").getString().equals(label),300);
for (int scale : List.of(1,2,3,4)) {
c.runOnClient(m -> {
m.options.guiScale().set(scale);m.resizeGui();m.gui.setScreen(new TitleScreen(false));
var screen=(TitleScreen)m.gui.screen();
var solo=button(screen,"menu.singleplayer");
var multi=button(screen,"menu.multiplayer");
var friends=button(screen,"sanctuary.menu.friends");
require(friends.getWidth()==200 && friends.getHeight()==20,"Friends uses a normal full-width Minecraft button");
require(solo.getX()==multi.getX() && multi.getX()==friends.getX(),"Main buttons align");
require(multi.getY()==solo.getY()+24 && friends.getY()==multi.getY()+24,"Friends is directly below Multiplayer");
require(button(screen,"menu.options").getY()==friends.getY()+24,"Options follows without the empty icon row");
require(friends.active==(!m.isDemo()&&!m.isOfflineDeveloperMode()),"Native friend account restrictions are preserved");
var children=screen.children();
require(children.indexOf(solo)<children.indexOf(multi) && children.indexOf(multi)<children.indexOf(friends)
&& children.indexOf(friends)<children.indexOf(button(screen,"menu.options")),"Keyboard order follows the visible menu");
for (var child : children) if (child instanceof AbstractWidget widget) {
require(!(widget instanceof FriendsButton) && !(widget instanceof SpriteIconButton),"Compact icon buttons are absent");
require(!key(widget,"menu.online")&&!key(widget,"options.language")&&!key(widget,"options.accessibility"),"Removed entries are absent");
require(widget.getY()>=0 && widget.getY()+widget.getHeight()<=screen.height,"Widgets fit at GUI scale "+scale);
}
String version=FabricLoader.getInstance().getModContainer("sanctuary").orElseThrow().getMetadata().getVersion().getFriendlyString();
require(Component.translatable("sanctuary.menu.version",version).getString().equals("Sanctuary "+version),"Version label follows the installed mod");
System.out.println("TITLE045_LAYOUT "+locale+" requestedScale="+scale+" gui="+screen.width+"x"+screen.height);
});
c.waitTicks(5);
if (scale==2 || scale==4) c.takeScreenshot("sanctuary-beta045-title-"+locale+"-"+scale);
}
}
c.runOnClient(m -> button((TitleScreen)m.gui.screen(),"menu.options").onPress(enter()));
c.waitFor(m -> m.gui.screen() instanceof OptionsScreen,100);
c.runOnClient(m -> m.gui.setScreen(new TitleScreen(false)));
// Inspect the native route without accepting or modifying online friend settings.
c.runOnClient(m -> button((TitleScreen)m.gui.screen(),"sanctuary.menu.friends").onPress(enter()));
c.waitFor(m -> m.gui.screen() instanceof net.minecraft.client.gui.screens.friends.FriendsOverlayScreen
|| (m.gui.screen()!=null && m.gui.screen().getClass().getSimpleName().equals("FriendsListConfirmScreen")),100);
c.runOnClient(m -> { m.gui.setScreen(new TitleScreen(false));m.options.guiScale().set(2);m.resizeGui(); });
System.out.println("TITLE045_CLIENT_PASS: FR/EN, four GUI scales, full-width Friends, native options/friends routes and no compact/Realms entries");
}
private static Button button(TitleScreen screen,String name) {
return screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b -> key(b,name)).findFirst().orElseThrow();
}
private static boolean key(AbstractWidget widget,String name) {
return widget.getMessage().getContents() instanceof TranslatableContents text && text.getKey().equals(name);
}
private static KeyEvent enter() { return new KeyEvent(InputConstants.KEY_RETURN,0,0); }
private static void require(boolean value,String message) { if(!value) throw new AssertionError(message); }
}
@@ -0,0 +1,98 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.tutorial.*;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
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.networking.v1.ClientPlayNetworking;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.components.events.*;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.inventory.InventoryScreen;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.network.chat.contents.TranslatableContents;
/** Native E opening -> bounded server award -> spendable XP and rendered skill icons. */
public final class Tutorial016ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
var armed=new AtomicBoolean(true);
ClientTickEvents.END_CLIENT_TICK.register(m -> {
if(armed.get() && m.gui.screen() instanceof HelloWorldScreen 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());armed.set(false);
}
});
String language=c.computeOnClient(m -> m.getLanguageManager().getSelected());
int gui=c.computeOnClient(m -> m.options.guiScale().get());
var key=c.computeOnClient(m -> InputConstants.getKey(m.options.keyInventory.saveString()));
try(var world=c.worldBuilder().adjustSettings(s -> {
s.setName("Sanctuary beta016 tutorial "+UUID.randomUUID());
s.setWorldType(s.getNormalPresetList().stream().filter(e -> e.preset()!=null && e.preset().getRegisteredName().equals("sanctuary:sanctuary")).findFirst().orElseThrow());
s.setSeed("42");s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);
}).create()) {
world.getConnection().waitForChunksDownload(2400);
c.waitFor(m -> m.player!=null && ProgressionClient.active() && TutorialClient.completedMask>=0 && m.gui.screen()==null,1200);
c.runOnClient(m -> {m.options.keyInventory.setKey(InputConstants.Type.KEYBOARD.getOrCreate(InputConstants.KEY_E));KeyMapping.resetMapping();});
world.getServer().runOnServer(s -> {
var p=s.getPlayerList().getPlayers().getFirst();
// Isolate inventory opening from the fresh world's automatic tree observation.
var progress=TutorialProgress.EMPTY;for(String step:TutorialProgress.STEPS)if(!step.equals("open_inventory"))progress=progress.complete(step,8);
p.setAttached(TutorialService.RECORD,progress.json());TutorialService.initialize(p);
p.setExperienceLevels(0);p.setExperiencePoints(0);p.totalExperience=0;ProgressionService.send(p,"");
});
c.waitFor(m -> TutorialClient.completedMask==23 && m.player.experienceLevel==0,200);
c.getInput().pressKey(InputConstants.KEY_E);
c.waitFor(m -> m.gui.screen() instanceof InventoryScreen && TutorialClient.completedMask==31 && m.player.experienceLevel==1,200);
require(world.getServer().computeOnServer(s -> s.getPlayerList().getPlayers().getFirst().totalExperience==8),"Native E triggers exactly eight server XP");
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitForScreen(null);c.waitTicks(3);
c.getInput().pressKey(InputConstants.KEY_E);c.waitFor(m -> m.gui.screen() instanceof InventoryScreen,200);
c.runOnClient(m -> {for(int i=0;i<20;i++)ClientPlayNetworking.send(new TutorialPayloads.InventoryOpened());});c.waitTicks(25);
require(world.getServer().computeOnServer(s -> s.getPlayerList().getPlayers().getFirst().totalExperience==8),"Reopening and repeated client packets never pay twice");
c.getInput().pressKey(InputConstants.KEY_ESCAPE);c.waitForScreen(null);c.waitTicks(3);
for(String locale:new String[]{"en_us","fr_fr"}) {
c.runOnClient(m -> {
m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
m.options.guiScale().set(0);m.resizeGui();ProgressionClient.open(null);
require(!TutorialService.label("reward",TutorialService.label("open_inventory"),8,1,5).getString().contains("sanctuary."),"Reward is translated in both languages");
});c.waitTick();
c.runOnClient(m -> {
require(widgets(m.gui.screen()).filter(ImageWidget.class::isInstance).count()==6,"Six new skill icons are rendered");
require(plus(m.gui.screen(),"vitality").active && plus(m.gui.screen(),"vitality").visible,"One tutorial reward makes a skill affordable");
});
c.takeScreenshot("sanctuary-beta016-tutorial-xp-"+locale);
}
c.runOnClient(m -> plus(m.gui.screen(),"vitality").onPress(enter()));
c.waitFor(m -> ProgressionClient.progress.vitality()==1 && m.player.experienceLevel==0,200);
require(world.getServer().computeOnServer(s -> {var p=s.getPlayerList().getPlayers().getFirst();return p.getMaxHealth()==8 && TutorialService.progress(p).done("open_inventory");}),"Real purchase spends tutorial XP and leaves the claim intact");
System.out.println("Tutorial016 native client passed: E, repeat protection, FR/EN, six icons, funded purchase");
} finally {
armed.set(false);c.runOnClient(m -> {
m.options.keyInventory.setKey(key);KeyMapping.resetMapping();
m.getLanguageManager().setSelected(language);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());m.options.guiScale().set(gui);m.resizeGui();
});
}
}
private static Button plus(Screen screen,String skill) {
var name=widgets(screen).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast)
.filter(w -> key(w,"sanctuary.progression."+skill)).findFirst().orElseThrow();
return widgets(screen).filter(Button.class::isInstance).map(Button.class::cast)
.filter(b -> b.getY()==name.getY() && b.getMessage().getString().equals("+")).findFirst().orElseThrow();
}
private static Stream<GuiEventListener> widgets(GuiEventListener widget) {
return Stream.concat(Stream.of(widget),widget instanceof ContainerEventHandler parent
? parent.children().stream().flatMap(Tutorial016ClientChecks::widgets) : Stream.empty());
}
private static boolean key(AbstractWidget widget,String key) {return widget.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key);}
private static Button button(Screen screen,String key) {return widgets(screen).filter(Button.class::isInstance).map(Button.class::cast).filter(b -> key(b,key)).findFirst().orElseThrow();}
private static KeyEvent enter() {return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void require(boolean ok,String message) {if(!ok)throw new AssertionError(message);}
}
@@ -0,0 +1,111 @@
package fr.koka.sanctuary.gametest;
import com.mojang.blaze3d.platform.InputConstants;
import fr.koka.sanctuary.client.*;
import fr.koka.sanctuary.realtime.*;
import fr.koka.sanctuary.weather.*;
import java.nio.file.*;
import java.time.*;
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.ClientGameTestContext;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.input.KeyEvent;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import net.minecraft.network.chat.contents.TranslatableContents;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.gamerules.GameRules;
public final class Weather057ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c){
var onboarding=new AtomicBoolean(true);byte[] prior;
try{prior=Files.exists(WeatherService.CONFIG)?Files.readAllBytes(WeatherService.CONFIG):null;Files.createDirectories(WeatherService.CONFIG.getParent());Files.writeString(WeatherService.CONFIG,WeatherConfig.DEFAULT.json());}catch(Exception e){throw new RuntimeException(e);}
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("Weather057 "+UUID.randomUUID());s.setSeed("57");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&&ProgressionClient.active()&&m.gui.screen()==null,1200);
var server=world.getServer();var day=new AtomicReference<>(LocalDate.of(2026,6,21));
server.runOnServer(s->{
check(WeatherService.active(s.overworld())&&RealtimeService.active(s.overworld()),"Daily weather and real sun active by default");
check(!WeatherService.active(s.getLevel(net.minecraft.world.level.Level.NETHER))&&!WeatherService.active(s.getLevel(net.minecraft.world.level.Level.END)),"Weatherless dimensions remain unmanaged");
s.getGlobalGameRules().set(GameRules.ADVANCE_WEATHER,true,s);s.getPlayerList().getPlayers().getFirst().getAbilities().invulnerable=true;
checked(()->{
var commands=s.getCommands().getDispatcher();var source=s.createCommandSourceStack();
check(commands.execute("sanctuary weather",source)==1,"Status command registered with other Sanctuary branches");
check(commands.execute("sanctuary time vanilla",source)==1&&WeatherService.active(s.overworld()),"Weather independent from sun mode");
check(commands.execute("sanctuary time realtime",source)==1,"Sun mode restored");
var host=s.getPlayerList().getPlayers().getFirst();var branch=commands.getRoot().getChild("sanctuary").getChild("weather");
s.getWorldData().setAllowCommands(false);
try{check(branch.canUse(host.createCommandSourceStack()),"Weather status visible normally");for(String mode:List.of("realtime","vanilla","reload"))check(!branch.getChild(mode).canUse(host.createCommandSourceStack()),"Weather mutation hidden without Allow Commands: "+mode);}
finally{s.getWorldData().setAllowCommands(true);}
});
});
c.getInput().lookAt(0,-5);
for(var kind:List.of(DailyWeather.Kind.RAIN,DailyWeather.Kind.THUNDER,DailyWeather.Kind.OVERCAST,DailyWeather.Kind.FOG,DailyWeather.Kind.CLEAR)){
server.runOnServer(s->{
LocalDate next=day.get().plusDays(1);while(DailyWeather.at(s.overworld().getSeed(),next,WeatherConfig.DEFAULT)!=kind)next=next.plusDays(1);day.set(next);at(s,next.atTime(12,0).atZone(RealtimeService.civilTime(s).getZone()).toInstant());
});
c.waitTicks(25);
c.waitFor(m->WeatherClient.state().active()&&WeatherClient.state().kind()==kind,100);
server.runOnServer(s->{var d=s.overworld().getWeatherData();check(d.isRaining()==kind.raining()&&d.isThundering()==kind.thundering(),"Native state matches daily "+kind);check(WeatherService.session(s).state().date()==day.get().toEpochDay(),"Day boundary committed");d.setClearWeatherTime(1);d.setRainTime(1);d.setThunderTime(1);});
c.waitFor(m->(kind.raining()?m.level.getRainLevel(1)>.95:m.level.getRainLevel(1)<.01)&&(kind.thundering()?m.level.getThunderLevel(1)>.95:m.level.getThunderLevel(1)<.01),300);
c.waitTicks(25);
if(kind==DailyWeather.Kind.FOG||kind==DailyWeather.Kind.OVERCAST||kind==DailyWeather.Kind.CLEAR)c.waitTicks(205);
c.runOnClient(m->{
float end=m.level.environmentAttributes().getDimensionValue(net.minecraft.world.attribute.EnvironmentAttributes.FOG_END_DISTANCE);
check(kind==DailyWeather.Kind.FOG?end<=80.01:end>80.01,"Native environment fog distance follows the server atmosphere");
if(kind==DailyWeather.Kind.OVERCAST){var color=m.level.environmentAttributes().getDimensionValue(net.minecraft.world.attribute.EnvironmentAttributes.SKY_COLOR);check(Math.max(color.x(),Math.max(color.y(),color.z()))-Math.min(color.x(),Math.min(color.y(),color.z()))<.12,"Cloudy sky is gray without fake rain");}
if(kind==DailyWeather.Kind.FOG){float gpu=m.gameRenderer.gameRenderState().levelRenderState.cameraRenderState.fogData.environmentalEnd;check(gpu<=80.01,"Native rendered fog actually uses the shorter weather distance");}
});
c.takeScreenshot("sanctuary-beta057-daylight-"+kind.key());
server.runOnServer(s->{var d=s.overworld().getWeatherData();check(d.getRainTime()==1&&d.getThunderTime()==1&&d.getClearWeatherTime()==1,"Native random countdown is suspended while interpolation continues");check(s.getGlobalGameRules().get(GameRules.ADVANCE_WEATHER),"Saved native weather rule remains untouched");
checked(()->{var commands=s.getCommands().getDispatcher();var source=s.createCommandSourceStack();for(String name:List.of("clear","rain","thunder"))check(commands.execute("weather "+name+" 10",source)==0,"Native command explains managed weather");});
s.overworld().resetWeatherCycle();check(d.isRaining()==kind.raining()&&d.isThundering()==kind.thundering(),"Sleep cannot clear daily forecast");
at(s,day.get().atTime(23,59,59).atZone(RealtimeService.civilTime(s).getZone()).toInstant());
});c.waitTicks(25);
server.runOnServer(s->check(WeatherService.session(s).state().date()==day.get().toEpochDay()&&WeatherService.session(s).state().weather()==kind,"The whole civil day retains the same weather"));
c.takeScreenshot("sanctuary-beta057-daily-"+kind.key());
System.out.println("WEATHER057_NATIVE: "+kind+" all day, client interpolation and native effects");
}
var wetTime=new AtomicReference<Instant>();var dryTime=new AtomicReference<Instant>();
server.runOnServer(s->{LocalDate next=day.get().plusDays(1);while(DailyWeather.at(s.overworld().getSeed(),next,WeatherConfig.DEFAULT)!=DailyWeather.Kind.SHOWERS)next=next.plusDays(1);day.set(next);var first=next.atStartOfDay(RealtimeService.civilTime(s).getZone());
for(int i=0;i<180;i++){var moment=first.plusMinutes(i);if(DailyWeather.raining(DailyWeather.Kind.SHOWERS,s.overworld().getSeed(),moment))wetTime.compareAndSet(null,moment.toInstant());else dryTime.compareAndSet(null,moment.toInstant());}
check(wetTime.get()!=null&&dryTime.get()!=null,"Shower day has both wet and dry moments");at(s,wetTime.get());
});c.waitFor(m->WeatherClient.state().kind()==DailyWeather.Kind.SHOWERS&&m.level.getRainLevel(1)>.95,350);c.takeScreenshot("sanctuary-beta057-showers-wet");
server.runOnServer(s->at(s,dryTime.get()));c.waitFor(m->WeatherClient.state().kind()==DailyWeather.Kind.SHOWERS&&m.level.getRainLevel(1)<.01,350);c.takeScreenshot("sanctuary-beta057-showers-dry");
server.runOnServer(s->checked(()->{WeatherService.reload(s);check(WeatherService.session(s).state().weather()==DailyWeather.Kind.SHOWERS&&!s.overworld().getWeatherData().isRaining(),"Reload retains both the daily profile and its current dry interval");}));
server.runOnServer(s->checked(()->{
var before=WeatherService.session(s).state();var commands=s.getCommands().getDispatcher();var source=s.createCommandSourceStack();
check(commands.execute("sanctuary weather vanilla",source)==1&&!WeatherService.active(s.overworld())&&RealtimeService.active(s.overworld()),"Vanilla weather independent from solar mode");
commands.execute("weather thunder 600",source);check(s.overworld().getWeatherData().isThundering(),"Native command works again");
check(commands.execute("sanctuary weather realtime",source)==1&&WeatherService.session(s).state().equals(before),"Toggling cannot reroll today's draw");
Path path=s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-weather.json");byte[] bytes=Files.readAllBytes(path);
WeatherService.reload(s);check(WeatherService.session(s).state().equals(before)&&Arrays.equals(bytes,Files.readAllBytes(path)),"Server-side reload preserves daily journal");
Files.writeString(WeatherService.CONFIG,new WeatherConfig(true,0,0,0,100,0).json());WeatherService.reload(s);check(WeatherService.session(s).state().equals(before),"Changing probabilities does not interrupt today's forecast");
Files.writeString(WeatherService.CONFIG,"{broken");boolean rejected=false;try{WeatherService.reload(s);}catch(Exception expected){rejected=true;}check(rejected&&WeatherService.active(s.overworld())&&Files.readString(WeatherService.CONFIG).equals("{broken"),"Invalid config preserves previous working session");
Files.writeString(WeatherService.CONFIG,new WeatherConfig(false,15,5,10,3,2).json());WeatherService.reload(s);check(!WeatherService.active(s.overworld()),"Global disable restores vanilla");
WeatherService.setMode(s,false);Files.writeString(WeatherService.CONFIG,WeatherConfig.DEFAULT.json());WeatherService.reload(s);check(!WeatherService.active(s.overworld()),"Explicit world opt-out survives config re-enable");
WeatherService.setMode(s,true);WeatherService.setMode(s,false);s.overworld().resetWeatherCycle();check(!s.overworld().getWeatherData().isRaining(),"Native reset available again");
s.getCommands().performPrefixedCommand(source,"weather rain 600");
}));c.waitTicks(25);
server.runOnServer(s->{var d=s.overworld().getWeatherData();check(d.getRainTime()>0&&d.getRainTime()<12000,"Native durations resume after leaving daily mode");});
for(String locale:List.of("fr_fr","en_us")){
c.runOnClient(m->{m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());for(String key:List.of("status","vanilla","clear","overcast","fog","showers","rain","thunder","inactive","invalid","managed"))check(!WeatherService.label(key,"Pluie","2026-09-15","Europe/Paris").getString().contains("sanctuary.weather."),"Localized weather text "+locale+"/"+key);});
}
System.out.println("WEATHER057_PASS: daily clear/overcast/fog/showers/rain/thunder, wet/dry alternation, full civil days, date rollover, native interpolation and client sync, cycle/sleep suspension, independent modes, commands and operator gates, reloads, configuration errors, vanilla return, FR/EN");
}finally{
onboarding.set(false);
checked(()->{if(prior==null)Files.deleteIfExists(WeatherService.CONFIG);else Files.write(WeatherService.CONFIG,prior);});
}
}
private static void at(MinecraftServer s,Instant instant){checked(()->{var field=RealtimeService.Session.class.getDeclaredField("clock");field.setAccessible(true);field.set(RealtimeService.session(s),Clock.fixed(instant,ZoneOffset.UTC));RealtimeService.tick(s);s.getPlayerList().broadcastAll(s.clockManager().createFullSyncPacket());});}
private interface Checked{void run()throws Exception;}
private static void checked(Checked action){try{action.run();}catch(Exception e){throw new RuntimeException(e);}}
private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);}
private static void check(boolean value,String why){if(!value)throw new AssertionError(why);}
}
@@ -0,0 +1,14 @@
package fr.koka.sanctuary.gametest.mixin;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.*;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(Minecraft.class)
abstract class ClientVsync046Mixin {
@Inject(method="renderFrame",at=@At("HEAD"))
private void sanctuary$testPresentation(CallbackInfo ci){
if(Boolean.getBoolean("sanctuary.test.noVsync")){
var m=(Minecraft)(Object)this;if(m.options.enableVsync().get())m.options.enableVsync().set(false);
}
}
}
@@ -0,0 +1,125 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.cycle.*;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.*;
import net.minecraft.world.inventory.ContainerInput;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.*;
import net.minecraft.world.phys.Vec3;
public final class Accessory018GameTests {
@GameTest public void typedEquipmentAndShiftClickAtEveryCapacity(GameTestHelper h) {
for(int rank=0;rank<=5;rank++) {
var p=player(h,rank);var menu=p.inventoryMenu;var e=(SanctuaryExtendedInventoryMenu)menu;var a=AccessoryAccess.of(p);
h.assertTrue(e.sanctuary$accessoryStart()==64 && e.sanctuary$overflowEnd()==64 && menu.slots.size()==67,"Stable storage addresses with three appended slots");
p.getInventory().setItem(0,new ItemStack(Items.COW_SPAWN_EGG,5));menu.quickMoveStack(p,36);
h.assertTrue(a.getItem(0).getCount()==1 && p.getInventory().getItem(0).getCount()==4,"Shift equips exactly one egg");
menu.quickMoveStack(p,64);h.assertTrue(a.isEmpty() && p.getInventory().getItem(0).getCount()==5,"Shift unequips by merging into existing egg pile");
p.getInventory().setItem(1,new ItemStack(AccessoryService.ZERO_CAPE));menu.quickMoveStack(p,37);
h.assertTrue(a.getItem(1).is(AccessoryService.ZERO_CAPE),"Cape auto-equips");
h.assertTrue(!menu.getSlot(64).mayPlace(new ItemStack(Items.STONE)) && !menu.getSlot(65).mayPlace(new ItemStack(Items.ELYTRA)),"Typed slots reject unrelated items");
menu.setCarried(new ItemStack(Items.PISTON,12));menu.clicked(66,0,ContainerInput.PICKUP,p);
h.assertTrue(a.getItem(2).getCount()==1 && a.getItem(2).is(Items.PISTON) && menu.getCarried().getCount()==11,"Any block can be deliberately equipped, one unit only");
menu.setCarried(ItemStack.EMPTY);
for(int i=0;i<36;i++)p.getInventory().setItem(i,new ItemStack(Items.STONE,64));
for(int i=43;i<=60;i++)p.getInventory().setItem(i,new ItemStack(Items.STONE,64));
menu.quickMoveStack(p,65);h.assertTrue(a.getItem(1).is(AccessoryService.ZERO_CAPE),"Full storage never deletes equipped cape");
p.getInventory().setItem(8,ItemStack.EMPTY);menu.quickMoveStack(p,65);
h.assertTrue(a.getItem(1).isEmpty() && p.getInventory().getItem(8).is(AccessoryService.ZERO_CAPE),"An eligible free hotbar slot accepts unequip at every capacity");
var equipped=a.getItems().stream().map(ItemStack::copy).toList();bind(p,ProgressEffects.get(p).buy("sorting",4));
InventorySortService.sort(p,new InventorySortPayloads.Sort(1,menu.containerId,menu.getStateId(),ProgressEffects.get(p).revision(),InventorySortPayloads.STORAGE));
h.assertTrue(ItemStack.listMatches(equipped,a.getItems()),"Storage sorting never touches accessory equipment");
}
h.succeed();
}
@GameTest public void storageMigrationAppearanceAndDeath(GameTestHelper h) {
var p=player(h,5);var a=AccessoryAccess.of(p);equip(a);
p.setItemSlot(EquipmentSlot.HEAD,new ItemStack(Items.IRON_HELMET));p.doTick();
h.assertTrue(p.getArmorValue()==2,"Cosmetic netherite helmet grants no armor over native iron helmet");
AccessoryService.publish(p);
h.assertTrue(p.getAttachedOrThrow(AccessoryService.APPEARANCE).head().is(Items.NETHERITE_HELMET),"Server appearance attachment has exact equipped item");
var output=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());p.saveWithoutId(output);
var saved=output.buildResult();var loaded=player(h,0);
loaded.load(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),saved));
h.assertTrue(ItemStack.listMatches(a.getItems(),AccessoryAccess.of(loaded).getItems()),"Save reload preserves all three separate stacks");
var keep=player(h,0);keep.restoreFrom(p,true);
h.assertTrue(ItemStack.listMatches(a.getItems(),AccessoryAccess.of(keep).getItems()),"Native keepInventory copies accessories");
var old=saved.copy();old.remove("sanctuary:accessories");
var migrated=player(h,0);migrated.load(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),old));
h.assertTrue(AccessoryAccess.of(migrated).isEmpty(),"Beta017 player receives empty slots without rearranging inventory");
var before=h.getLevel().getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class,p.getBoundingBox().inflate(5)).size();
p.getInventory().dropAll();
h.assertTrue(a.isEmpty() && h.getLevel().getEntitiesOfClass(net.minecraft.world.entity.item.ItemEntity.class,p.getBoundingBox().inflate(5)).size()==before+4,"Death drops three accessories plus native helmet once");
h.succeed();
}
@GameTest public void futureAccessoryDataIsRetained(GameTestHelper h) {
var p=player(h,0);var input=new CompoundTag();var future=new CompoundTag();future.putInt("schema",99);future.putString("future","do not erase");input.put("sanctuary:accessories",future);
var a=AccessoryAccess.of(p);a.read(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),input));
h.assertTrue(!a.readable() && !p.inventoryMenu.getSlot(64).mayPlace(new ItemStack(Items.COW_SPAWN_EGG)),"Unknown schema cannot be modified");
var out=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());a.write(out);
h.assertTrue(out.buildResult().getCompound("sanctuary:accessories").orElseThrow().equals(future),"Unknown data round-trips unchanged");h.succeed();
}
@GameTest public void prestigePreservesAccessories(GameTestHelper h) throws Exception {
var p=player(h,5);equip(AccessoryAccess.of(p));
var s=ProgressionService.session(p.level().getServer());s.inhabitants.create(p.getUUID(),"pet"+p.getUUID().toString().substring(0,8),"",s.inhabitants.available().getFirst());
var progress=ProgressEffects.get(p);for(String skill:ProgressRecord.SKILLS)while(progress.rank(skill)<ProgressRecord.cap(skill))progress=progress.buy(skill,1);
bind(p,progress);var before=AccessoryAccess.of(p).getItems().stream().map(ItemStack::copy).toList();
h.assertTrue(CycleService.perform(p,new CyclePayloads.Action("ngp","","",s.cycles.revision(),progress.revision())).equals("done"),"Actual prestige accepted");
h.assertTrue(ProgressEffects.get(p).inventoryRows()==1 && ItemStack.listMatches(before,AccessoryAccess.of(p).getItems()),"Prestige keeps accessories while inventory capacity returns to one row");h.succeed();
}
@GameTest public void familiarIsHarmlessAndEscapesWalls(GameTestHelper h) {
var p=player(h,0);platform(h,p.blockPosition());AccessoryAccess.of(p).setItem(0,new ItemStack(Items.CREEPER_SPAWN_EGG));
FamiliarService.tick(p);var pet=FamiliarService.active(p);h.assertTrue(pet!=null,"Dedicated moving familiar spawns");
h.assertTrue(!pet.isNoAi() && pet.getTarget()==null && !pet.shouldBeSaved(),"Navigation runs without destructive mob AI or chunk persistence");
float health=pet.getHealth();h.assertTrue(!pet.hurtServer(h.getLevel(),p.damageSources().genericKill(),10000) && pet.getHealth()==health,"Even bypass damage cannot injure familiar");
h.assertTrue(!pet.isPushable() && !pet.canBeCollidedWith(p) && !pet.canCollideWith(p),"No entity collision or pushing");
var pos=pet.position();pet.push(p);h.assertTrue(pet.position().equals(pos),"Contact does not shove pet");
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.LEAD));
h.assertTrue(pet.interact(p,InteractionHand.MAIN_HAND,Vec3.ZERO).consumesAction() && p.getMainHandItem().getCount()==1 && !pet.isLeashed(),"Right-click pets without consuming a lead or adding its native behavior");
var wall=p.blockPosition().offset(2,0,0);h.getLevel().setBlockAndUpdate(wall,Blocks.STONE.defaultBlockState());pet.setPos(Vec3.atBottomCenterOf(wall));
h.assertTrue(pet.teleportNearOwner() && h.getLevel().noBlockCollision(pet,pet.getBoundingBox()),"Safe fallback exits a wall into free supported space");
var saved=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());h.assertTrue(!pet.save(saved),"Temporary pet is excluded from saves");
FamiliarService.tick(p);h.assertTrue(FamiliarService.active(p)==pet,"Repeated service tick never duplicates pet");
AccessoryAccess.of(p).setItem(0,ItemStack.EMPTY);FamiliarService.tick(p);h.assertTrue(pet.isRemoved() && FamiliarService.active(p)==null,"Removing egg removes familiar");h.succeed();
}
@GameTest public void familiarWalksAroundObstacle(GameTestHelper h) {
var p=player(h,0);platform(h,p.blockPosition());var origin=p.blockPosition();
AccessoryAccess.of(p).setItem(0,new ItemStack(Items.WOLF_SPAWN_EGG));FamiliarService.tick(p);var pet=FamiliarService.active(p);
h.assertTrue(pet!=null,"Walking fixture has a pet");
pet.setPos(Vec3.atBottomCenterOf(origin.offset(-3,0,0)));pet.setOnGround(true);p.setPos(Vec3.atBottomCenterOf(origin.offset(3,0,0)));
for(int z=-1;z<=1;z++)for(int y=0;y<2;y++)h.getLevel().setBlockAndUpdate(origin.offset(0,y,z),Blocks.STONE.defaultBlockState());
h.assertTrue(pet.getNavigation().moveTo(p,1.15) && pet.getNavigation().getPath()!=null,"Native navigator finds a route around a two-block wall");
var route=pet.getNavigation().getPath();boolean around=false;
for(int i=0;i<route.getNodeCount();i++)if(Math.abs(route.getNode(i).z-origin.getZ())>=2)around=true;
h.assertTrue(around,"Route leaves the blocked straight line before reaching owner");pet.discard();h.succeed();
}
@GameTest public void changingHotbarInPlayStopsUsingOldItem(GameTestHelper h) {
var p=player(h,5);p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.BOW));p.startUsingItem(InteractionHand.MAIN_HAND);
h.assertTrue(p.isUsingItem(),"Fixture is drawing a bow");
var menu=p.inventoryMenu;
h.assertTrue(HotbarService.rotate(p,new HotbarPayloads.Rotate(1,menu.containerId,menu.getStateId(),ProgressEffects.get(p).revision(),1)),"Same native inventory menu can rotate while playing");
h.assertTrue(!p.isUsingItem() && HotbarRows.selected(p)==5 && p.getInventory().getItem(52).is(Items.BOW),"Row changes atomically and cancels using the old item without firing");h.succeed();
}
private static void platform(GameTestHelper h,BlockPos at) {
for(int x=-6;x<=6;x++)for(int z=-6;z<=6;z++)for(int y=-1;y<=3;y++)h.getLevel().setBlockAndUpdate(at.offset(x,y,z),(y==-1 ? Blocks.STONE : Blocks.AIR).defaultBlockState());
}
private static void equip(AccessoryContainer a) {a.setItem(0,new ItemStack(Items.COW_SPAWN_EGG));a.setItem(1,new ItemStack(AccessoryService.ZERO_CAPE));a.setItem(2,new ItemStack(Items.NETHERITE_HELMET));}
private static ServerPlayer player(GameTestHelper h,int rank) {
var p=Progression003GameTests.player(h);p.setUUID(UUID.randomUUID());var r=ProgressRecord.INITIAL;
for(int i=0;i<rank;i++)r=r.buy("inventory",1);bind(p,r);p.getInventory().clearContent();
((SanctuaryExtendedInventoryMenu)p.inventoryMenu).sanctuary$ensureExtendedInventory();return p;
}
private static void bind(ServerPlayer p,ProgressRecord record) {p.setAttached(ProgressionService.RECORD,record.json());ProgressEffects.apply(p,record);}
}
@@ -0,0 +1,221 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import fr.koka.sanctuary.expansion.*;
import fr.koka.sanctuary.worldgen.SanctuaryChunkGenerator;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.RegistryOps;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.storage.LevelResource;
/** Real beta worlds: settings persistence, native landscapes and second-process player-edit witnesses. */
public final class AncientExpeditions001GameTests {
@GameTest(maxTicks = 12000)
public void initialLayoutAndSettingsPersist(GameTestHelper helper) throws Exception {
var host = (SanctuaryChunkGenerator) helper.getLevel().getChunkSource().getGenerator();
if (!host.ancientExpeditions()) { helper.succeed(); return; }
var level = helper.getLevel();
var session = ExpansionRuntime.session(level);
var expected = (host.expeditionRevision() == 2 ? AncientExpeditions002.plan(level.getSeed(), host.initialDiameter(), host.aerialPlan24())
: AncientExpeditions001.plan(level.getSeed(), host.initialDiameter(), host.aerialPlan24()));
helper.assertTrue(session.islands().equals(expected), "All five regions exist before exploration");
Path directory = level.getServer().getWorldPath(LevelResource.ROOT).resolve("data/" + host.journalDirectory());
byte[] before = Files.readAllBytes(directory.resolve(ExpansionJournal.FILE_NAME));
var reloaded = new ExpansionJournal(directory, level.getSeed(), host.initialDiameter(), 24, "", true, host.expeditionRevision());
helper.assertTrue(reloaded.islands().equals(expected), "Every region field survives journal reload");
reloaded.commit(reloaded.islands());
helper.assertTrue(Arrays.equals(before, Files.readAllBytes(directory.resolve(ExpansionJournal.FILE_NAME))), "Loading and no-op commits preserve bytes");
UnifiedWorldSettingsDiagnostics.verify(helper);
helper.succeed();
}
@GameTest(maxTicks = 16000)
public void landscapesAndSavedEditsSurviveExploration(GameTestHelper helper) throws Exception {
var level = helper.getLevel();
var host = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator();
if (!host.ancientExpeditions()) { helper.succeed(); return; }
var session = ExpansionRuntime.session(level);
Path directory = level.getServer().getWorldPath(LevelResource.ROOT).resolve("data/" + host.journalDirectory());
Path output = Path.of("diagnostics/expeditions00" + host.expeditionRevision(), host.initialDiameter() + "-" + level.getSeed() + "-native.json");
Files.createDirectories(output.getParent());
String journalHash = hash(Files.readAllBytes(directory.resolve(ExpansionJournal.FILE_NAME)));
var ops = RegistryOps.create(JsonOps.INSTANCE, level.registryAccess());
var codec = ChunkGenerator.CODEC.encodeStart(ops, host).getOrThrow();
if (Boolean.getBoolean("sanctuary.test.expansionReload")) {
var saved = JsonParser.parseString(Files.readString(output)).getAsJsonObject();
helper.assertTrue(saved.get("passed").getAsBoolean() && saved.get("process_id").getAsLong() != ProcessHandle.current().pid(), "Reload uses a successful earlier process");
helper.assertTrue(saved.get("journal_sha256").getAsString().equals(journalHash), "Process reload never moves or recreates an expedition");
helper.assertTrue(saved.get("generator_codec").equals(codec), "Saved generator options remain exact");
for (var entry : saved.getAsJsonArray("regions")) {
var row = entry.getAsJsonObject();
var point = row.getAsJsonArray("player_edit");
var at = new BlockPos(point.get(0).getAsInt(), point.get(1).getAsInt(), point.get(2).getAsInt());
level.getChunk(at.getX() >> 4, at.getZ() >> 4);
helper.assertTrue(level.getBlockState(at).is(Blocks.GOLD_BLOCK), "Player edit survives second process: " + row.get("id"));
}
saved.addProperty("reload_process_id", ProcessHandle.current().pid());
saved.addProperty("reload_passed", true);
Files.writeString(output.resolveSibling(output.getFileName().toString().replace("-native", "-reload")), new GsonBuilder().setPrettyPrinting().create().toJson(saved));
helper.succeed(); return;
}
var rows = new ArrayList<Map<String, Object>>();
var report = new LinkedHashMap<String, Object>();
report.put("seed", Long.toString(level.getSeed())); report.put("diameter", host.initialDiameter());
report.put("version", net.fabricmc.loader.api.FabricLoader.getInstance().getModContainer("sanctuary")
.orElseThrow().getMetadata().getVersion().getFriendlyString()); report.put("layout_version", host.expeditionRevision());
report.put("process_id", ProcessHandle.current().pid()); report.put("journal_sha256", journalHash);
report.put("generator_codec", codec); report.put("regions", rows);
var iterator = session.islands().stream().filter(i -> !i.origin()).iterator();
Runnable[] step = new Runnable[1];
step[0] = () -> {
try {
if (!iterator.hasNext()) {
helper.assertTrue(level.getServer().saveEverything(true, true, true), "Flush player edits before writing restart evidence");
report.put("passed", true);
Files.writeString(output, new GsonBuilder().setPrettyPrinting().create().toJson(report));
helper.succeed(); return;
}
var island = iterator.next();
var landing = session.landing(island.id());
helper.assertTrue(ExpansionRuntime.safeLanding(level, landing), "Native visit finds real supported air");
helper.assertTrue(host.islandForChunk(landing.getX() >> 4, landing.getZ() >> 4).id().equals(island.id()), "Correct island owns the landing chunk");
var climatePoint = new BlockPos(island.centerX(), 320, island.centerZ());
level.getChunk(climatePoint.getX() >> 4, climatePoint.getZ() >> 4);
String biome = level.getBiome(climatePoint).unwrapKey().orElseThrow().identifier().getPath();
var allowed = switch (island.climate()) {
case "boreal" -> Set.of("taiga", "grove", "snowy_taiga", "snowy_plains", "ice_spikes", "frozen_peaks");
case "glacial" -> Set.of("snowy_plains", "ice_spikes", "frozen_peaks");
case "tropical" -> Set.of("mangrove_swamp", "jungle", "sparse_jungle", "bamboo_jungle");
case "arid" -> Set.of("desert", "badlands", "eroded_badlands", "wooded_badlands");
default -> host.expeditionRevision() == 2 ? Set.of("warm_ocean") : Set.of("ocean", "deep_ocean");
};
helper.assertTrue(allowed.contains(biome), "Actual chunk biome matches expedition climate: " + island.id() + " / " + biome);
var row = new LinkedHashMap<String, Object>();
row.put("id", island.id()); row.put("direction", island.direction()); row.put("climate", island.climate()); row.put("relief", island.relief());
row.put("center", List.of(island.centerX(), island.centerZ())); row.put("diameter", island.diameter()); row.put("island_seed", Long.toString(island.seed()));
row.put("landing", coordinates(landing)); row.put("upper_biome", biome);
row.put("landing_biome", level.getBiome(landing).unwrapKey().orElseThrow().identifier().toString());
if (ExpansionOcean23.active(island)) {
var ocean = ExpansionOcean23.column(island, island.centerX(), island.centerZ());
helper.assertTrue(ocean.water(), "Ocean has a real central basin");
var water = new BlockPos(island.centerX(), ocean.waterLevel() - 4, island.centerZ());
helper.assertTrue(level.getBlockState(water).is(Blocks.WATER), "Actual ocean chunk contains water");
row.put("ocean_water", coordinates(water)); row.put("ocean_depth", ocean.depth());
}
var root = session.islands().getFirst();
int dx = Integer.signum(island.centerX()), dz = Integer.signum(island.centerZ());
double distance = ExpansionPlacement.distanceFromParent(island, root);
int rootEdge = edge(host, root, dx, dz, level);
int facingEdge = edge(host, island, -dx, -dz, level);
row.put("center_distance", distance); row.put("axis_terrain_gap_sampled_every_4_blocks", distance - rootEdge - facingEdge);
var old = ExpansionPlacement.candidates(level.getSeed(), List.of(root), island.id(), root.id(), island.direction(), island.climate(), 512, island.relief(), 0).getFirst();
row.put("previous_placement_center_distance", ExpansionPlacement.distanceFromParent(old, root));
var edit = landing.above(5);
helper.assertTrue(level.getBlockState(edit).isAir(), "Player witness uses empty space");
helper.assertTrue(level.setBlock(edit, Blocks.GOLD_BLOCK.defaultBlockState(), 3), "Write a development-only player edit");
row.put("player_edit", coordinates(edit)); rows.add(row);
boolean occupied = false;
try { ExpansionAdmission.verify(level, island); }
catch (ExpansionAdmission.OccupiedException expected) { occupied = true; }
helper.assertTrue(occupied, "Close placement never bypasses loaded-chunk admission");
// GameTest removes a callback after execution; rescheduling the same map key loses it.
helper.runAfterDelay(10, () -> step[0].run());
} catch (Exception failure) { throw new IllegalStateException(failure); }
};
step[0].run();
}
@GameTest(maxTicks = 24000)
public void revisedPalettesTemplesAndLivingCoral(GameTestHelper helper) throws Exception {
var level = helper.getLevel();
var host = (SanctuaryChunkGenerator) level.getChunkSource().getGenerator();
if (!host.ancientExpeditions() || host.expeditionRevision() != 2) { helper.succeed(); return; }
var rows = new ArrayList<Map<String, Object>>();
if (level.structureManager().shouldGenerateStructures()) {
helper.assertTrue(host.expeditionTemples002().size() == 2, "Both requested temples are reserved before exploration");
for (var temple : host.expeditionTemples002()) {
var planned = temple.start(); var cp = planned.getChunkPos();
var box = ExpeditionTemples002.footprint(planned);
for (int cx = box.minX() >> 4; cx <= box.maxX() >> 4; cx++)
for (int cz = box.minZ() >> 4; cz <= box.maxZ() >> 4; cz++) level.getChunk(cx, cz);
var actual = level.structureManager().getStartForStructure(temple.structure().value(), level.getChunk(cp.x(), cp.z()));
helper.assertTrue(actual != null && actual.isValid(), "Requested temple has a real native start");
var serialization = net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext.fromLevel(level);
var nbt = actual.createTag(serialization, cp);
var restored = net.minecraft.world.level.levelgen.structure.StructureStart.loadStaticStart(serialization, nbt, level.getSeed());
helper.assertTrue(restored != null && restored.isValid() && restored.createTag(serialization, cp).equals(nbt), "Temple native pieces and flags round-trip");
String id = temple.structure().unwrapKey().orElseThrow().identifier().toString();
int chests = 0, explosives = 0, dispensers = 0, masonry = 0;
for (var at : BlockPos.betweenClosed(box.minX(), box.minY(), box.minZ(), box.maxX(), box.maxY(), box.maxZ())) {
var block = level.getBlockState(at);
if (block.is(Blocks.CHEST)) chests++;
if (block.is(Blocks.TNT)) explosives++;
if (block.is(Blocks.DISPENSER)) dispensers++;
if (block.is(Blocks.SANDSTONE) || block.is(Blocks.COBBLESTONE) || block.is(Blocks.MOSSY_COBBLESTONE)) masonry++;
}
boolean desert = id.endsWith("desert_pyramid");
helper.assertTrue(chests >= (desert ? 4 : 2) && masonry > 100, "Complete native temple masonry and loot chests: " + id);
helper.assertTrue(desert ? explosives == 9 : dispensers == 2, "Native temple traps: " + id);
var located = host.findNearestMapStructure(level, net.minecraft.core.HolderSet.direct(temple.structure()),
actual.getBoundingBox().getCenter(), 1, false);
helper.assertTrue(located != null && located.getSecond().equals(temple.structure()), "Native locate discovers the reserved temple");
rows.add(Map.of("id", id, "chunk", List.of(cp.x(), cp.z()), "box", box.toString(), "chests", chests,
"tnt", explosives, "dispensers", dispensers, "masonry", masonry, "native_round_trip", true, "locate", true));
}
} else helper.assertTrue(host.expeditionTemples002().isEmpty(), "Structures-off never adds a temple");
var north = host.source().islands().stream().filter(i -> i.id().equals("noor_vigil")).findFirst().orElseThrow();
var palette = new HashSet<String>();
var sampler = host.randomState(north).createClimateSampler(net.minecraft.world.level.levelgen.densityfunction.SamplerContext.builder().enableCaches().build());
var resolver = host.source().createResolver(sampler);
for (int x = -144; x <= 144; x += 16) for (int z = -144; z <= 144; z += 16) for (int y : new int[]{210, 250, 280})
palette.add(resolver.getNoiseBiome((north.centerX() + x) >> 2, y >> 2, (north.centerZ() + z) >> 2).unwrapKey().orElseThrow().identifier().getPath());
helper.assertTrue(palette.contains("taiga") && palette.contains("snowy_taiga")
&& palette.stream().anyMatch(Set.of("ice_spikes", "frozen_peaks", "snowy_plains")::contains), "Boreal preserves forest and adds genuine ice biomes");
var ocean = host.source().islands().stream().filter(i -> i.id().equals("ari_waters")).findFirst().orElseThrow();
int coral = 0, visited = 0;
var point = new BlockPos.MutableBlockPos();
outer: for (int dz = -4; dz <= 4; dz += 2) for (int dx = -4; dx <= 4; dx += 2) {
int cx = (ocean.centerX() >> 4) + dx, cz = (ocean.centerZ() >> 4) + dz;
level.getChunk(cx, cz); visited++;
for (int x = cx * 16; x < cx * 16 + 16; x++) for (int z = cz * 16; z < cz * 16 + 16; z++) {
var column = ExpansionOcean23.column(ocean, x, z);
if (!column.water()) continue;
helper.assertTrue(level.getBiome(point.set(x, column.floorY() + 4, z)).is(net.minecraft.world.level.biome.Biomes.WARM_OCEAN), "Actual reef basin uses warm_ocean");
for (int y = column.floorY() + 1; y < column.waterLevel(); y++) {
var state = level.getBlockState(point.set(x, y, z));
String name = net.minecraft.core.registries.BuiltInRegistries.BLOCK.getKey(state.getBlock()).getPath();
if (name.contains("coral") && !name.startsWith("dead_")) coral++;
}
}
if (coral > 0 && visited >= 3) break outer;
}
helper.assertTrue(coral > 0, "Native warm-ocean decoration produces living coral");
Path output = Path.of("diagnostics/expeditions002", host.initialDiameter() + "-" + level.getSeed() + "-temples-reef"
+ (Boolean.getBoolean("sanctuary.test.expansionReload") ? "-reload" : "") + ".json");
Files.createDirectories(output.getParent());
Files.writeString(output, new GsonBuilder().setPrettyPrinting().create().toJson(Map.of("seed", Long.toString(level.getSeed()),
"temples", rows, "boreal_biomes", palette, "living_coral_blocks", coral, "ocean_chunks_inspected", visited, "passed", true)));
helper.succeed();
}
private static int edge(SanctuaryChunkGenerator host, ExpansionIsland island, int dx, int dz, net.minecraft.server.level.ServerLevel level) {
int edge = 0;
for (int distance = 0; distance <= island.terrainRadius(); distance += 4) {
int x = island.centerX() + dx * distance, z = island.centerZ() + dz * distance;
if (host.getBaseHeight(x, z, Heightmap.Types.WORLD_SURFACE_WG, level, host.randomState(island)) > 1) edge = distance;
}
return edge;
}
private static List<Integer> coordinates(BlockPos point) { return List.of(point.getX(), point.getY(), point.getZ()); }
private static String hash(byte[] bytes) throws Exception { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); }
}
@@ -0,0 +1,53 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.atlas.*;
import fr.koka.sanctuary.progression.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.material.MapColor;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.core.BlockPos;
public final class Atlas005GameTests {
@GameTest
public void mapPurchaseAndSeparation(GameTestHelper helper) throws Exception {
ServerPlayer player=(ServerPlayer)helper.makeMockServerPlayer(GameType.SURVIVAL);
player.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(player,ProgressRecord.INITIAL);
helper.assertTrue(!AtlasService.canRead(player,false) && !AtlasService.canRead(player,true),"No map or operator access by default");
helper.assertTrue(!ProgressionService.purchase(player,"atlas",0),"Insufficient levels cannot unlock map");
var store=AtlasService.observations(player);byte[] known=new byte[256];java.util.Arrays.fill(known,(byte)1);store.observe(new AtlasStore.Tile(-12,7,known));store.save();
helper.assertTrue(store.size()==1 && !ProgressEffects.get(player).atlas(),"Discovery persists independently before aptitude purchase");
player.giveExperienceLevels(8);
helper.assertTrue(ProgressionService.purchase(player,"atlas",0) && player.experienceLevel==4,"Server charges configurable map cost");
helper.assertTrue(AtlasService.canRead(player,false) && !AtlasService.canRead(player,true),"Map purchase never grants operator inspection");
helper.assertTrue(!ProgressionService.purchase(player,"atlas",0) && !ProgressionService.purchase(player,"atlas",1)
&& player.experienceLevel==4,"Duplicate or replayed map purchase cannot charge twice");
helper.assertTrue(!ProgressEffects.get(player).names() && ProgressEffects.get(player).maxFood()==6,"Aptitude does not unlock other abilities or skills");
ServerPlayer other=(ServerPlayer)helper.makeMockServerPlayer(GameType.SURVIVAL);
helper.assertTrue(AtlasService.observations(other).size()==0,"An inhabitant cannot inherit another's discovery");
helper.succeed();
}
@GameTest
public void samplerReadsSurfaceWithoutLoading(GameTestHelper helper) {
var level=helper.getLevel();var origin=helper.absolutePos(BlockPos.ZERO);
int x=origin.getX()>>4,z=origin.getZ()>>4;
var chunk=level.getChunkSource().getChunkNow(x,z);
helper.assertTrue(chunk!=null,"Fixture chunk is loaded");
int dx=5,dz=5,wx=x*16+dx,wz=z*16+dz;
int y=Math.min(level.getMaxY()-2,chunk.getHeight(Heightmap.Types.WORLD_SURFACE,dx,dz)+10);
var pos=new BlockPos(wx,y,wz);var previous=level.getBlockState(pos);
level.setBlock(pos,Blocks.GOLD_BLOCK.defaultBlockState(),3);
try {
var tile=AtlasSampler.sample(level,x,z);
helper.assertTrue(Byte.toUnsignedInt(tile.colors()[dz*16+dx])/4==MapColor.GOLD.id,"Top-down survey uses the actual visible surface material");
int far=1000000;
helper.assertTrue(level.getChunkSource().getChunkNow(far,far)==null,"Distant chunk is absent before consultation");
helper.assertTrue(AtlasSampler.sample(level,far,far)==null && level.getChunkSource().getChunkNow(far,far)==null,
"Atlas sampling does not load or generate unexplored terrain");
} finally {level.setBlock(pos,previous,3);}
helper.succeed();
}
}
@@ -0,0 +1,20 @@
package fr.koka.sanctuary.gametest;
import java.util.ArrayList;
/** Test-only accounting of actual native overlay draws and texture uploads. */
public final class Atlas026RenderProbe {
public static boolean active,inside;
public static int fills,uploads,blits;
public static long started;
public static final ArrayList<Long> nanos=new ArrayList<>();
public static void reset() {active=false;inside=false;fills=0;uploads=0;blits=0;nanos.clear();}
public static void start() {if(active){inside=true;started=System.nanoTime();}}
public static void end() {if(inside){nanos.add(System.nanoTime()-started);inside=false;}}
public static String report(String phase) {
var ordered=nanos.stream().sorted().toList();
return "ATLAS026 "+phase+" frames="+ordered.size()+" fills="+fills+" uploads="+uploads
+" blits="+blits+" median_us="+(ordered.isEmpty()?0:ordered.get(ordered.size()/2)/1000.)
+" p95_us="+(ordered.isEmpty()?0:ordered.get((ordered.size()-1)*95/100)/1000.);
}
}
@@ -0,0 +1,101 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.boats.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.component.DataComponents;
import net.minecraft.gametest.framework.GameTestHelper;
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.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.*;
import net.minecraft.network.protocol.game.*;
import net.minecraft.sounds.*;
public final class BoatsHead049GameTests {
@GameTest public void trapdoorBroadcastIncludesActorWearerAndObserversExactlyOnce(GameTestHelper h){
var wearer=player(h);var actor=player(h);actor.setPos(wearer.position().add(1,0,0));
equip(wearer,Items.OAK_TRAPDOOR);var s=HeadBlockService.session(wearer);
Head049SoundProbe.capture=new ArrayList<>();
try {
for(boolean open:new boolean[]{true,false}){
Head049SoundProbe.capture.clear();h.assertTrue(HeadBlockService.use(actor,wearer,false),"Remote trapdoor use accepted");
var sounds=Head049SoundProbe.capture.stream().filter(b->b.packet() instanceof ClientboundSoundPacket).toList();
h.assertTrue(sounds.size()==1&&sounds.getFirst().excluded()==null,"One native broadcast includes every nearby player, including actor");
var packet=(ClientboundSoundPacket)sounds.getFirst().packet();
h.assertTrue(packet.getSound().value()==(open?SoundEvents.WOODEN_TRAPDOOR_OPEN:SoundEvents.WOODEN_TRAPDOOR_CLOSE),"Correct native open/close sound");
AccessoryService.publish(wearer);h.assertTrue(HeadCosmeticData.state(HeadCosmeticService.appearance(wearer)).getValue(TrapDoorBlock.OPEN)==open,"Same open state sent to every client");
}
Head049SoundProbe.capture.clear();
h.getLevel().playSound(actor,s.pos(),SoundEvents.LEVER_CLICK,SoundSource.BLOCKS,1,1);
h.assertTrue(Head049SoundProbe.capture.size()==1&&Head049SoundProbe.capture.getFirst().excluded()==actor,"Normal world sound retains vanilla prediction exclusion");
Head049SoundProbe.capture.clear();HeadBlockService.scoped(s,()->{s.level().levelEvent(actor,1000,s.pos(),0);return null;});
h.assertTrue(Head049SoundProbe.capture.size()==1&&Head049SoundProbe.capture.getFirst().excluded()==null,"Native level-event sounds include the actor too");
}finally{Head049SoundProbe.capture=null;cleanup(wearer,actor);}h.succeed();
}
@GameTest public void visibleFoodAndLidEventsSyncWithoutExposingChestContents(GameTestHelper h){
var p=player(h);var a=player(h);equip(p,Items.CAMPFIRE);var s=HeadBlockService.session(p);
a.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.BEEF,2));
h.assertTrue(HeadBlockService.use(a,p,false)&&a.getMainHandItem().getCount()==1,"Real campfire accepts one ingredient");
AccessoryService.publish(p);var shown=HeadCosmeticService.appearance(p);
var proxy=new CampfireBlockEntity(s.pos(),s.state);proxy.setLevel(h.getLevel());
var data=shown.get(DataComponents.CUSTOM_DATA).copyTag().getCompoundOrEmpty(HeadBlockService.VISUAL).getCompoundOrEmpty("data");
proxy.loadWithComponents(net.minecraft.world.level.storage.TagValueInput.create(net.minecraft.util.ProblemReporter.DISCARDING,h.getLevel().registryAccess(),data));
h.assertTrue(proxy.getItems().getFirst().is(Items.BEEF),"Remote render proxy receives actual visible food");
h.assertTrue(!AccessoryAccess.of(p).getItem(AccessoryContainer.HEAD).has(DataComponents.CONTAINER),"Render data never creates a portable storage copy");
equip(p,Items.CHEST);s=HeadBlockService.session(p);((Container)s.entity).setItem(0,new ItemStack(Items.DIAMOND,17));
a.setItemInHand(InteractionHand.MAIN_HAND,ItemStack.EMPTY);HeadBlockService.use(a,p,false);AccessoryService.publish(p);
shown=HeadCosmeticService.appearance(p);data=shown.get(DataComponents.CUSTOM_DATA).copyTag().getCompoundOrEmpty(HeadBlockService.VISUAL);
h.assertTrue(!shown.has(DataComponents.CONTAINER)&&!data.contains("data")&&data.getIntOr("value",0)==1,"Open lid travels; seventeen hidden diamonds do not");
a.closeContainer();AccessoryService.publish(p);
h.assertTrue(HeadCosmeticService.appearance(p).get(DataComponents.CUSTOM_DATA).copyTag().getCompoundOrEmpty(HeadBlockService.VISUAL).getIntOr("value",-1)==0,"Closing lid also synchronized");
cleanup(p,a);h.succeed();
}
@GameTest(maxTicks=50) public void damagePulseActivatesNativeDispenserLampAndLocalPiston(GameTestHelper h){
var p=player(h);equip(p,Items.DISPENSER);var dispenser=HeadBlockService.session(p);((Container)dispenser.entity).setItem(0,new ItemStack(Items.COBBLESTONE,2));
var at=dispenser.pos();var terrain=h.getLevel().getBlockState(at);
HeadBlockService.pulse(p);h.assertTrue(dispenser.state.getValue(DispenserBlock.TRIGGERED),"Native rising edge arms dispenser");
h.runAfterDelay(5,()->{
dispenser.tick();h.assertTrue(((Container)dispenser.entity).getItem(0).getCount()==1,"Scheduled dispenser really emits one item");
equip(p,Items.REDSTONE_LAMP);HeadBlockService.pulse(p);var lamp=HeadBlockService.session(p);
h.assertTrue(lamp.state.getValue(RedstoneLampBlock.LIT),"Lamp is lit by the hit pulse");
h.runAfterDelay(20,()->{
lamp.tick();h.runAfterDelay(5,()->{
lamp.tick();h.assertTrue(!lamp.state.getValue(RedstoneLampBlock.LIT),"Lamp resets after native delayed shutoff");
equip(p,Items.PISTON);HeadBlockService.pulse(p);
h.assertTrue(HeadBlockService.session(p).state.getValue(net.minecraft.world.level.block.piston.PistonBaseBlock.EXTENDED),"Local piston extends");
h.assertTrue(h.getLevel().getBlockState(at)==terrain,"No virtual head block overwrites world terrain");
cleanup(p);h.succeed();
});
});
});
}
@GameTest public void rectangularFootprintsRotateAndEverySeatIsEquipable(GameTestHelper h){
for(var v:CrewBoats.variants()){
var b=v.type().create(h.getLevel(),EntitySpawnReason.COMMAND);
h.assertTrue(AccessoryContainer.accepts(AccessoryContainer.HEAD,new ItemStack(v.item())),"Every new boat fits cosmetic slot");
h.assertTrue(b.seats()==2*v.tiles(),"Two people per module");
var primary=new HashSet<String>();
for(int i=0;i<v.tiles();i++){
var first=b.seatOffset(i);var second=b.seatOffset(i+v.tiles());
primary.add(first.x+":"+first.z);
h.assertTrue(first.x==second.x&&Math.abs(first.z-second.z-.8)<.0001,"Secondary sits beside the primary of its module");
}
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");
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");
}b.discard();
}h.succeed();
}
private static ServerPlayer player(GameTestHelper h){var p=Progression003GameTests.player(h);p.setUUID(UUID.randomUUID());p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(p,ProgressRecord.INITIAL);return p;}
private static void equip(ServerPlayer p,Item item){AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,new ItemStack(item));}
private static void cleanup(ServerPlayer... ps){for(var p:ps){p.closeContainer();AccessoryAccess.of(p).clearContent();}}
}
@@ -0,0 +1,237 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.building.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.knowledge.BlockKnowledgeService;
import fr.koka.demeure.DemeureService;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.minecraft.commands.arguments.EntityAnchorArgument;
import net.minecraft.core.*;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.stats.Stats;
import net.minecraft.world.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.properties.*;
import net.minecraft.world.phys.*;
public final class Building009GameTests {
private static final Set<UUID> DENIED=new HashSet<>(),MUTATED=new HashSet<>();
static {
UseBlockCallback.EVENT.register((player,level,hand,hit) -> {
if (DENIED.contains(player.getUUID())) return InteractionResult.FAIL;
if (MUTATED.contains(player.getUUID())) level.setBlockAndUpdate(hit.getBlockPos().relative(hit.getDirection()),Blocks.CHEST.defaultBlockState());
return InteractionResult.PASS;
});
}
@GameTest public void purchasesLimitsAndPlaneOrientations(GameTestHelper h) {
var p=player(h,0,Items.STONE,64);p.giveExperienceLevels(111);
double reach=p.blockInteractionRange(),attack=p.entityInteractionRange();
for (int rank=1;rank<=7;rank++) {
h.assertTrue(ProgressionService.purchase(p,"building",rank-1),"Seven Building ranks can be bought");
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");
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");
h.assertTrue(plane.stream().allMatch(pos -> pos.get(face.getAxis())==0),"Every position stays in the clicked plane");
}
h.succeed();
}
@GameTest public void nativeFloorsWallsAndCeilings(GameTestHelper h) {
for (Direction face:Direction.values()) {
var origin=fixture(h,face,1);var p=player(h,1,Items.COBBLESTONE,16);aim(p,origin,face);
var hit=BuildSelection.aimed(p,origin);h.assertTrue(hit!=null && hit.getDirection()==face,"Server ray sees the expected face: "+face);
start(h,p,origin);BuildingManager.tick(p);
h.assertTrue(p.getMainHandItem().getCount()==16,"Preview and first tick consume no blocks");
finish(h,p);
h.assertTrue(p.getMainHandItem().getCount()==7,"A 3x3 surface consumes exactly nine blocks: "+face);
h.assertTrue(p.getStats().getValue(Stats.ITEM_USED.get(Items.COBBLESTONE))==9
&& BlockKnowledgeService.knowledge(p,Blocks.COBBLESTONE).placed()==9,"Native stats and Blocodex count nine actual placements");
for (var pos:BuildPlane.positions(origin.relative(face),face,p.getDirection(),1)) h.assertTrue(h.getLevel().getBlockState(pos).is(Blocks.COBBLESTONE),"Every previewed position is filled");
BuildingManager.handle(p,new BuildingPayload(1,true,origin,1));
h.assertTrue(!BuildingManager.active(p),"A completed heartbeat cannot extend a new layer");
BuildingManager.forget(p);
}
h.succeed();
}
@GameTest public void survivalStockAndCreative(GameTestHelper h) {
var origin=fixture(h,Direction.UP,1);var p=player(h,1,Items.COBBLESTONE,4);aim(p,origin,Direction.UP);
double before=footprint(p,origin);start(h,p,origin);finish(h,p);
h.assertTrue(p.getMainHandItem().isEmpty() && p.getStats().getValue(Stats.ITEM_USED.get(Items.COBBLESTONE))==4,"Stock exhaustion stops after exactly four paid blocks");
h.assertTrue(Math.abs(footprint(p,origin)-before-.2)<1e-8,"Four completed placements contribute exactly four Demeure traces");
BuildingManager.forget(p);
fixture(h,Direction.UP,1);var creative=player(h,1,Items.COBBLESTONE,1,GameType.CREATIVE);aim(creative,origin,Direction.UP);
start(h,creative,origin);finish(h,creative);
h.assertTrue(creative.getMainHandItem().getCount()==1 && creative.getStats().getValue(Stats.ITEM_USED.get(Items.COBBLESTONE))==9,"Creative retains native infinite materials");
BuildingManager.forget(creative);h.succeed();
}
@GameTest public void slabsStairsAndOccupiedPositions(GameTestHelper h) {
var origin=fixture(h,Direction.UP,1);var p=player(h,1,Items.STONE_SLAB,32);aim(p,origin,Direction.UP);
h.getLevel().setBlockAndUpdate(origin.above().east(),Blocks.CHEST.defaultBlockState());
var chest=h.getLevel().getBlockEntity(origin.above().east());
start(h,p,origin);finish(h,p);
h.assertTrue(p.getMainHandItem().getCount()==24 && h.getLevel().getBlockEntity(origin.above().east())==chest,"Occupied chest stays intact and costs no slab");
h.assertTrue(h.getLevel().getBlockState(origin.above()).getValue(SlabBlock.TYPE)==SlabType.BOTTOM,"Floor slabs use native bottom orientation");
BuildingManager.forget(p);
for (Direction look:List.of(Direction.NORTH,Direction.EAST,Direction.SOUTH,Direction.WEST)) {
fixture(h,Direction.UP,1);p=player(h,1,Items.OAK_STAIRS,16);
p.setPos(origin.getX()+.5-look.getStepX()*3,origin.getY()+1,origin.getZ()+.5-look.getStepZ()*3);
p.lookAt(EntityAnchorArgument.Anchor.EYES,Vec3.atCenterOf(origin).add(0,.5,0));
start(h,p,origin);finish(h,p);
var stairs=h.getLevel().getBlockState(origin.above());
h.assertTrue(stairs.is(Blocks.OAK_STAIRS) && stairs.getValue(StairBlock.FACING)==look && stairs.getValue(StairBlock.HALF)==Half.BOTTOM,"Stairs follow all four player directions");
BuildingManager.forget(p);
}
h.succeed();
}
@GameTest public void refusalsAndCallbackWorldChanges(GameTestHelper h) {
var origin=fixture(h,Direction.UP,1);
for (var set:List.of(DENIED,MUTATED)) {
fixture(h,Direction.UP,1);var p=player(h,1,Items.COBBLESTONE,16);aim(p,origin,Direction.UP);
set.add(p.getUUID());double before=footprint(p,origin);
try {
start(h,p,origin);finish(h,p);
h.assertTrue(p.getMainHandItem().getCount()==16 && p.getStats().getValue(Stats.ITEM_USED.get(Items.COBBLESTONE))==0
&& footprint(p,origin)==before,"Refusal or callback mutation consumes and credits nothing");
} finally {set.remove(p.getUUID());BuildingManager.forget(p);}
}
h.succeed();
}
@GameTest public void slabMergingCeilingAndEntityCollisions(GameTestHelper h) {
var origin=fixture(h,Direction.UP,1);
for(var pos:BuildPlane.positions(origin,Direction.UP,Direction.SOUTH,1))
h.getLevel().setBlockAndUpdate(pos,Blocks.STONE_SLAB.defaultBlockState());
var p=player(h,1,Items.STONE_SLAB,16);aim(p,origin,Direction.UP);
p.lookAt(EntityAnchorArgument.Anchor.EYES,Vec3.atLowerCornerOf(origin).add(.5,.5,.5));
start(h,p,origin);finish(h,p);
h.assertTrue(p.getMainHandItem().getCount()==7 && h.getLevel().getBlockState(origin).getValue(SlabBlock.TYPE)==SlabType.DOUBLE,"Existing slabs merge through native contexts and consume one item each");
BuildingManager.forget(p);
fixture(h,Direction.DOWN,1);p=player(h,1,Items.STONE_SLAB,16);aim(p,origin,Direction.DOWN);
start(h,p,origin);finish(h,p);
h.assertTrue(h.getLevel().getBlockState(origin.below()).getValue(SlabBlock.TYPE)==SlabType.TOP,"Ceiling slabs retain top orientation");
BuildingManager.forget(p);
fixture(h,Direction.UP,1);p=player(h,1,Items.COBBLESTONE,16);aim(p,origin,Direction.UP);
var cow=net.minecraft.core.registries.BuiltInRegistries.ENTITY_TYPE.getValue(net.minecraft.resources.Identifier.withDefaultNamespace("cow")).create(h.getLevel(),net.minecraft.world.entity.EntitySpawnReason.TRIGGERED);
cow.setPos(origin.getX()+1.5,origin.getY()+1,origin.getZ()+.5);cow.setNoGravity(true);
h.getLevel().addFreshEntity(cow);
var plan=BuildSelection.plan(p,BuildSelection.aimed(p,origin),1);
h.assertTrue(plan!=null && plan.targets().size()<9 && plan.targets().stream().noneMatch(t -> new AABB(t.pos()).intersects(cow.getBoundingBox())),"Native collision validation excludes cells occupied by a living entity");
start(h,p,origin);finish(h,p);
h.assertTrue(h.getLevel().getBlockState(origin.above().east()).isAir(),"Bulk construction does not enclose the cow in a new block");
cow.discard();BuildingManager.forget(p);h.succeed();
}
@GameTest public void interruptionsPreserveAlreadyPlacedBlocks(GameTestHelper h) {
for (int reason=0;reason<4;reason++) {
var origin=fixture(h,Direction.UP,1);var p=player(h,1,Items.COBBLESTONE,16);aim(p,origin,Direction.UP);
start(h,p,origin);BuildingManager.tick(p);BuildingManager.tick(p);
h.assertTrue(p.getMainHandItem().getCount()==15,"First placement is paid before cancellation");
switch(reason) {
case 0 -> BuildingManager.handle(p,new BuildingPayload(1,false,origin,1));
case 1 -> p.setItemInHand(InteractionHand.MAIN_HAND,p.getMainHandItem().copy());
case 2 -> p.setPos(p.getX()+30,p.getY(),p.getZ());
case 3 -> p.setHealth(0);
}
BuildingManager.tick(p);h.assertTrue(!BuildingManager.active(p) && h.getLevel().getBlockState(origin.above()).is(Blocks.COBBLESTONE),"Interruption stops future work, retains completed placement: "+reason);
h.assertTrue(p.getMainHandItem().getCount()==15,"No additional material consumed after interruption");
BuildingManager.forget(p);
}
h.succeed();
}
@GameTest public void concurrentBuildersAndUntrustedRanks(GameTestHelper h) {
var origin=fixture(h,Direction.UP,1);var a=player(h,1,Items.COBBLESTONE,16);var b=player(h,1,Items.COBBLESTONE,16);
aim(a,origin,Direction.UP);aim(b,origin,Direction.UP);
BuildingManager.handle(a,new BuildingPayload(1,true,origin,7));
h.assertTrue(BuildingManager.selectedCount(a)==9,"Forged high rank is clamped to the purchased surface");
start(h,b,origin);
for (int i=0;i<40;i++) {BuildingManager.tick(a);BuildingManager.tick(b);}
h.assertTrue(a.getMainHandItem().getCount()+b.getMainHandItem().getCount()==23,"Two builders consume only nine items for one shared surface");
h.assertTrue(a.getStats().getValue(Stats.ITEM_USED.get(Items.COBBLESTONE))+b.getStats().getValue(Stats.ITEM_USED.get(Items.COBBLESTONE))==9,"No duplicated placement credit");
BuildingManager.forget(a);BuildingManager.forget(b);
var novice=player(h,0,Items.COBBLESTONE,16);aim(novice,origin,Direction.UP);
BuildingManager.handle(novice,new BuildingPayload(1,true,origin,7));h.assertTrue(!BuildingManager.active(novice),"Rank zero cannot start group work");
BuildingManager.handle(a,new BuildingPayload(2,true,origin,8));h.assertTrue(!BuildingManager.active(a),"Invalid rank rejected");
var far=new BlockPos(29000000,100,29000000);
h.assertTrue(!h.getLevel().isLoaded(far) && BuildSelection.plan(a,new BlockHitResult(Vec3.atCenterOf(far),Direction.UP,far,false),7)==null
&& !h.getLevel().isLoaded(far),"Preview never loads a missing chunk");
BuildingManager.forget(novice);BuildingManager.forget(a);h.succeed();
}
@GameTest public void containerSupportsAndUnsupportedItems(GameTestHelper h) {
var origin=fixture(h,Direction.UP,1);h.getLevel().setBlockAndUpdate(origin,Blocks.CHEST.defaultBlockState());
var chest=(net.minecraft.world.level.block.entity.ChestBlockEntity)h.getLevel().getBlockEntity(origin);
chest.setItem(0,new ItemStack(Items.DIAMOND,3));
var p=player(h,1,Items.COBBLESTONE,16);aim(p,origin,Direction.UP);
start(h,p,origin);finish(h,p);
h.assertTrue(chest.getItem(0).getCount()==3 && p.containerMenu==p.inventoryMenu,"Building on a container never opens it or changes its contents");
BuildingManager.forget(p);
for(var item:List.of(Items.OAK_DOOR,Items.BED.red(),Items.SCAFFOLDING,Items.LILY_PAD)) {
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(item,4));
h.assertTrue(!BuildSelection.usable(p),"Multi-cell and relocated placements keep individual native use: "+item);
}
h.succeed();
}
@GameTest(maxTicks=50) public void missingHeartbeatExpires(GameTestHelper h) {
var origin=fixture(h,Direction.UP,2);var p=player(h,2,Items.COBBLESTONE,64);aim(p,origin,Direction.UP);start(h,p,origin);
h.runAfterDelay(16,() -> {
h.assertTrue(!BuildingManager.active(p),"A lost heartbeat expires a running surface");
h.assertTrue(p.getMainHandItem().getCount()>39,"Lost connection cannot finish the full surface");
BuildingManager.forget(p);h.succeed();
});
}
@GameTest public void lockedSurfaceKeepsItsFacingWhileLookingAround(GameTestHelper h) {
var origin=fixture(h,Direction.UP,1);var p=player(h,1,Items.OAK_STAIRS,16);aim(p,origin,Direction.UP);
var look=p.getDirection();var hit=BuildSelection.aimed(p,origin);var plan=BuildSelection.plan(p,hit,1);
h.assertTrue(plan.targets().size()==9,"Nine valid stairs are captured before starting");start(h,p,origin);
BuildingManager.tick(p);BuildingManager.tick(p);
p.setYRot(p.getYRot()+180);p.setXRot(-65);p.setPos(p.getX()+.1,p.getY(),p.getZ());finish(h,p);
h.assertTrue(p.getMainHandItem().getCount()==7,"Turning and moving within reach still place exactly the original surface");
for(var target:plan.targets()) {
var state=h.getLevel().getBlockState(target.pos());
h.assertTrue(state.is(Blocks.OAK_STAIRS) && state.getValue(StairBlock.FACING)==look,"All stairs retain the initial orientation");
}
var ordinary=new net.minecraft.world.item.context.UseOnContext(p,InteractionHand.MAIN_HAND,hit);
h.assertTrue(ordinary.getHorizontalDirection()==p.getDirection() && ordinary.getHorizontalDirection()!=look,"Frozen orientation never leaks into ordinary item use");
BuildingManager.handle(p,new BuildingPayload(1,true,origin,1));h.assertTrue(!BuildingManager.active(p),"A completed surface cannot restart from held heartbeats");
BuildingManager.forget(p);h.succeed();
}
private static ServerPlayer player(GameTestHelper h,int rank,Item item,int count) {return player(h,rank,item,count,GameType.SURVIVAL);}
private static ServerPlayer player(GameTestHelper h,int rank,Item item,int count,GameType mode) {
var p=Progression003GameTests.player(h,mode);var record=ProgressRecord.INITIAL;
for(int i=0;i<rank;i++) record=record.buy("building",ProgressionConfig.DEFAULT.statCosts().get(i));
p.setAttached(ProgressionService.RECORD,record.json());ProgressEffects.apply(p,record);
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(item,count));p.setOnGround(true);return p;
}
private static BlockPos fixture(GameTestHelper h,Direction face,int radius) {
var origin=h.absolutePos(new BlockPos(3,5,3));
for(int x=-3;x<=3;x++) for(int y=-3;y<=3;y++) for(int z=-3;z<=3;z++) h.getLevel().setBlockAndUpdate(origin.offset(x,y,z),Blocks.AIR.defaultBlockState());
for(var pos:BuildPlane.positions(origin,face,Direction.SOUTH,radius)) h.getLevel().setBlockAndUpdate(pos,Blocks.STONE.defaultBlockState());
return origin;
}
private static void aim(ServerPlayer p,BlockPos origin,Direction face) {
if(face==Direction.UP) p.setPos(origin.getX()+.5,origin.getY()+1,origin.getZ()-2.5);
else if(face==Direction.DOWN) p.setPos(origin.getX()+.5,origin.getY()-3,origin.getZ()+.5);
else p.setPos(origin.getX()+.5+face.getStepX()*3,origin.getY()-1,origin.getZ()+.5+face.getStepZ()*3);
p.lookAt(EntityAnchorArgument.Anchor.EYES,Vec3.atCenterOf(origin).add(face.getStepX()*.5,face.getStepY()*.5,face.getStepZ()*.5));
p.setOnGround(true);
}
private static void start(GameTestHelper h,ServerPlayer p,BlockPos origin) {
BuildingManager.handle(p,new BuildingPayload(1,true,origin,ProgressEffects.get(p).building()));
h.assertTrue(BuildingManager.active(p),"Valid native target starts a build session");
}
private static void finish(GameTestHelper h,ServerPlayer p) {
for(int i=0;i<600 && BuildingManager.active(p);i++) BuildingManager.tick(p);
h.assertTrue(!BuildingManager.active(p),"Surface finishes within a bounded tick budget");
}
private static double footprint(ServerPlayer p,BlockPos origin) {
var keys=new HashSet<fr.koka.demeure.FootprintStore.Key>();
for(int x=-2;x<=2;x++) for(int z=-2;z<=2;z++) keys.add(DemeureService.key(p,origin.offset(x,0,z)));
return keys.stream().map(k -> DemeureService.view(p.level().getServer(),k)).filter(Objects::nonNull).flatMap(v -> v.influences().stream())
.filter(s -> s.subject().id().equals(p.getUUID().toString())).mapToDouble(fr.koka.demeure.FootprintStore.Share::score).sum();
}
}
@@ -0,0 +1,197 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.SanctuaryMod;
import fr.koka.sanctuary.cosmetics.CarryService;
import fr.koka.sanctuary.progression.*;
import java.util.UUID;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerEntityEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.TagValueOutput;
import net.minecraft.world.phys.Vec3;
/** Native player mounts, attributes, lifecycle callbacks and collision checks, without a graphical client. */
public final class Carry027GameTests {
@GameTest public void seatedPelvisTouchesHeadAtEveryLayerAndScale(GameTestHelper h){
var p=players(h,3);
try{
tower(h,p);
for(int i=1;i<p.length;i++)h.assertTrue(Math.abs(p[i].getY()-p[i-1].getY()-1.171875)<.00001,
"Seated players are separated by 20 model pixels at 15/16 scale, without a standing leg gap");
p[0].getAttribute(Attributes.SCALE).setBaseValue(1.5);
p[1].getAttribute(Attributes.SCALE).setBaseValue(.75);
p[0].setPose(Pose.CROUCHING);
p[0].positionRider(p[1]);p[1].positionRider(p[2]);
double headTop=p[0].getY()+1.50484375*1.5;
h.assertTrue(Math.abs(p[1].getY()+.7040625*.75-headTop)<.00001,"Scaled rider pelvis meets crouched carrier's rendered head");
h.assertTrue(Math.abs(p[2].getY()+.7040625-(p[1].getY()+1.8759375*.75))<.00001,"Next layer uses the seated carrier's head and its scale");
}finally{cleanup(p);}h.succeed();
}
@GameTest public void babiesAddNoWeightAndGrowingUpUpdatesTheWholeStack(GameTestHelper h){
var p=players(h,2);double base=speed(p[0]);var cow=EntityTypes.COW.create(h.getLevel(),EntitySpawnReason.COMMAND);
var online=h.getLevel().getServer().getPlayerList().getPlayers();var added=new java.util.ArrayList<ServerPlayer>();
try{
for(var player:p)if(!online.contains(player)){online.add(player);added.add(player);}
tower(h,p);cow.setBaby(true);cow.setNoAi(true);cow.setPos(p[1].position().add(0,0,2));h.getLevel().addFreshEntity(cow);
h.assertTrue(cow.isBaby()&&CarryService.interact(p[1],cow),"Native baby is carried on the upper player's head");
ratio(h,p[0],base,.75);ratio(h,p[1],base,1);
cow.setBaby(false);ServerTickEvents.END_SERVER_TICK.invoker().onEndTick(h.getLevel().getServer());
ratio(h,p[0],base,.5625);ratio(h,p[1],base,.75);
cow.setBaby(true);ServerTickEvents.END_SERVER_TICK.invoker().onEndTick(h.getLevel().getServer());
ratio(h,p[0],base,.75);ratio(h,p[1],base,1);
h.assertTrue(cow.getVehicle()==p[1],"Age changes do not drop or replace the animal");
}finally{online.removeAll(added);cleanup(p);cow.discard();}h.succeed();
}
@GameTest public void familiarBabyAppearanceAddsNoWeightButMiniatureAdultsDo(GameTestHelper h){
var p=players(h,2);double base=speed(p[0]);
var pet=fr.koka.sanctuary.cosmetics.FamiliarService.TYPE.create(h.getLevel(),EntitySpawnReason.COMMAND);
try{
tower(h,p);
for(var egg:java.util.List.of(net.minecraft.world.item.Items.COW_SPAWN_EGG,net.minecraft.world.item.Items.ZOMBIE_SPAWN_EGG,net.minecraft.world.item.Items.ENDER_DRAGON_SPAWN_EGG)){
pet.bind(p[1],new net.minecraft.world.item.ItemStack(egg));pet.setPos(p[1].position().add(0,0,2));
boolean baby=egg!=net.minecraft.world.item.Items.ENDER_DRAGON_SPAWN_EGG;
h.assertTrue(pet.babyAppearance()==baby,"Familiar uses the same baby support as its native model");
h.assertTrue(CarryService.interact(p[1],pet),"The familiar can be carried");
ratio(h,p[0],base,baby?.75:.5625);ratio(h,p[1],base,baby?1:.75);
h.assertTrue(CarryService.release(pet,true),"The same familiar is put down");
ratio(h,p[0],base,.75);ratio(h,p[1],base,1);
}
}finally{cleanup(p);pet.discard();}h.succeed();
}
@GameTest public void stackPersistsAcrossTicksAndProtectsEveryMember(GameTestHelper h){
var p=players(h,4);double base=speed(p[0]);
try{
tower(h,p);
ratio(h,p[0],base,.421875);ratio(h,p[1],base,.5625);ratio(h,p[2],base,.75);
for(int tick=0;tick<20;tick++){
p[0].setPos(p[0].position().add(.03,0,0));
for(int i=1;i<p.length;i++){p[i].setShiftKeyDown(false);p[i-1].positionRider(p[i]);}
ServerTickEvents.END_SERVER_TICK.invoker().onEndTick(h.getLevel().getServer());
for(int i=1;i<p.length;i++)h.assertTrue(p[i].getVehicle()==p[i-1]&&CarryService.mayDismount(p[i]),"Every layer stays mounted and can dismount after releasing sneak");
}
float health=p[0].getHealth();p[3].attack(p[0]);
h.assertTrue(p[0].getHealth()==health&&!p[0].hurtServer(h.getLevel(),p[0].damageSources().playerAttack(p[3]),2)
&&!p[3].hurtServer(h.getLevel(),p[3].damageSources().playerAttack(p[0]),2),"Damage is refused across the entire stack in both directions");
p[3].stopRiding();ratio(h,p[0],base,.5625);ratio(h,p[2],base,1);
h.assertTrue(!CarryService.marked(p[3])&&!CarryService.pair(p[0],p[3]),"Native dismount immediately removes carry status and friendly protection");
}finally{cleanup(p);}h.succeed();
}
@GameTest public void aCarrierCanClimbWithItsWholeUpperGroup(GameTestHelper h){
var p=players(h,4);double base=speed(p[0]);
try{
p[1].setPos(p[0].position().add(2,0,0));mount(h,p[2],p[1]);mount(h,p[3],p[2]);
h.assertTrue(CarryService.interact(p[1],p[0]),"A player already carrying two people can climb a free head");
for(int i=1;i<p.length;i++)h.assertTrue(p[i].getVehicle()==p[i-1]
&&p[i].position().distanceTo(CarryService.head(p[i-1],p[i]))<.001,"All upper passengers immediately follow the new base");
ratio(h,p[0],base,.421875);
h.assertTrue(CarryService.release(p[1],true),"The lower carrier can put down the whole upper group");
h.assertTrue(!p[1].isPassenger()&&p[2].getVehicle()==p[1]&&p[3].getVehicle()==p[2],"The detached group keeps its own hierarchy");
for(int i=2;i<p.length;i++)h.assertTrue(p[i].position().distanceTo(CarryService.head(p[i-1],p[i]))<.001,"Safe placement moves every layer together");
ratio(h,p[0],base,1);ratio(h,p[1],base,.5625);
}finally{cleanup(p);}h.succeed();
}
@GameTest public void rejectsCyclesOccupiedHeadsAndForeignMounts(GameTestHelper h){
var p=players(h,4);var pig=EntityTypes.PIG.create(h.getLevel(),EntitySpawnReason.COMMAND);
try{
tower(h,new ServerPlayer[]{p[0],p[1],p[2]});
p[0].getAttribute(Attributes.ENTITY_INTERACTION_RANGE).setBaseValue(20);
h.assertTrue(!CarryService.interact(p[0],p[2]),"The bottom player cannot climb their own upper passenger");
h.assertTrue(!CarryService.interact(p[3],p[3]),"A player cannot ride themselves");
p[3].setPos(p[0].position().add(0,0,2));
h.assertTrue(!CarryService.interact(p[3],p[0])&&p[0].getPassengers().size()==1,"Each head still has exactly one seat");
CarryService.detach(p[0]);CarryService.detach(p[1]);
pig.setPos(p[0].position());h.getLevel().addFreshEntity(pig);
h.assertTrue(p[0].startRiding(pig,true,true),"Native foreign mount fixture");
h.assertTrue(!CarryService.interact(p[3],p[0]),"A normal animal mount is not a player stack");
}finally{p[0].stopRiding();cleanup(p);pig.discard();}h.succeed();
}
@GameTest public void checksTheWholeGroupAgainstACeilingBeforeMounting(GameTestHelper h){
var p=players(h,3);
try{
p[1].setPos(p[0].position().add(2,0,0));mount(h,p[2],p[1]);
var ceiling=p[0].blockPosition().above(4);h.getLevel().setBlockAndUpdate(ceiling,Blocks.STONE.defaultBlockState());
h.assertTrue(!CarryService.interact(p[1],p[0])&&!p[1].isPassenger()&&p[2].getVehicle()==p[1],"The first rider fits but the upper rider would hit the ceiling, so nothing moves");
h.getLevel().setBlockAndUpdate(ceiling,Blocks.AIR.defaultBlockState());
h.assertTrue(CarryService.interact(p[1],p[0]),"Removing that ceiling permits the same group");
}finally{cleanup(p);}h.succeed();
}
@GameTest public void deathInTheMiddleSeparatesTheSurvivingGroups(GameTestHelper h){
var p=players(h,4);double base=speed(p[0]);
try{
tower(h,p);p[1].die(p[1].damageSources().generic());
h.assertTrue(!p[1].isPassenger()&&!p[1].isVehicle()&&!CarryService.marked(p[1]),"Death removes both carry links around the lost player");
h.assertTrue(!p[2].isPassenger()&&p[3].getVehicle()==p[2]&&!CarryService.pair(p[0],p[3]),"The upper group is put down as a separate surviving stack");
ratio(h,p[0],base,1);ratio(h,p[2],base,.75);
}finally{cleanup(p);}h.succeed();
}
@GameTest public void unloadAndSaveNeverLeaveAPersistentTower(GameTestHelper h){
var p=players(h,3);double base=speed(p[0]);
try{
tower(h,p);
for(var player:p){
var output=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());player.saveWithoutId(output);
h.assertTrue(!output.buildResult().contains("RootVehicle")&&!output.buildResult().contains("Passengers"),"No player saves another inhabitant in their native data");
}
ServerEntityEvents.ENTITY_UNLOAD.invoker().onUnload(p[1],h.getLevel());
for(var player:p)h.assertTrue(!player.isPassenger()&&!player.isVehicle()&&!CarryService.marked(player),"Unloading the middle player clears every affected link");
ratio(h,p[0],base,1);ratio(h,p[1],base,1);
}finally{cleanup(p);}h.succeed();
}
@GameTest public void carriedAnimalAddsWeightAndKeepsItsIdentity(GameTestHelper h){
var p=players(h,2);double base=speed(p[0]);var cow=EntityTypes.COW.create(h.getLevel(),EntitySpawnReason.COMMAND);var id=cow.getUUID();
try{
tower(h,p);cow.setPos(p[1].position().add(0,0,2));h.getLevel().addFreshEntity(cow);
h.assertTrue(CarryService.interact(p[1],cow)&&cow.getVehicle()==p[1],"A seated player can carry an animal on their head");
ratio(h,p[0],base,.5625);ratio(h,p[1],base,.75);
CarryService.release(cow,true);ratio(h,p[0],base,.75);ratio(h,p[1],base,1);
h.assertTrue(!cow.isRemoved()&&cow.getUUID().equals(id)&&!CarryService.marked(cow),"Putting down the animal preserves the original entity");
}finally{cleanup(p);cow.discard();}h.succeed();
}
@GameTest public void largeStacksStayMobileAndPreserveOtherSpeedModifiers(GameTestHelper h){
var p=players(h,8);double base=speed(p[0]);var extra=SanctuaryMod.id("test_carry_other_speed");
try{
p[0].getAttribute(Attributes.MOVEMENT_SPEED).addTransientModifier(new AttributeModifier(extra,.5,AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL));
tower(h,p);ratio(h,p[0],base,1.5*.25);
for(int i=p.length-1;i>0;i--){
p[i].stopRiding();double expected=1.5*Math.max(.25,Math.pow(.75,i-1));ratio(h,p[0],base,expected);
}
h.assertTrue(p[0].getAttribute(Attributes.MOVEMENT_SPEED).getModifier(extra)!=null,"Dismounting removes only Sanctuary's carrying penalty");
}finally{cleanup(p);p[0].getAttribute(Attributes.MOVEMENT_SPEED).removeModifier(extra);}h.succeed();
}
private static ServerPlayer[] players(GameTestHelper h,int count){
var players=new ServerPlayer[count];
for(int i=0;i<count;i++){
var p=Progression003GameTests.player(h);p.setUUID(UUID.randomUUID());
p.connection.handleAcceptPlayerLoad(new net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket());
p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(p,ProgressRecord.INITIAL);p.setHealth(6);players[i]=p;
}
var at=players[0].blockPosition();
for(int x=-4;x<=4;x++)for(int z=-4;z<=4;z++)for(int y=-1;y<=19;y++)
h.getLevel().setBlockAndUpdate(at.offset(x,y,z),(y==-1?Blocks.STONE:Blocks.AIR).defaultBlockState());
return players;
}
private static void mount(GameTestHelper h,ServerPlayer rider,ServerPlayer carrier){
rider.setPos(carrier.position().add(0,0,1.5));rider.setShiftKeyDown(true);
h.assertTrue(CarryService.interact(rider,carrier),"Mount a free player head, including an already seated player");
}
private static void tower(GameTestHelper h,ServerPlayer[] p){for(int i=1;i<p.length;i++)mount(h,p[i],p[i-1]);}
private static double speed(ServerPlayer p){return p.getAttributeValue(Attributes.MOVEMENT_SPEED);}
private static void ratio(GameTestHelper h,ServerPlayer p,double base,double ratio){h.assertTrue(Math.abs(speed(p)-base*ratio)<.00001,"Expected speed factor "+ratio+", got "+speed(p)/base);}
private static void cleanup(ServerPlayer[] p){for(int i=p.length-1;i>=0;i--)CarryService.detach(p[i]);}
}
@@ -0,0 +1,77 @@
package fr.koka.sanctuary.gametest;
import com.google.gson.*;
import fr.koka.sanctuary.knowledge.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.recipes.*;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
public final class Collections035GameTests {
@GameTest public void exhaustiveCatalogueMatchesLoadedVanilla(GameTestHelper h)throws Exception{
var server=h.getLevel().getServer();var entries=RecipeCollections.entries(server);var primary=new HashSet<String>();var members=new HashSet<String>();int itemCount=0,blockCount=0;
check(h,entries.size()==67,"67 collection definitions loaded");
for(var entry:entries){
var resource=server.getResourceManager().getResource(Identifier.fromNamespaceAndPath("sanctuary","sanctuary_recipe_collections/"+Identifier.parse(entry.id()).getPath()+".json")).orElseThrow();
JsonObject raw;try(var reader=resource.openAsReader()){raw=JsonParser.parseReader(reader).getAsJsonObject();}
for(var id:raw.getAsJsonArray("recipes"))check(h,primary.add(id.getAsString()),"Each vanilla recipe has one primary collection: "+id);
var content=raw.getAsJsonObject("members");
for(var id:content.getAsJsonArray("items")){itemCount++;members.add(id.getAsString());check(h,BuiltInRegistries.ITEM.containsKey(Identifier.parse(id.getAsString())),"Real item: "+id);}
for(var id:content.getAsJsonArray("blocks")){blockCount++;members.add(id.getAsString());check(h,BuiltInRegistries.BLOCK.containsKey(Identifier.parse(id.getAsString())),"Real block: "+id);}
for(String id:entry.items())check(h,BuiltInRegistries.ITEM.containsKey(Identifier.parse(id)),"Real discovery seed: "+id);
for(String id:entry.advancements())check(h,server.getAdvancements().get(Identifier.parse(id))!=null,"Real advancement: "+id);
}
var actual=new HashSet<String>();for(var recipe:server.getRecipeManager().getRecipes())if(recipe.id().identifier().getNamespace().equals("minecraft"))actual.add(recipe.id().identifier().toString());
check(h,primary.size()==2042&&primary.equals(actual),"All 2042 loaded vanilla recipes classified exactly once");
check(h,itemCount==1658&&blockCount==1286&&members.size()==1815,"Complete document inventory uses real registry IDs");h.succeed();
}
@GameTest public void everyAdvancementTeachesItsCollectionsWithoutIngredients(GameTestHelper h){
var server=h.getLevel().getServer();var entries=RecipeCollections.entries(server);var all=new TreeSet<String>();entries.forEach(e->all.addAll(e.advancements()));
check(h,all.size()==126,"All progression advancements configured");
for(String id:all){
var p=player(h);var advancement=server.getAdvancements().get(Identifier.parse(id));for(String criterion:advancement.value().criteria().keySet())p.getAdvancements().award(advancement,criterion);
var data=new RecipeKnowledge();int xp=p.totalExperience;RecipeCollections.apply(p,data);
for(var collection:entries)if(collection.advancements().contains(id))check(h,data.collections.contains(collection.id())&&data.recipes.containsAll(collection.recipes()),"Advancement alone teaches whole family: "+id+""+collection.id());
check(h,!data.learned()&&data.items.isEmpty()&&p.totalExperience==xp,"No purchase, ingredient possession or bonus XP fabricated");
check(h,!RecipeCollections.apply(p,data),"Repeated evaluation is silent");
}
h.succeed();
}
@GameTest public void everySurvivalFamilyHasDiscoveryAccessAndTechnicalHasNone(GameTestHelper h){
var p=player(h);int families=0;
for(var collection:RecipeCollections.entries(h.getLevel().getServer())){
if(collection.operatorOnly()){check(h,collection.items().isEmpty()&&collection.advancements().isEmpty()&&collection.recipes().isEmpty(),"Technical content never grants survival recipes");continue;}
families++;var data=new RecipeKnowledge();check(h,!collection.items().isEmpty(),"A discovery route exists before the late advancement");data.items.add(collection.items().getFirst());RecipeCollections.apply(p,data);
check(h,data.collections.contains(collection.id())&&data.recipes.containsAll(collection.recipes()),"First family item teaches the family: "+collection.id());
}
var technical=new RecipeKnowledge();technical.items.add("minecraft:zombie_spawn_egg");technical.items.add("minecraft:barrier");RecipeCollections.apply(p,technical);
check(h,families==66&&!technical.collections.contains("sanctuary:technical_catalogue"),"66 survival collections; familiars never unlock the technical catalogue");h.succeed();
}
@GameTest public void discoveryBoundariesAndCircularDependencies(GameTestHelper h){
var p=player(h);var wood=new RecipeKnowledge();wood.items.add("minecraft:oak_planks");RecipeCollections.apply(p,wood);
check(h,wood.recipes.contains("minecraft:crafting_table")&&wood.recipes.contains("minecraft:oak_boat")&&wood.recipes.contains("minecraft:crimson_door"),"Wood and historical boats/Nether woodworking remain available");
check(h,!wood.recipes.contains("minecraft:piston")&&!wood.recipes.contains("minecraft:shield"),"A plank is not every recipe containing planks");
for(var example:Map.of("white_wool","white_bed","blaze_rod","brewing_stand","redstone","crafter","clay_ball","brush").entrySet()){
var data=new RecipeKnowledge();data.items.add("minecraft:"+example.getKey());RecipeCollections.apply(p,data);check(h,data.recipes.contains("minecraft:"+example.getValue()),"Starter teaches required tool before dependent advancement: "+example);
}
BlockKnowledgeService.recordSeen(p,Blocks.AMETHYST_BLOCK);RecipeService.refresh(p);
check(h,RecipeService.knowledge(p).collections.contains("sanctuary:amethyst_and_optics")&&!RecipeService.knowledge(p).items.contains("minecraft:spyglass"),"Discovery feeds collection knowledge without creating possession");h.succeed();
}
@GameTest public void legacyRecordsReloadAndPlayersRemainIndependent(GameTestHelper h){
var p=player(h);var old=new RecipeKnowledge();old.collections.add("sanctuary:basic_wood");old.recipes.add("minecraft:shield");old.recipes.add("another_pack:old_recipe");old.paid=4;old.purchasedAt=1;
String saved=old.json();var data=RecipeKnowledge.parse(saved);check(h,RecipeCollections.apply(p,data)&&data.recipes.contains("minecraft:poplar_door"),"Existing collection gains new plans without rediscovering ingredients");
String applied=data.json();for(int i=0;i<100;i++)check(h,!RecipeCollections.apply(p,data),"Stable record receives no repeated additions");
RecipeCollections.clear(h.getLevel().getServer());check(h,!RecipeCollections.apply(p,data)&&data.json().equals(applied),"Reload reconciles once and retains every old recipe including absent IDs");
p.setAttached(RecipeService.RECORD,data.json());ProgressEffects.apply(p,ProgressRecord.INITIAL);RecipeService.refresh(p);
check(h,RecipeService.knowledge(p).learned()&&RecipeService.knowledge(p).recipes.contains("another_pack:old_recipe"),"Progress reset preserves schema-1 recipe knowledge");
var other=new RecipeKnowledge();RecipeCollections.apply(player(h),other);check(h,!other.collections.contains("sanctuary:basic_wood")&&!other.recipes.contains("minecraft:shield"),"Personal collection access is isolated");h.succeed();
}
private static ServerPlayer player(GameTestHelper h){var p=Progression003GameTests.player(h);p.setUUID(UUID.randomUUID());p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(p,ProgressRecord.INITIAL);p.getInventory().clearContent();return p;}
private static void check(GameTestHelper h,boolean value,String message){h.assertTrue(value,message);}
}
@@ -0,0 +1,203 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.mixin.*;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.*;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.entity.monster.zombie.Zombie;
import net.minecraft.world.effect.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.phys.*;
import net.minecraft.world.InteractionHand;
/** Behavioral checks exercise native hooks, resource mutations, authority and locomotion. */
public final class Companion019GameTests {
@GameTest public void catalogMatchesEveryNativeEgg(GameTestHelper h){
var found=new HashSet<CompanionType>();
for(var item:BuiltInRegistries.ITEM)if(item instanceof SpawnEggItem){
var type=CompanionType.find(BuiltInRegistries.ENTITY_TYPE.getKey(SpawnEggItem.getType(new ItemStack(item))).toString());
h.assertTrue(type!=null,"Each native egg has a configured power definition");found.add(type);
}
h.assertTrue(found.size()==88&&found.size()==CompanionType.values().length,"Exactly 88 native familiar species");h.succeed();
}
@GameTest public void activePurchaseChargesOnceAndCooldownPersists(GameTestHelper h){
var p=player(h,CompanionType.ZOMBIE);p.giveExperienceLevels(10);
h.assertTrue(CompanionService.buy(p)&&p.experienceLevel==6,"Link costs four actual levels");
h.assertTrue(!CompanionService.buy(p)&&p.experienceLevel==6,"Duplicate purchase does not charge");
long until=CompanionService.now()+120000;CompanionService.save(p,CompanionService.record(p).cooldown(until));
CompanionService.clear(p);h.assertTrue(CompanionService.record(p).unlocked()&&CompanionService.record(p).readyAt()==until,"Lifecycle cache clearing preserves purchase and absolute cooldown");
h.assertTrue(CompanionService.activate(p,-1).equals("cooldown"),"Operator and ordinary activation both respect the shared cooldown");h.succeed();
}
@GameTest public void malformedStateIsPreserved(GameTestHelper h){
var p=player(h,CompanionType.CAT);String bad="{\"schema\":99}";p.setAttached(CompanionService.RECORD,bad);
h.assertTrue(!CompanionService.available(p)&&CompanionService.record(p)==null,"Future state disables only powers");
h.assertTrue(p.getAttached(CompanionService.RECORD).equals(bad),"Future data is not silently reset");h.succeed();
}
@GameTest public void switchingEggRemovesPassiveAndActive(GameTestHelper h){
var p=player(h,CompanionType.CAT);CompanionPassives.tick(p);
h.assertTrue(Math.abs(p.getAttributeValue(Attributes.FALL_DAMAGE_MULTIPLIER)-.8)<.001,"Cat applies native fall attribute");
CompanionEffects.add(p,p,CompanionEffects.Kind.DEFENCE,100,.25F);
AccessoryAccess.of(p).setItem(0,new ItemStack(Items.ZOMBIE_SPAWN_EGG));CompanionService.tick(p);
h.assertTrue(!CompanionEffects.has(p,CompanionEffects.Kind.DEFENCE)&&p.getAttributeValue(Attributes.FALL_DAMAGE_MULTIPLIER)==1,"Switch clears owned active and passive modifiers");
h.assertTrue(CompanionService.passive(p)==null,"New egg has its ten-second attunement");h.succeed();
}
@GameTest public void poisonDurationAndMilkUseNativeHooks(GameTestHelper h){
var p=player(h,CompanionType.CAVE_SPIDER);p.addEffect(new MobEffectInstance(MobEffects.POISON,100,0));
h.assertTrue(p.getEffect(MobEffects.POISON).getDuration()==80,"New poison is shortened by native addEffect hook");
equip(p,CompanionType.COW);p.addEffect(new MobEffectInstance(MobEffects.SPEED,300,0));
CompanionPassives.consume(new ItemStack(Items.MILK_BUCKET),p::removeAllEffects);
h.assertTrue(p.hasEffect(MobEffects.SPEED)&&!p.hasEffect(MobEffects.POISON),"Selective milk preserves beneficial effects while native clear is called");h.succeed();
}
@GameTest public void sharedMilkSpendsOneBucket(GameTestHelper h){
var p=player(h,CompanionType.COW);p.addEffect(new MobEffectInstance(MobEffects.POISON,100,0));p.getInventory().setItem(0,new ItemStack(Items.MILK_BUCKET));
var action=CompanionActions.prepare(p,CompanionType.COW,-1);h.assertTrue(action.success(),"Milk has an actual condition to clean");action.commit().run();
h.assertTrue(!p.hasEffect(MobEffects.POISON)&&p.getInventory().getItem(0).is(Items.BUCKET),"One milk consumed, one empty bucket returned");
h.assertTrue(!CompanionActions.prepare(p,CompanionType.COW,-1).success(),"No-op cure cannot spend again");h.succeed();
}
@GameTest public void healingCostsFoodAndStaysBelowThreeHearts(GameTestHelper h){
var p=player(h,CompanionType.ZOMBIE);p.setHealth(4);p.getFoodData().setFoodLevel(6);p.getFoodData().setSaturation(0);
var action=CompanionActions.prepare(p,CompanionType.ZOMBIE,-1);h.assertTrue(action.success(),"Second wind can start");action.commit().run();
h.assertTrue(p.getFoodData().getFoodLevel()==5,"One real food point spent up front");
h.assertTrue(CompanionEffects.canRecover(p,false),"A scheduled small heal leaves shared capacity until delivered");
CompanionEffects.tick(h.getLevel().getServer());h.assertTrue(p.getHealth()>4&&p.getHealth()<5.1&&p.getMaxHealth()==6,"Healing starts gradually without granting maximum health");
h.assertTrue(p.hurtServer(h.getLevel(),p.damageSources().generic(),1),"Fixture takes real native damage");CompanionEffects.tick(h.getLevel().getServer());
h.assertTrue(!CompanionEffects.has(p,CompanionEffects.Kind.HEAL),"Received damage interrupts recovery");h.succeed();
}
@GameTest public void airCannotExceedPurchasedCapacity(GameTestHelper h){
var p=player(h,CompanionType.COD);var at=p.blockPosition();
for(int y=0;y<3;y++)h.getLevel().setBlockAndUpdate(at.above(y),Blocks.WATER.defaultBlockState());p.baseTick();
p.setAirSupply(p.getMaxAirSupply()-5);var action=CompanionActions.prepare(p,CompanionType.COD,-1);
h.assertTrue(action.success(),"Bubble starts in water");action.commit().run();
h.assertTrue(p.getAirSupply()==p.getMaxAirSupply()&&p.getMaxAirSupply()==90,"One bubble clamps to the three-bubble capacity");
h.assertTrue(Math.abs(CompanionBudget.remaining(p,CompanionBudget.Pool.AIR)-25)<.001,"Only five air actually restored are charged to the beneficiary");h.succeed();
}
@GameTest public void allyCanRevokeAnExistingEffect(GameTestHelper h){
var p=player(h,CompanionType.POLAR_BEAR);var ally=player(h,CompanionType.CAT);
CompanionService.save(ally,CompanionService.record(ally).consent(true));CompanionEffects.add(p,ally,CompanionEffects.Kind.MELEE_DEFENCE,100,.25F);
h.assertTrue(CompanionEffects.has(ally,CompanionEffects.Kind.MELEE_DEFENCE),"Consent permits finite effect");
CompanionService.save(ally,CompanionService.record(ally).consent(false));h.assertTrue(!CompanionEffects.has(ally,CompanionEffects.Kind.MELEE_DEFENCE),"Revocation immediately removes effect applicability");h.succeed();
}
@GameTest public void damageBudgetAndNoTerrainExplosion(GameTestHelper h){
var p=player(h,CompanionType.WITHER);var target=EntityTypes.ZOMBIE.create(h.getLevel(),EntitySpawnReason.COMMAND);target.setNoAi(true);target.setPos(p.position().add(0,0,2));h.getLevel().addFreshEntity(target);
var cast=new CompanionCombat.Cast(p,CompanionType.WITHER);float health=target.getHealth();
h.assertTrue(CompanionCombat.hit(cast,target,12),"A visible hostile target can be hit");
target.setInvulnerableTime(0);h.assertTrue(!CompanionCombat.hit(cast,target,12),"Three skulls share one twelve-point budget per PvE target");
h.assertTrue(health-target.getHealth()<=12&&cast.spent.get(target.getUUID())==12,"Native armour and budget apply");
var block=p.blockPosition().below();h.assertTrue(h.getLevel().getBlockState(block).is(Blocks.STONE),"No terrain explosion");target.discard();h.succeed();
}
@GameTest public void lockedInventoryCannotSupplyConsumables(GameTestHelper h){
var p=player(h,CompanionType.SHEEP);p.setXRot(85);p.getInventory().setItem(9,new ItemStack(Items.WOOL.white(),10));
h.assertTrue(!CompanionActions.prepare(p,CompanionType.SHEEP,-1).success(),"Locked rows cannot fund powers");
p.getInventory().setItem(0,new ItemStack(Items.WOOL.white(),1));var action=CompanionActions.prepare(p,CompanionType.SHEEP,-1);h.assertTrue(action.success(),"Hotbar can fund a real cushion");action.commit().run();
h.assertTrue(p.getInventory().getItem(0).isEmpty()&&p.getInventory().getItem(9).getCount()==10,"Only the unlocked material is consumed");
h.assertTrue(CompanionEffects.fall(p,10)==6&&CompanionEffects.fall(p,10)==10,"Shared cushion saves at most four points, exactly once");h.succeed();
}
@GameTest public void creatureProfilesActuallyMove(GameTestHelper h){
var p=player(h,CompanionType.ENDER_DRAGON);var pet=FamiliarService.active(p);var start=pet.position();
for(int i=0;i<120;i++)pet.tick();
h.assertTrue(pet.isNoGravity()&&pet.motion()==FamiliarMotion.DRAGON,"Dragon uses aerial navigation");
h.assertTrue(pet.position().distanceToSqr(start)>1&&pet.getY()>p.getY()+.5,"Native navigation produces an actual airborne displacement");
h.assertTrue(h.getLevel().noBlockCollision(pet,pet.getBoundingBox()),"Flying familiar stays out of solid blocks");
equip(p,CompanionType.GHAST);pet=FamiliarService.active(p);pet.tick();h.assertTrue(pet.isNoGravity(),"Ghast hovers rather than walking");
equip(p,CompanionType.WITHER);pet=FamiliarService.active(p);pet.tick();h.assertTrue(pet.isNoGravity(),"Wither hovers rather than walking");h.succeed();
}
@GameTest public void aquaticPetFlopsAndThenSwims(GameTestHelper h){
var p=player(h,CompanionType.SALMON);var pet=FamiliarService.active(p);pet.tick();
h.assertTrue(!pet.isNoGravity()&&pet.getAttributeValue(Attributes.MOVEMENT_SPEED)<=.06,"Fish struggles on dry ground");
var at=p.blockPosition();for(int x=-4;x<=4;x++)for(int z=-4;z<=4;z++)for(int y=0;y<4;y++)h.getLevel().setBlockAndUpdate(at.offset(x,y,z),Blocks.WATER.defaultBlockState());
pet.setPos(p.position().add(2,0,0));p.baseTick();for(int i=0;i<15;i++)pet.tick();
h.assertTrue(pet.isInWater()&&pet.isNoGravity()&&pet.getAttributeValue(Attributes.MOVEMENT_SPEED)>.3,"Fish switches to buoyant native swimming in water");h.succeed();
}
@GameTest public void projectileBudgetAndFriendlyTargets(GameTestHelper h){
var p=player(h,CompanionType.WITHER);var other=player(h,CompanionType.CAT);other.setPos(p.position().add(0,0,2));
var cast=new CompanionCombat.Cast(p,CompanionType.WITHER);
h.assertTrue(CompanionCombat.hit(cast,other,12),"Native PvP hit is accepted");other.setInvulnerableTime(0);
h.assertTrue(!CompanionCombat.hit(cast,other,12)&&other.getHealth()>=4,"All three skulls share a two-point PvP budget");
var wolf=EntityTypes.WOLF.create(h.getLevel(),EntitySpawnReason.COMMAND);wolf.setTame(true,true);wolf.setPos(p.position().add(2,0,0));h.getLevel().addFreshEntity(wolf);
h.assertTrue(!CompanionCombat.hit(new CompanionCombat.Cast(p,CompanionType.WITHER),wolf,12),"Tamed animals are never life-steal targets");wolf.discard();h.succeed();
}
@GameTest public void potionSharingRestoresIndependentEffects(GameTestHelper h){
var p=player(h,CompanionType.WITCH);p.setYRot(0);p.setXRot(0);var ally=player(h,CompanionType.CAT);ally.setPos(p.position().add(0,0,4));h.getLevel().addNewPlayer(ally);
try{
CompanionService.save(ally,CompanionService.record(ally).consent(true));ally.addEffect(new MobEffectInstance(MobEffects.SPEED,1000,0));
var potion=new ItemStack(Items.POTION);potion.set(net.minecraft.core.component.DataComponents.POTION_CONTENTS,
new net.minecraft.world.item.alchemy.PotionContents(Optional.empty(),Optional.empty(),List.of(new MobEffectInstance(MobEffects.SPEED,200,1)),Optional.empty()));p.setItemInHand(InteractionHand.MAIN_HAND,potion);
var action=CompanionActions.prepare(p,CompanionType.WITCH,-1);h.assertTrue(action.success(),"A stronger real potion can be shared");action.commit().run();
CompanionZones.tick(h.getLevel().getServer());
h.assertTrue(p.getMainHandItem().is(Items.GLASS_BOTTLE)&&ally.getEffect(MobEffects.SPEED).getDuration()==200&&ally.getEffect(MobEffects.SPEED).getAmplifier()==1,"One full native dose, native strength, and one empty bottle");
ally.addEffect(new MobEffectInstance(MobEffects.SPEED,600,2));CompanionWork.cancel(p);
h.assertTrue(ally.getEffect(MobEffects.SPEED).getAmplifier()==2&&ally.getEffect(MobEffects.SPEED).getDuration()==600,"Later ordinary potion survives familiar cancellation");
}finally{CompanionWork.cancel(p);ally.discard();}h.succeed();
}
@GameTest public void villagerBatchUsesNativeCostsAndStock(GameTestHelper h){
var p=player(h,CompanionType.VILLAGER);p.setYRot(0);p.setXRot(0);var villager=EntityTypes.VILLAGER.create(h.getLevel(),EntitySpawnReason.COMMAND);villager.setNoAi(true);villager.setPos(p.position().add(0,0,2));
var offer=new net.minecraft.world.item.trading.MerchantOffer(new net.minecraft.world.item.trading.ItemCost(Items.EMERALD,2),new ItemStack(Items.BREAD),2,1,0);
var offers=new net.minecraft.world.item.trading.MerchantOffers();offers.add(offer);villager.setOffers(offers);h.getLevel().addFreshEntity(villager);p.getInventory().setItem(0,new ItemStack(Items.EMERALD,10));
try{
h.assertTrue(CompanionTrades.batch(p,-1).reason().equals("choose_offer"),"A real merchant creates a server-held quote");
var action=CompanionTrades.batch(p,0);h.assertTrue(action.success(),"Quoted trade can execute");action.commit().run();
var inventory=new CompanionInventory.Plan(p);h.assertTrue(inventory.count(s->s.is(Items.EMERALD))==6&&inventory.count(s->s.is(Items.BREAD))==2&&offer.getUses()==2,"Native stock caps the batch at two trades with exact payment and output");
h.assertTrue(!CompanionTrades.batch(p,0).success(),"The consumed quote cannot be replayed");
}finally{CompanionTrades.cancel(p);villager.discard();}h.succeed();
}
@GameTest public void cropCyclesStopAfterOwnerLeaves(GameTestHelper h){
var p=player(h,CompanionType.BEE);h.getLevel().addNewPlayer(p);var at=p.blockPosition().offset(2,0,0);h.getLevel().setBlockAndUpdate(at.below(),Blocks.FARMLAND.defaultBlockState());h.getLevel().setBlockAndUpdate(at,Blocks.WHEAT.defaultBlockState());
try{
var state=h.getLevel().getBlockState(at);var random=net.minecraft.util.RandomSource.create(42);
CompanionProduction.cropRoll(h.getLevel(),at,state,random,25,1);String raw=h.getLevel().getAttached(CompanionProduction.CROPS);
h.assertTrue(raw!=null&&raw.contains(p.getUUID().toString()),"Only a present bee owner can start a crop cycle");
CompanionProduction.interrupt(p);h.assertTrue(h.getLevel().getAttached(CompanionProduction.CROPS).contains("\"valid\":false"),"Leaving or changing the bond invalidates the cycle");
int changed=0;for(int i=0;i<200;i++)if(CompanionProduction.cropRoll(h.getLevel(),at,state,random,25,1)==0)changed++;
h.assertTrue(changed==0,"Returning cannot harvest stored growth credit");
}finally{p.discard();}h.succeed();
}
@GameTest public void furnaceBonusConsumesFuelAndKeepsOneOutput(GameTestHelper h){
var p=player(h,CompanionType.BLAZE);h.getLevel().addNewPlayer(p);
try{
var a=p.blockPosition().offset(2,0,0);var b=p.blockPosition().offset(6,0,0);
h.getLevel().setBlockAndUpdate(a,Blocks.FURNACE.defaultBlockState());h.getLevel().setBlockAndUpdate(b,Blocks.FURNACE.defaultBlockState());
var boosted=(net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity)h.getLevel().getBlockEntity(a);
var ordinary=(net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity)h.getLevel().getBlockEntity(b);
for(var furnace:List.of(boosted,ordinary)){furnace.setItem(0,new ItemStack(Items.RAW_IRON,2));furnace.setItem(1,new ItemStack(Items.COAL));}
for(int i=0;i<100;i++)for(var pos:List.of(a,b))net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity.serverTick(h.getLevel(),pos,h.getLevel().getBlockState(pos),(net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity)h.getLevel().getBlockEntity(pos));
var fast=(CompanionFurnaceAccess)boosted;var slow=(CompanionFurnaceAccess)ordinary;
h.assertTrue(fast.sanctuary$cooking()>slow.sanctuary$cooking()&&fast.sanctuary$fuel()<slow.sanctuary$fuel(),"Extra cooking progress consumes matching fuel");
for(int i=0;i<100;i++)net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity.serverTick(h.getLevel(),a,h.getLevel().getBlockState(a),boosted);
h.assertTrue(boosted.getItem(2).getCount()==1&&boosted.getItem(0).getCount()==1,"Bonus changes speed without duplicating smelted output");
}finally{CompanionProduction.interrupt(p);p.discard();}h.succeed();
}
@GameTest public void temporaryFangsCannotDealNativeDamageOrSave(GameTestHelper h){
var p=player(h,CompanionType.EVOKER);var fangs=new net.minecraft.world.entity.projectile.EvokerFangs(h.getLevel(),p.getX(),p.getY(),p.getZ(),0,0,p);
fangs.setAttached(CompanionProjectiles.VISUAL_FANGS,true);h.getLevel().addFreshEntity(fangs);
h.assertTrue(!fangs.shouldBeSaved(),"Telegraph cannot become an autonomous damaging entity after reload");fangs.discard();h.succeed();
}
@GameTest public void movementStopsRootAndSupportRemovalStopsSuspension(GameTestHelper h){
var p=player(h,CompanionType.CREAKING);CompanionEffects.add(p,p,CompanionEffects.Kind.ROOT,120,.25F);CompanionEffects.add(p,p,CompanionEffects.Kind.KNOCKBACK,120,.5F);
p.setPos(p.position().add(1,0,0));CompanionEffects.tick(h.getLevel().getServer());
h.assertTrue(!CompanionEffects.has(p,CompanionEffects.Kind.ROOT)&&!CompanionEffects.has(p,CompanionEffects.Kind.KNOCKBACK),"Rooted resistance ends with root movement");
equip(p,CompanionType.SHULKER);var at=p.blockPosition().offset(0,1,2);h.getLevel().setBlockAndUpdate(at,Blocks.STONE.defaultBlockState());
CompanionEffects.add(p,p,CompanionEffects.Kind.SUSPEND,120,0).support=at;CompanionEffects.tick(h.getLevel().getServer());h.assertTrue(CompanionEffects.has(p,CompanionEffects.Kind.SUSPEND),"A solid support permits the finite suspension");
h.getLevel().setBlockAndUpdate(at,Blocks.AIR.defaultBlockState());CompanionEffects.tick(h.getLevel().getServer());h.assertTrue(!CompanionEffects.has(p,CompanionEffects.Kind.SUSPEND),"Removing support ends the suspension");h.succeed();
}
static ServerPlayer player(GameTestHelper h,CompanionType type){
var p=Progression003GameTests.player(h);p.setUUID(UUID.randomUUID());var record=ProgressRecord.INITIAL;
p.connection.handleAcceptPlayerLoad(new net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket());
p.setAttached(ProgressionService.RECORD,record.json());ProgressEffects.apply(p,record);p.setHealth(6);p.getInventory().clearContent();
var at=p.blockPosition();for(int x=-7;x<=7;x++)for(int z=-7;z<=7;z++)for(int y=-1;y<8;y++)h.getLevel().setBlockAndUpdate(at.offset(x,y,z),(y==-1?Blocks.STONE:Blocks.AIR).defaultBlockState());
p.setOnGround(true);equip(p,type);return p;
}
static void equip(ServerPlayer p,CompanionType type){
var item=BuiltInRegistries.ITEM.getValue(net.minecraft.resources.Identifier.withDefaultNamespace(type.id()+"_spawn_egg"));
AccessoryAccess.of(p).setItem(0,new ItemStack(item));p.tickCount=((p.tickCount/10)+1)*10;FamiliarService.tick(p);CompanionService.tick(p);
CompanionService.runtime(p).equippedAt=p.tickCount-200;
}
}
@@ -0,0 +1,204 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.inventory.*;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.*;
import net.minecraft.world.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.effect.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.phys.*;
public final class Companion032GameTests {
@GameTest public void sharedHealingAllowsSmallComplementAndSurvivesReload(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.AXOLOTL);p.setHealth(2);
h.assertTrue(Math.abs(CompanionBudget.heal(p,.3F)-.3)<.0001,"A small heal is delivered fractionally");
h.assertTrue(Math.abs(CompanionBudget.remaining(p,CompanionBudget.Pool.HEALTH)-.9)<.0001,"Small heal leaves a complement");
CompanionService.clear(p);Companion019GameTests.equip(p,CompanionType.ZOMBIE);
h.assertTrue(Math.abs(CompanionBudget.heal(p,4)-.9)<.0001,"Changing egg and clearing caches cannot refill shared budget");
h.assertTrue(CompanionBudget.heal(p,1)<.0001&&Math.abs(p.getHealth()-3.2)<.0001,"All sources together restore at most 20 percent of six HP");h.succeed();
}
@GameTest public void ledgerExpiryAndMalformedData(GameTestHelper h){
var state=CompanionBudget.EMPTY.debit(CompanionBudget.Pool.HEALTH,.25,1001).debit(CompanionBudget.Pool.HEALTH,.5,1100);
h.assertTrue(state.health().size()==1&&Math.abs(state.remaining(CompanionBudget.Pool.HEALTH,1.2,21001)-.45)<1e-9,"Bucket expires conservatively");
h.assertTrue(state.remaining(CompanionBudget.Pool.HEALTH,1.2,21250)==1.2,"Expired spending recovers");
h.assertTrue(CompanionBudget.State.parse(state.json()).equals(state),"Budget has exact persistence round trip");
var p=Companion019GameTests.player(h,CompanionType.CAT);String raw="{\"schema\":99}";p.setAttached(CompanionBudget.RECORD,raw);
h.assertTrue(!CompanionService.available(p)&&p.getAttached(CompanionBudget.RECORD).equals(raw),"Invalid budget blocks powers and retains original data");h.succeed();
}
@GameTest public void legacyDeadlineRemainsHonored(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.AXOLOTL);p.setHealth(2);
CompanionService.save(p,CompanionService.record(p).recovery(false,CompanionService.now()+60000));
h.assertTrue(CompanionBudget.heal(p,1)==0,"Migration must not shorten an existing legacy recovery deadline");h.succeed();
}
@GameTest public void airSavingsAreCountedPerBeneficiary(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.NAUTILUS);
h.assertTrue(CompanionBudget.air(p,12)==12&&CompanionBudget.air(p,50)==18&&CompanionBudget.air(p,1)==0,"Three-bubble capacity yields only thirty active air ticks");
CompanionService.clear(p);Companion019GameTests.equip(p,CompanionType.COD);
h.assertTrue(CompanionBudget.air(p,1)==0,"Changing air source cannot refill recipient allowance");h.succeed();
}
@GameTest public void potionMistMovesWithoutCopyingDose(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.WITCH);var ally=Companion019GameTests.player(h,CompanionType.CAT);
ally.setPos(p.position().add(0,0,4));h.getLevel().addNewPlayer(ally);
try{
var effect=new MobEffectInstance(MobEffects.SPEED,600,0);
var zone=CompanionZones.add(p,CompanionZones.Kind.MIST,ally.position(),1.5,120,List.of(effect));
int end=zone.end;zone.velocity=new Vec3(.1,0,0);CompanionZones.tick(h.getLevel().getServer());
h.assertTrue(zone.end==end&&zone.spent&&zone.beneficiaries.size()==1,"Movement preserves TTL and only one dose is served");
h.assertTrue(ally.hasEffect(MobEffects.SPEED)&&!p.hasEffect(MobEffects.SPEED),"Exactly one recipient receives the consumed potion");
CompanionZones.tick(h.getLevel().getServer());CompanionService.clear(p);
h.assertTrue(ally.getEffect(MobEffects.SPEED).getDuration()==600,"Real paid potion remains native after familiar removal");
}finally{ally.discard();CompanionZones.cancel(p);}h.succeed();
}
@GameTest public void cushionsServeFourDistinctPlayersOnlyOnce(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.SHEEP);var zone=CompanionZones.add(p,CompanionZones.Kind.CUSHION,p.position(),1,160);
h.assertTrue(CompanionEffects.fall(p,10)==6&&CompanionEffects.fall(p,10)==10,"Cushion saves half up to four HP once");
for(int i=0;i<4;i++){
var q=Companion019GameTests.player(h,CompanionType.CAT);q.setPos(p.position());
h.assertTrue(CompanionEffects.fall(q,10)==(i<3?6:10),"Zone cannot recycle its four lifetime recipients");
}
CompanionZones.cancel(p);h.succeed();
}
@GameTest public void aerialBudgetCannotResetOnPlayerOrMagicalSupport(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.RABBIT);
h.assertTrue(CompanionBudget.movement(p,3,2,true),"First hop fits shared envelope");
h.assertTrue(!CompanionBudget.movement(p,1,0,true),"Only one rabbit hop per flight");
h.assertTrue(CompanionBudget.movement(p,5,2,false)&&!CompanionBudget.movement(p,.01,0,false),"Different eggs share eight horizontal and four rising blocks");
p.setOnGround(false);for(int i=0;i<8;i++)CompanionBudget.tick(p);
h.assertTrue(CompanionBudget.state(p).horizontal()==8,"Air time does not reset movement");
p.setOnGround(true);for(int i=0;i<4;i++)CompanionBudget.tick(p);
h.assertTrue(CompanionBudget.state(p).horizontal()==0&&!CompanionBudget.state(p).rabbit(),"Actual stable terrain rearms flight");h.succeed();
}
@GameTest public void axolotlFoodDurationAndGolemKnockbackUseNativeHooks(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.AXOLOTL);
h.assertTrue(new ItemStack(Items.BREAD).getUseDuration(p)==29,"Food is ten percent faster on land");
Companion019GameTests.equip(p,CompanionType.IRON_GOLEM);
h.assertTrue(Math.abs(CompanionPassives.knockback(p,1,null)-.8)<.00001,"Golem grants constant twenty percent knockback reduction");h.succeed();
}
@GameTest public void invalidAimedCushionDoesNotConsumeWool(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.SHEEP);p.setXRot(-80);p.getInventory().setItem(0,new ItemStack(Items.WOOL.white(),2));
h.assertTrue(!CompanionActions.prepare(p,CompanionType.SHEEP,-1).success()&&p.getMainHandItem().getCount()==2,"Invalid target never consumes wool");h.succeed();
}
@GameTest public void defaultCatalogKeepsAllRaritiesAndNewCooldowns(GameTestHelper h){
h.assertTrue(CompanionType.values().length==88&&CompanionConfig.DEFAULT.species().size()==88,"Eighty-eight species remain configured");
h.assertTrue(CompanionType.RABBIT.cooldown==8&&CompanionType.WITCH.cooldown==30&&CompanionType.NAUTILUS.cooldown==45,"Precise prototype cooldowns");
long common=Arrays.stream(CompanionType.values()).filter(t->t.rarity==CompanionType.Rarity.COMMON).count();h.assertTrue(common==22,"Existing rarity distribution remains stable");h.succeed();
}
@GameTest public void guardsAndPotionsDoNotMultiply(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.IRON_GOLEM);p.setYRot(0);p.setXRot(0);
var zombie=net.minecraft.world.entity.EntityTypes.ZOMBIE.create(h.getLevel(),net.minecraft.world.entity.EntitySpawnReason.COMMAND);zombie.setPos(p.position().add(0,0,2));h.getLevel().addFreshEntity(zombie);
var source=p.damageSources().mobAttack(zombie);CompanionZones.add(p,CompanionZones.Kind.GUARD,p.position(),3,80);
h.assertTrue(Math.abs(CompanionEffects.incoming(p,source,10)-6)<.001,"Frontal guard reduces forty percent");
zombie.setPos(p.position().add(0,0,-2));h.assertTrue(CompanionEffects.incoming(p,source,10)==10,"Guard leaves the back exposed");
zombie.setPos(p.position().add(0,0,2));p.addEffect(new MobEffectInstance(MobEffects.RESISTANCE,200,1));
h.assertTrue(CompanionEffects.incoming(p,source,10)==10,"Equal native Resistance receives no extra familiar multiplication");
p.removeEffect(MobEffects.RESISTANCE);CompanionBudget.spend(p,CompanionBudget.Pool.GUARD,6000);
h.runAfterDelay(1,()->{h.assertTrue(Math.abs(CompanionEffects.incoming(p,source,10)-7.5)<.001,"Exhausted shared guard falls back to twenty-five percent");zombie.discard();CompanionZones.cancel(p);h.succeed();});
}
@GameTest(maxTicks=100) public void controlledMonstersGainResistanceAndPlayersAreNotRooted(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.CREAKING);
var mob=EntityTypes.ZOMBIE.create(h.getLevel(),EntitySpawnReason.COMMAND);mob.setNoAi(true);mob.setPos(p.position().add(0,0,2));h.getLevel().addFreshEntity(mob);
CompanionEffects.root(p,mob,100);var root=CompanionEffects.get(mob,CompanionEffects.Kind.ROOTED);
h.assertTrue(root!=null&&root.end-root.start==60,"Hard control is capped at three seconds");
CompanionEffects.root(p,p,100);h.assertTrue(!CompanionEffects.has(p,CompanionEffects.Kind.ROOTED),"No hard crowd control on players");
h.runAfterDelay(61,()->{CompanionEffects.root(p,mob,60);h.assertTrue(!CompanionEffects.has(mob,CompanionEffects.Kind.ROOTED),"Another controller cannot bypass post-control resistance");mob.discard();h.succeed();});
}
@GameTest public void aimPriorityStillKeepsOwnerAndFourRecipientLimit(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.SKELETON);p.setYRot(0);p.setXRot(0);
var people=new ArrayList<ServerPlayer>();
try{for(int i=0;i<5;i++){
var q=Companion019GameTests.player(h,CompanionType.CAT);q.setPos(p.position().add(i==0?0:i,0,3));h.getLevel().addNewPlayer(q);people.add(q);
}
var recipients=CompanionWorld.allies(p,16,4,true);
h.assertTrue(recipients.size()==4&&recipients.getFirst()==people.getFirst()&&recipients.get(1)==p,"Aimed ally, owner, then nearest within the four-person cap");
}finally{people.forEach(Entity::discard);}h.succeed();
}
@GameTest public void barrelUnloadingConservesStockAndProtectsHotbar(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.DONKEY);
var progress=ProgressEffects.get(p).buy("inventory",1);ProgressEffects.apply(p,progress);
var at=p.blockPosition().offset(0,0,2);h.getLevel().setBlockAndUpdate(at,Blocks.BARREL.defaultBlockState());
var container=CompanionGestures.container(p,at);container.setItem(0,new ItemStack(Items.COBBLESTONE,60));
p.getInventory().setItem(0,new ItemStack(Items.COBBLESTONE,10));p.getInventory().setItem(9,new ItemStack(Items.COBBLESTONE,20));p.getInventory().setItem(10,new ItemStack(Items.DIRT,5));
var action=CompanionInventory.transfer(p,container,18,true);h.assertTrue(action!=null,"Barrel accepts existing types");action.run();
int total=0;for(int i=0;i<container.getContainerSize();i++)if(container.getItem(i).is(Items.COBBLESTONE))total+=container.getItem(i).getCount();
h.assertTrue(total==80&&p.getInventory().getItem(0).getCount()==10&&p.getInventory().getItem(9).isEmpty()&&p.getInventory().getItem(10).getCount()==5,"Exactly twenty matching items move; hotbar and other types stay");h.succeed();
}
@GameTest public void everyEggHasARealActionPath(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.CAT);
for(var type:CompanionType.values()){
Companion019GameTests.equip(p,type);var action=CompanionActions.prepare(p,type,-1);
h.assertTrue(action!=null&&(action.success()||!action.reason().isEmpty()),"Every egg offers an action or a contextual refusal: "+type);
}
h.succeed();
}
@GameTest public void migrationPreservesCustomConfigAndRunsOnce(GameTestHelper h){
try{
java.nio.file.Files.createDirectories(java.nio.file.Path.of("build"));var dir=java.nio.file.Files.createTempDirectory(java.nio.file.Path.of("build"),"companion032-config-");var file=dir.resolve("powers.json");
var entries=new TreeMap<>(CompanionConfig.DEFAULT.species());
entries.put("rabbit",new CompanionConfig.Entry(true,CompanionType.Rarity.COMMON,25));
entries.put("witch",new CompanionConfig.Entry(false,CompanionType.Rarity.RARE,37));
var original=new CompanionConfig(1,true,4,10,2,entries).json();java.nio.file.Files.writeString(file,original);
var migrated=CompanionConfig.load(file);h.assertTrue(migrated.entry(CompanionType.RABBIT).cooldownSeconds()==8,"Legacy default cooldown migrates");
h.assertTrue(migrated.entry(CompanionType.WITCH).cooldownSeconds()==37&&!migrated.entry(CompanionType.WITCH).enabled(),"Custom cooldown, rarity and disable flag survive");
java.nio.file.Files.writeString(file,original);h.assertTrue(CompanionConfig.load(file).entry(CompanionType.RABBIT).cooldownSeconds()==25,"The marker makes migration one-time");
h.assertTrue(java.nio.file.Files.readString(file.resolveSibling("powers.json.pre-v2")).equals(original),"Original config is backed up exactly");
}catch(Exception e){throw new AssertionError(e);}h.succeed();
}
@GameTest public void budgetsSurviveNativeSaveDeathAndPrestige(GameTestHelper h) throws Exception {
var p=Companion019GameTests.player(h,CompanionType.AXOLOTL);p.setHealth(2);CompanionBudget.heal(p,.4F);CompanionBudget.air(p,12);CompanionBudget.movement(p,3,2,true);
String expected=p.getAttached(CompanionBudget.RECORD);
var saved=net.minecraft.world.level.storage.TagValueOutput.createWithContext(net.minecraft.util.ProblemReporter.DISCARDING,h.getLevel().registryAccess());p.saveWithoutId(saved);
var loaded=Companion019GameTests.player(h,CompanionType.CAT);loaded.load(net.minecraft.world.level.storage.TagValueInput.create(net.minecraft.util.ProblemReporter.DISCARDING,h.getLevel().registryAccess(),saved.buildResult()));
h.assertTrue(expected.equals(loaded.getAttached(CompanionBudget.RECORD)),"Native player save/load retains exact v2 accounting");
var respawn=Companion019GameTests.player(h,CompanionType.CAT);respawn.setUUID(p.getUUID());respawn.restoreFrom(p,false);
net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents.AFTER_RESPAWN.invoker().afterRespawn(p,respawn,false);
h.assertTrue(expected.equals(respawn.getAttached(CompanionBudget.RECORD)),"Death copies all pending budgets");
var session=ProgressionService.session(respawn.level().getServer());session.inhabitants.create(respawn.getUUID(),"budget"+respawn.getUUID().toString().substring(0,8),"Budget fixture",session.inhabitants.available().getFirst());
var progress=ProgressEffects.get(respawn);for(String skill:ProgressRecord.SKILLS)while(progress.rank(skill)<ProgressRecord.cap(skill))progress=progress.buy(skill,1);
respawn.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(respawn,progress);
String answer=fr.koka.sanctuary.cycle.CycleService.perform(respawn,new fr.koka.sanctuary.cycle.CyclePayloads.Action("ngp","","",session.cycles.revision(),progress.revision()));
h.assertTrue(answer.equals("done")&&expected.equals(respawn.getAttached(CompanionBudget.RECORD)),"Actual New Game Plus preserves budgets despite reduced capacities");h.succeed();
}
@GameTest public void thirtyRecipientsKeepBoundedAccounting(GameTestHelper h){
long began=System.nanoTime();var people=new CompanionBudget.State[30];Arrays.fill(people,CompanionBudget.EMPTY);
for(int tick=0;tick<1200;tick++)for(int i=0;i<people.length;i++){
long now=100000+tick*50L;var state=people[i];
for(var pool:CompanionBudget.Pool.values()){
double cap=pool==CompanionBudget.Pool.HEALTH?1.2:pool==CompanionBudget.Pool.AIR?30:6000;
double amount=Math.min(pool==CompanionBudget.Pool.HEALTH?.03:pool==CompanionBudget.Pool.AIR?1:50,state.remaining(pool,cap,now));
if(amount>1e-8)state=state.debit(pool,amount,now);
h.assertTrue(state.remaining(pool,cap,now)<=cap+1e-8,"Thirty overlapping sources cannot exceed a recipient cap");
}
people[i]=state;
}
for(var state:people)h.assertTrue(state.json().length()<24576&&CompanionBudget.State.parse(state.json()).equals(state),"Rolling ledgers remain bounded and serializable");
System.out.println("FAM032: thirty-recipient, sixty-second budget simulation in "+((System.nanoTime()-began)/1_000_000)+" ms; no FPS claim");h.succeed();
}
@GameTest public void breezeMovesAnotherPlayersPotionWithoutRenewingIt(GameTestHelper h){
var witch=Companion019GameTests.player(h,CompanionType.WITCH);
var breeze=Companion019GameTests.player(h,CompanionType.BREEZE);breeze.setYRot(0);breeze.setXRot(0);
var at=breeze.getEyePosition().add(0,0,3);
var zone=CompanionZones.add(witch,CompanionZones.Kind.MIST,at,1.5,120,List.of(new MobEffectInstance(MobEffects.SPEED,600,0)));
int end=zone.end;
try{
var wind=CompanionGestures.wind(breeze);h.assertTrue(wind.success(),"Wind has a native action");wind.commit().run();
CompanionZones.tick(h.getLevel().getServer());
h.assertTrue(zone.center.z>at.z&&zone.end==end&&zone.owner==witch&&!zone.spent&&zone.potion.size()==1,"Another player's gust moves the same paid dose, owner and expiry intact");
}finally{CompanionZones.cancel(witch);CompanionService.clear(breeze);}h.succeed();
}
@GameTest public void beeHarvestUsesARealSeedAndWearsTheHoe(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.BEE);p.getInventory().setItem(0,new ItemStack(Items.IRON_HOE));
var at=p.blockPosition().offset(0,0,2);h.getLevel().setBlockAndUpdate(at.below(),Blocks.FARMLAND.defaultBlockState());
var wheat=(CropBlock)Blocks.WHEAT;h.getLevel().setBlockAndUpdate(at,wheat.getStateForAge(wheat.getMaxAge()));
h.assertTrue(!CompanionHarvest.harvest(p,at)&&wheat.isMaxAge(h.getLevel().getBlockState(at)),"No owned seed means no harvest or free replant");
p.getInventory().setItem(1,new ItemStack(Items.WHEAT_SEEDS,2));
h.assertTrue(CompanionHarvest.harvest(p,at),"One authorized ripe crop is harvested natively");
h.assertTrue(h.getLevel().getBlockState(at).equals(wheat.getStateForAge(0))&&p.getInventory().getItem(1).getCount()==1&&p.getMainHandItem().getDamageValue()==1,"Exactly one real seed and one hoe durability pay for replanting");h.succeed();
}
}
@@ -0,0 +1,233 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.cosmetics.FamiliarService;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.*;
import net.minecraft.core.component.DataComponents;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.effect.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.phys.*;
public class Companion044GameTests {
@GameTest(maxTicks=120) public void slimePlacesRealBlockUnderPlayerThenExpires(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.SLIME);var pos=p.blockPosition();var ground=h.getLevel().getBlockState(pos.below());
var action=CompanionActions.prepare(p,CompanionType.SLIME,-1);h.assertTrue(action.success(),"Ordinary ground permits a block below the player");action.commit().run();
var state=h.getLevel().getBlockState(pos);h.assertTrue(state.is(CompanionSlimeBlock.BLOCK),"A real world block is placed");
h.assertTrue(p.getY()==pos.getY()+1&&!state.getCollisionShape(h.getLevel(),pos).isEmpty()&&state.getBlock().getBounceRestitution()==1,"Full native slime collision and bounce");
h.assertTrue(h.getLevel().getBlockTicks().hasScheduledTick(pos,CompanionSlimeBlock.BLOCK),"Lifetime uses persisted native block ticks");
h.runAfterDelay(102,()->{h.assertTrue(h.getLevel().getBlockState(pos).isAir(),"Block disappears after five seconds");h.assertTrue(h.getLevel().getBlockState(pos.below())==ground,"Original ground preserved");h.succeed();});
}
@GameTest public void slimeRefusesLowCeilingAndCannotDropPermanentSlime(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.SLIME);var pos=p.blockPosition();h.getLevel().setBlockAndUpdate(pos.above(2),Blocks.STONE.defaultBlockState());
var before=p.position();h.assertTrue(!CompanionActions.prepare(p,CompanionType.SLIME,-1).success()&&p.position().equals(before),"No suffocation beneath a ceiling");
h.getLevel().setBlockAndUpdate(pos.above(2),Blocks.AIR.defaultBlockState());CompanionActions.prepare(p,CompanionType.SLIME,-1).commit().run();
h.getLevel().destroyBlock(pos,true);h.assertTrue(h.getLevel().getEntitiesOfClass(ItemEntity.class,p.getBoundingBox().inflate(3)).stream().noneMatch(e->e.getItem().is(Items.SLIME_BLOCK)),"Temporary block cannot be harvested as a permanent block");h.succeed();
}
@GameTest(maxTicks=120) public void vexMiningDoesNotRequireBuildingRank(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.VEX);progress(p,ProgressRecord.INITIAL.buy("mining",1));
p.setShiftKeyDown(true);p.setPose(Pose.CROUCHING);p.setYRot(0);p.setXRot(20);p.getInventory().setItem(0,new ItemStack(Items.DIAMOND_PICKAXE));
var at=p.blockPosition().offset(0,0,3);h.getLevel().setBlockAndUpdate(at,Blocks.STONE.defaultBlockState());
var action=CompanionActions.prepare(p,CompanionType.VEX,-1);h.assertTrue(action.success(),"Mining-only progression unlocks spectral mining");action.commit().run();h.runAfterDelay(100,()->{h.assertTrue(p.level().getBlockState(at).isAir(),"Spectral mining actually finishes without Building");h.succeed();});
}
@GameTest public void fallbackOfferingWithoutTargetDoesNotDemandGold(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.ZOMBIFIED_PIGLIN);var action=CompanionActions.prepare(p,CompanionType.ZOMBIFIED_PIGLIN,-1);
h.assertTrue(action.success(),"Documented no-target fallback needs no gold");action.commit().run();
h.assertTrue(CompanionEffects.value(p,CompanionEffects.Kind.KNOCKBACK)==.25F,"Fallback actually grants the described resistance");h.succeed();
}
@GameTest(maxTicks=200) public void everyEggCommitsWithActualRequiredContext(GameTestHelper h){
var errors=new ArrayList<String>();int passed=0;
for(var type:CompanionType.values()){
var p=Companion019GameTests.player(h,type);var spawned=new ArrayList<Entity>();int selection=-1;
try{
progress(p,ProgressRecord.INITIAL.buy("inventory",1).buy("hunger",1).buy("swimming",1).buy("building",1).buy("mining",1));
p.getFoodData().setFoodLevel(8);p.setHealth(3);p.setYRot(0);p.setXRot(0);var at=p.blockPosition();
switch(type){
case CHICKEN,PHANTOM->{p.setPos(p.position().add(0,2,0));p.setOnGround(false);p.fallDistance=25;}
case COD,SALMON,DOLPHIN,NAUTILUS,ZOMBIE_NAUTILUS->{
for(int x=-1;x<=1;x++)for(int y=0;y<5;y++)for(int z=-1;z<=5;z++)h.getLevel().setBlockAndUpdate(at.offset(x,y,z),Blocks.WATER.defaultBlockState());
p.baseTick();p.setAirSupply(20);
}
case COW->{p.addEffect(new MobEffectInstance(MobEffects.POISON,100));p.getInventory().setItem(0,new ItemStack(Items.MILK_BUCKET));}
case DONKEY,COPPER_GOLEM->{
h.getLevel().setBlockAndUpdate(at.offset(0,1,2),Blocks.BARREL.defaultBlockState());var c=(Container)h.getLevel().getBlockEntity(at.offset(0,1,2));c.setItem(0,new ItemStack(Items.STONE));
p.getInventory().setItem(9,new ItemStack(Items.STONE,12));p.setItemInHand(InteractionHand.OFF_HAND,new ItemStack(Items.STONE));
}
case MULE->{
var ally=Companion019GameTests.player(h,CompanionType.CAT);progress(ally,ProgressRecord.INITIAL.buy("inventory",1));ally.setPos(p.position().add(0,0,2));h.getLevel().addNewPlayer(ally);spawned.add(ally);
p.getInventory().setItem(9,new ItemStack(Items.STONE,12));p.setItemInHand(InteractionHand.OFF_HAND,new ItemStack(Items.STONE));
}
case PIG,SNIFFER->{h.getLevel().setBlockAndUpdate(at.offset(2,0,0),Blocks.OAK_LOG.defaultBlockState());fr.koka.sanctuary.knowledge.BlockKnowledgeService.recordSeen(p,Blocks.OAK_LOG);}
case SHEEP->{p.setXRot(85);p.getInventory().setItem(0,new ItemStack(Items.WOOL.white()));}
case SKELETON,BOGGED,PARCHED,STRAY->{p.getInventory().setItem(0,new ItemStack(Items.BOW));p.getInventory().setItem(1,new ItemStack(Items.ARROW,8));}
case PILLAGER->p.getInventory().setItem(0,new ItemStack(Items.CROSSBOW));
case CAVE_SPIDER,PIGLIN_BRUTE,WITHER_SKELETON->p.getInventory().setItem(0,new ItemStack(Items.DIAMOND_SWORD));
case VINDICATOR->p.getInventory().setItem(0,new ItemStack(Items.DIAMOND_AXE));
case SPIDER,SILVERFISH,TROPICAL_FISH,GLOW_SQUID,PARROT->{h.getLevel().setBlockAndUpdate(at.offset(0,1,1),Blocks.STONE.defaultBlockState());}
case TADPOLE->{h.getLevel().setBlockAndUpdate(at.offset(1,0,0),Blocks.WATER.defaultBlockState());}
case BEE->{h.getLevel().setBlockAndUpdate(at.offset(1,-1,1),Blocks.FARMLAND.defaultBlockState());h.getLevel().setBlockAndUpdate(at.offset(1,0,1),Blocks.WHEAT.defaultBlockState());p.getInventory().setItem(0,new ItemStack(Items.BONE_MEAL,4));}
case SNOW_GOLEM->p.getInventory().setItem(0,new ItemStack(Items.SNOWBALL,3));
case STRIDER->h.getLevel().setBlockAndUpdate(at.offset(2,-1,0),Blocks.LAVA.defaultBlockState());
case SKELETON_HORSE->h.getLevel().setBlockAndUpdate(at.offset(2,-1,0),Blocks.WATER.defaultBlockState());
case MOOSHROOM->{p.getInventory().setItem(0,new ItemStack(Items.MUSHROOM_STEW));p.getFoodData().setFoodLevel(2);}
case FROG,ALLAY->{var item=new ItemEntity(h.getLevel(),p.getX(),p.getEyeY(),p.getZ()+3,new ItemStack(Items.STONE,5));h.getLevel().addFreshEntity(item);spawned.add(item);}
case VEX->{h.getLevel().setBlockAndUpdate(at.offset(0,1,3),Blocks.STONE.defaultBlockState());p.getInventory().setItem(0,new ItemStack(Items.COBBLESTONE,2));}
case WITCH->{var potion=new ItemStack(Items.POTION);potion.set(DataComponents.POTION_CONTENTS,new net.minecraft.world.item.alchemy.PotionContents(net.minecraft.world.item.alchemy.Potions.SWIFTNESS));p.getInventory().setItem(0,potion);}
case VILLAGER,WANDERING_TRADER->{
var v=EntityTypes.VILLAGER.create(h.getLevel(),EntitySpawnReason.COMMAND);v.setNoAi(true);v.setPos(p.position().add(0,0,2));
var offers=new net.minecraft.world.item.trading.MerchantOffers();offers.add(new net.minecraft.world.item.trading.MerchantOffer(new net.minecraft.world.item.trading.ItemCost(Items.EMERALD,2),new ItemStack(Items.BREAD),4,1,0));v.setOffers(offers);h.getLevel().addFreshEntity(v);spawned.add(v);p.getInventory().setItem(0,new ItemStack(Items.EMERALD,10));
if(type==CompanionType.VILLAGER){CompanionActions.prepare(p,type,-1);selection=0;}
else{v.setTradingPlayer(p);p.tickCount=100;CompanionService.runtime(p).equippedAt=-100;CompanionTrades.observe(p,type);v.setTradingPlayer(null);}
}
case PIGLIN->{var v=EntityTypes.PIGLIN.create(h.getLevel(),EntitySpawnReason.COMMAND);v.setNoAi(true);v.setPos(p.position().add(0,0,2));h.getLevel().addFreshEntity(v);spawned.add(v);p.getInventory().setItem(0,new ItemStack(Items.GOLD_INGOT,2));}
case ZOMBIE_HORSE,TRADER_LLAMA->{
var horse=EntityTypes.DONKEY.create(h.getLevel(),EntitySpawnReason.COMMAND);horse.setTamed(true);horse.setOwner(p);horse.setPos(p.position().add(0,0,2));h.getLevel().addFreshEntity(horse);spawned.add(horse);
if(type==CompanionType.ZOMBIE_HORSE)p.startRiding(horse,true,true);else p.setXRot(45);
}
case BAT,CAT,GUARDIAN,WOLF,SHULKER,WARDEN->{var v=EntityTypes.ZOMBIE.create(h.getLevel(),EntitySpawnReason.COMMAND);v.setNoAi(true);v.setPos(p.position().add(0,0,2));h.getLevel().addFreshEntity(v);spawned.add(v);}
default->{}
}
var action=CompanionActions.prepare(p,type,selection);h.assertTrue(action.success(),type+": valid fixture refused: "+action.reason());action.commit().run();
switch(type){
case COW->h.assertTrue(p.getMainHandItem().is(Items.BUCKET)&&!p.hasEffect(MobEffects.POISON),"Milk consumed and poison removed");
case SLIME->h.assertTrue(h.getLevel().getBlockState(at).is(CompanionSlimeBlock.BLOCK),"Real slime block under player");
case DONKEY,COPPER_GOLEM,MULE->h.assertTrue(p.getInventory().getItem(9).isEmpty(),"Real stock leaves source storage");
case SHEEP->h.assertTrue(p.getMainHandItem().isEmpty(),"One wool paid");
case SNOW_GOLEM->h.assertTrue(p.getMainHandItem().isEmpty(),"Three snowballs paid");
case COD->h.assertTrue(p.getAirSupply()==50,"Thirty air supplied");
case VILLAGER->h.assertTrue(p.getInventory().countItem(Items.BREAD)==3,"Three real trades completed");
case PIGLIN->h.assertTrue(p.getMainHandItem().isEmpty()&&!p.level().getEntitiesOfClass(ItemEntity.class,p.getBoundingBox().inflate(3)).isEmpty(),"Two gold buy real barter drops");
case VEX->h.assertTrue(p.getMainHandItem().getCount()==1,"Spectral placement consumes a real block");
case CHICKEN,PHANTOM->h.assertTrue(p.fallDistance==0&&CompanionEffects.has(p,CompanionEffects.Kind.GLIDE),"Fall accumulator reset and glide active");
case WITCH->h.assertTrue(p.getMainHandItem().is(Items.GLASS_BOTTLE),"Potion dose converted into one mist");
default->{}
}
passed++;
}catch(Throwable e){errors.add(type+": "+e.getMessage());}
finally{p.stopRiding();CompanionService.clear(p);var pet=FamiliarService.active(p);if(pet!=null)pet.discard();spawned.forEach(Entity::discard);p.discard();}
}
fr.koka.sanctuary.SanctuaryMod.LOGGER.info("EGG044_POSITIVE_CONTEXT: {}/88, failures={}",passed,errors);
h.assertTrue(errors.isEmpty(),String.join(" | ",errors));h.succeed();
}
@GameTest(maxTicks=80) public void creeperDestroysTerrainAfterFuse(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.CREEPER);var at=p.blockPosition().below();
for(int x=-3;x<=3;x++)for(int z=-3;z<=3;z++)h.getLevel().setBlockAndUpdate(at.offset(x,0,z),Blocks.DIRT.defaultBlockState());
CompanionActions.prepare(p,CompanionType.CREEPER,-1).commit().run();
h.assertTrue(h.getLevel().getBlockState(at).is(Blocks.DIRT),"Fuse does not explode immediately");
h.runAfterDelay(35,()->{h.assertTrue(h.getLevel().getBlockState(at).isAir(),"Real creeper explosion excavates the floor");h.succeed();});
}
@GameTest public void nativeFireballsHaveRealWorldImpacts(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.BLAZE);var level=p.level();var wall=p.blockPosition().offset(0,1,4);
level.setBlockAndUpdate(wall,Blocks.STONE.defaultBlockState());level.setBlockAndUpdate(wall.north().below(),Blocks.STONE.defaultBlockState());
var blaze=CompanionNativePowers.launch(p,CompanionType.BLAZE,new Vec3(0,0,1));
impact(blaze,new BlockHitResult(Vec3.atCenterOf(wall).add(0,0,-.5),Direction.NORTH,wall,false));
h.assertTrue(level.getBlockState(wall.north()).is(Blocks.FIRE),"Blaze ignites an actual fire block");
var ghast=CompanionNativePowers.launch(p,CompanionType.GHAST,new Vec3(0,0,1));var target=wall.offset(3,-1,0);
level.setBlockAndUpdate(target,Blocks.DIRT.defaultBlockState());ghast.setPos(Vec3.atCenterOf(target));
impact(ghast,new BlockHitResult(ghast.position(),Direction.UP,target,false));
h.assertTrue(level.getBlockState(target).isAir()||level.getBlockState(target).is(Blocks.FIRE),"Ghast explosion destroys actual terrain");
var dragon=CompanionNativePowers.launch(p,CompanionType.ENDER_DRAGON,new Vec3(0,0,1));dragon.setPos(Vec3.atCenterOf(wall));
impact(dragon,new BlockHitResult(dragon.position(),Direction.NORTH,wall,false));
var clouds=level.getEntitiesOfClass(AreaEffectCloud.class,new AABB(wall).inflate(8));
h.assertTrue(clouds.stream().anyMatch(c->c.getDuration()==600&&c.getRadius()>=3),"Dragon creates a real lingering breath cloud");
clouds.forEach(Entity::discard);h.succeed();
}
@GameTest public void nativeProjectilesInflictStatusEffects(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.WITHER);var level=p.level();var target=EntityTypes.PIG.create(level,EntitySpawnReason.COMMAND);
target.setNoAi(true);target.getAttribute(net.minecraft.world.entity.ai.attributes.Attributes.MAX_HEALTH).setBaseValue(100);target.setHealth(100);
target.setPos(p.position().add(0,0,4));level.addFreshEntity(target);
var skull=CompanionNativePowers.launch(p,CompanionType.WITHER,new Vec3(0,0,1));skull.setPos(target.getEyePosition());
impact(skull,new EntityHitResult(target));
h.assertTrue(target.getHealth()<100&&target.hasEffect(MobEffects.WITHER),"Wither skull causes native damage and wither");
target.discard();var floating=EntityTypes.PIG.create(level,EntitySpawnReason.COMMAND);floating.setNoAi(true);floating.setPos(p.position().add(0,0,4));level.addFreshEntity(floating);
Companion019GameTests.equip(p,CompanionType.SHULKER);
var bullet=new net.minecraft.world.entity.projectile.ShulkerBullet(level,p,floating,Direction.Axis.Y);level.addFreshEntity(bullet);
impact(bullet,new EntityHitResult(floating));
h.assertTrue(floating.hasEffect(MobEffects.LEVITATION),"Real shulker bullet levitates a fresh victim");
floating.discard();
target.discard();h.succeed();
}
@GameTest public void nativeArrowAndMeleeEffectsSurviveRealHits(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.BOGGED);
for(var type:List.of(CompanionType.BOGGED,CompanionType.PARCHED,CompanionType.STRAY,CompanionType.CAVE_SPIDER,CompanionType.WITHER_SKELETON)){
Companion019GameTests.equip(p,type);var target=EntityTypes.PIG.create(p.level(),EntitySpawnReason.COMMAND);target.setNoAi(true);target.setPos(p.position().add(0,0,3));p.level().addFreshEntity(target);
var effect=switch(type){case BOGGED,CAVE_SPIDER->MobEffects.POISON;case PARCHED->MobEffects.WEAKNESS;case STRAY->MobEffects.SLOWNESS;default->MobEffects.WITHER;};
boolean bow=type==CompanionType.BOGGED||type==CompanionType.PARCHED||type==CompanionType.STRAY;
p.getInventory().setItem(0,new ItemStack(bow?Items.BOW:Items.DIAMOND_SWORD));p.getInventory().setItem(1,new ItemStack(Items.ARROW,5));
var a=CompanionActions.prepare(p,type,-1);h.assertTrue(a.success(),"Attack can be armed: "+type);a.commit().run();
if(bow){
var arrow=new net.minecraft.world.entity.projectile.arrow.Arrow(p.level(),p,new ItemStack(Items.ARROW),p.getMainHandItem());arrow.setDeltaMovement(0,0,1);p.level().addFreshEntity(arrow);CompanionArrows.fired(arrow);
impact(arrow,new EntityHitResult(target));arrow.discard();
}else target.hurtServer(p.level(),p.damageSources().playerAttack(p),1);
h.assertTrue(target.hasEffect(effect),"Native hit applies "+effect+" for "+type);target.discard();
}h.succeed();
}
@GameTest(maxTicks=80) public void sonicBoomCrossesWall(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.WARDEN);p.setYRot(0);p.setXRot(0);
var target=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND);target.setNoAi(true);target.setPos(p.position().add(0,0,4));p.level().addFreshEntity(target);
var wall=p.blockPosition().offset(0,0,2);for(int y=0;y<3;y++)p.level().setBlockAndUpdate(wall.above(y),Blocks.STONE.defaultBlockState());
var a=CompanionActions.prepare(p,CompanionType.WARDEN,-1);h.assertTrue(a.success(),"Sonic boom can target through solid wall");a.commit().run();
h.runAfterDelay(38,()->{h.assertTrue(target.getHealth()<=10,"Sonic damage crosses wall and bypasses zombie armour");target.discard();h.succeed();});
}
@GameTest(maxTicks=80) public void evokerFangsActuallyBite(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.EVOKER);p.setYRot(0);p.setXRot(0);
var target=EntityTypes.PIG.create(p.level(),EntitySpawnReason.COMMAND);target.setNoAi(true);target.setPos(p.position().add(0,0,3));p.level().addFreshEntity(target);
CompanionActions.prepare(p,CompanionType.EVOKER,-1).commit().run();h.assertTrue(target.getHealth()==10,"Fangs wait for their native bite");
h.runAfterDelay(40,()->{h.assertTrue(target.getHealth()<=4,"Native fangs deal six actual magic damage");target.discard();h.succeed();});
}
@GameTest public void copperCannotDistributeThroughAnInterveningWall(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.COPPER_GOLEM);progress(p,ProgressRecord.INITIAL.buy("inventory",1));
var at=p.blockPosition().offset(0,1,3);p.level().setBlockAndUpdate(at,Blocks.BARREL.defaultBlockState());((Container)p.level().getBlockEntity(at)).setItem(0,new ItemStack(Items.STONE));
p.getInventory().setItem(9,new ItemStack(Items.STONE,12));p.setItemInHand(InteractionHand.OFF_HAND,new ItemStack(Items.STONE));
p.level().setBlockAndUpdate(at.north(),Blocks.STONE.defaultBlockState());
h.assertTrue(!CompanionActions.prepare(p,CompanionType.COPPER_GOLEM,-1).success()&&p.getInventory().getItem(9).getCount()==12,"Intervening wall still blocks stock transfer");h.succeed();
}
@GameTest public void windSpitAndSnowUseNativeImpacts(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.LLAMA);p.setYRot(0);p.setXRot(0);
var pig=EntityTypes.PIG.create(p.level(),EntitySpawnReason.COMMAND);pig.setNoAi(true);pig.setPos(p.position().add(0,0,3));p.level().addFreshEntity(pig);
CompanionActions.prepare(p,CompanionType.LLAMA,-1).commit().run();
var spit=p.level().getEntitiesOfClass(net.minecraft.world.entity.projectile.LlamaSpit.class,p.getBoundingBox().inflate(4)).getFirst();impact(spit,new EntityHitResult(pig));
h.assertTrue(pig.getHealth()==9,"Real spit causes one native damage");pig.discard();spit.discard();
Companion019GameTests.equip(p,CompanionType.SNOW_GOLEM);p.getInventory().setItem(0,new ItemStack(Items.SNOWBALL,3));
var blaze=EntityTypes.BLAZE.create(p.level(),EntitySpawnReason.COMMAND);blaze.setNoAi(true);blaze.setPos(p.position().add(0,0,3));p.level().addFreshEntity(blaze);
CompanionActions.prepare(p,CompanionType.SNOW_GOLEM,-1).commit().run();
var balls=p.level().getEntitiesOfClass(net.minecraft.world.entity.projectile.throwableitemprojectile.Snowball.class,p.getBoundingBox().inflate(4));
h.assertTrue(balls.size()==3&&p.getMainHandItem().isEmpty(),"Three real snowballs consume three items");impact(balls.getFirst(),new EntityHitResult(blaze));
h.assertTrue(blaze.getHealth()==17,"Native snowball deals three damage to Blaze");balls.forEach(Entity::discard);blaze.discard();
Companion019GameTests.equip(p,CompanionType.BREEZE);var target=EntityTypes.PIG.create(p.level(),EntitySpawnReason.COMMAND);target.setNoAi(true);target.setPos(p.position().add(0,0,3));p.level().addFreshEntity(target);
CompanionActions.prepare(p,CompanionType.BREEZE,-1).commit().run();
var charge=p.level().getEntitiesOfClass(net.minecraft.world.entity.projectile.hurtingprojectile.windcharge.BreezeWindCharge.class,p.getBoundingBox().inflate(4)).getFirst();
charge.setPos(target.position().add(0,.5,-1));impact(charge,new EntityHitResult(target));
h.assertTrue(target.getDeltaMovement().lengthSqr()>0,"Native wind explosion pushes a real entity");target.discard();h.succeed();
}
@GameTest public void mobGriefingStillControlsNativeExplosionTerrain(GameTestHelper h){
var p=Companion019GameTests.player(h,CompanionType.GHAST);var rule=net.minecraft.world.level.gamerules.GameRules.MOB_GRIEFING;
var rules=p.level().getGameRules();boolean before=rules.get(rule);var at=p.blockPosition().offset(0,0,4);
try{
rules.set(rule,false,p.level().getServer());p.level().setBlockAndUpdate(at,Blocks.DIRT.defaultBlockState());
var fireball=CompanionNativePowers.launch(p,CompanionType.GHAST,new Vec3(0,0,1));fireball.setPos(Vec3.atCenterOf(at));
impact(fireball,new BlockHitResult(fireball.position(),Direction.UP,at,false));
h.assertTrue(p.level().getBlockState(at).is(Blocks.DIRT),"Native server rule can disable terrain destruction");
}finally{rules.set(rule,before,p.level().getServer());}
h.succeed();
}
private static void impact(net.minecraft.world.entity.projectile.Projectile projectile,HitResult hit){
try{
var method=net.minecraft.world.entity.projectile.Projectile.class.getDeclaredMethod("hitTargetOrDeflectSelf",HitResult.class);
method.setAccessible(true);method.invoke(projectile,hit);
}catch(ReflectiveOperationException failure){throw new AssertionError("Native projectile impact failed",failure);}
}
private static void progress(ServerPlayer p,ProgressRecord r){p.setAttached(ProgressionService.RECORD,r.json());ProgressEffects.apply(p,r);}
}
@@ -0,0 +1,54 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.progression.*;
import java.util.UUID;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.targeting.TargetingConditions;
import net.minecraft.world.level.block.Blocks;
public final class Corrections036GameTests {
@GameTest public void carriedBirdGlidesWithoutStoringPastFallDamage(GameTestHelper h){
var p=player(h);var bird=EntityTypes.PARROT.create(h.getLevel(),EntitySpawnReason.COMMAND);
try{
bird.setPos(p.position().add(0,0,1));h.getLevel().addFreshEntity(bird);
h.assertTrue(CarryService.interact(p,bird),"Pick up a real parrot");p.setOnGround(false);p.setDeltaMovement(.2,-2,.1);p.fallDistance=32;
CarryGlide.tick(p);h.assertTrue(CarryGlide.active(p)&&p.getDeltaMovement().y==-.12&&p.fallDistance==0,"Descent capped and accumulated fall cleared");
h.assertTrue(p.getDeltaMovement().x==.2&&p.getDeltaMovement().z==.1,"Horizontal movement preserved");
p.setDeltaMovement(0,.3,0);CarryGlide.tick(p);h.assertTrue(p.getDeltaMovement().y==.3,"Jump has no upward boost");
p.getAbilities().flying=true;p.setDeltaMovement(0,-2,0);CarryGlide.tick(p);h.assertTrue(p.getDeltaMovement().y==-2,"Creative flight untouched");p.getAbilities().flying=false;
CarryService.release(bird,true);p.setOnGround(false);p.setDeltaMovement(0,-2,0);p.fallDistance=8;CarryGlide.tick(p);
h.assertTrue(!CarryGlide.supported(p)&&p.getDeltaMovement().y==-2&&p.fallDistance==8,"Release immediately restores ordinary fall without lingering potion");
}finally{CarryService.detach(p);bird.discard();}h.succeed();
}
@GameTest public void flyingSpeciesAndNonFlyersUseNativeClassification(GameTestHelper h){
var p=player(h);
for(var type:new EntityType<?>[]{EntityTypes.BAT,EntityTypes.BEE,EntityTypes.CHICKEN,EntityTypes.GHAST,EntityTypes.WITHER,EntityTypes.ENDER_DRAGON}){
var e=type.create(h.getLevel(),EntitySpawnReason.COMMAND);h.assertTrue(CarryGlide.flyer(e),"Flying carrier species "+type);e.discard();
}
var cow=EntityTypes.COW.create(h.getLevel(),EntitySpawnReason.COMMAND);
try{cow.setPos(p.position().add(0,0,1));h.getLevel().addFreshEntity(cow);h.assertTrue(CarryService.interact(p,cow),"Cow picked up normally");p.setOnGround(false);p.setDeltaMovement(0,-2,0);CarryGlide.tick(p);h.assertTrue(!CarryGlide.supported(p)&&p.getDeltaMovement().y==-2,"A ground animal gives no gliding");}
finally{CarryService.detach(p);cow.discard();}h.succeed();
}
@GameTest public void proneReducesNativeVisualDetectionWithoutInvulnerability(GameTestHelper h){
var p=player(h);var skeleton=EntityTypes.SKELETON.create(h.getLevel(),EntitySpawnReason.COMMAND);
var nativeSight=TargetingConditions.forNonCombat().range(16).ignoreLineOfSight();skeleton.setPos(p.position().add(12,0,0));p.setOnGround(true);
h.assertTrue(nativeSight.test(h.getLevel(),skeleton,p),"Standing is visible at 12 blocks");
h.assertTrue(LyingService.request(p,new LyingService.Request(1,ProgressEffects.get(p).revision())),"Free lying request");
h.assertTrue(p.getVisibilityPercent(h.getLevel(),skeleton)==.5&&!nativeSight.test(h.getLevel(),skeleton,p),"Prone halves native visual acquisition range");
skeleton.setPos(p.position().add(3,0,0));h.assertTrue(nativeSight.test(h.getLevel(),skeleton,p),"Nearby mobs still see you");
float health=p.getHealth();h.assertTrue(p.hurtServer(h.getLevel(),p.damageSources().mobAttack(skeleton),1)&&p.getHealth()<health,"Lying is not damage immunity");
p.setAttached(LyingService.STATE,3);h.assertTrue(p.getVisibilityPercent(h.getLevel(),skeleton)==1,"Diving is not ground stealth");
p.setAttached(LyingService.STATE,2);h.assertTrue(p.getVisibilityPercent(h.getLevel(),skeleton)==.5,"Ground rest shares low visual profile");
p.setOnGround(false);h.assertTrue(p.getVisibilityPercent(h.getLevel(),skeleton)==1,"No stealth while airborne");
p.setOnGround(true);LyingService.request(p,new LyingService.Request(0,ProgressEffects.get(p).revision()));h.assertTrue(p.getVisibilityPercent(h.getLevel(),skeleton)==1,"Standing restores visibility");skeleton.discard();h.succeed();
}
private static ServerPlayer player(GameTestHelper h){
var p=Progression003GameTests.player(h);p.setUUID(UUID.randomUUID());p.connection.handleAcceptPlayerLoad(new net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket());
p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(p,ProgressRecord.INITIAL);p.setHealth(6);
var at=p.blockPosition();for(int x=-3;x<=3;x++)for(int z=-3;z<=3;z++)for(int y=-1;y<10;y++)h.getLevel().setBlockAndUpdate(at.offset(x,y,z),(y==-1?Blocks.STONE:Blocks.AIR).defaultBlockState());return p;
}
}
@@ -0,0 +1,207 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.cycle.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.atlas.*;
import fr.koka.demeure.DemeureService;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.GameType;
import net.minecraft.world.item.*;
import net.minecraft.world.inventory.*;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.level.storage.*;
import net.minecraft.util.ProblemReporter;
import net.minecraft.stats.Stats;
public final class Cycle011GameTests {
@GameTest public void sixSkillsRequiredButAptitudesOptional(GameTestHelper h) throws Exception {
ProgressRecord five=ProgressRecord.INITIAL.buy("names",4).buy("atlas",4);
for (String skill:ProgressRecord.SKILLS) if (!skill.equals("inventory"))
while(five.rank(skill)<ProgressRecord.cap(skill)) five=five.buy(skill,1);
var p=player(h,five,GameType.SURVIVAL);var session=session(p);
h.assertTrue(action(p,"ngp","","").equals("incomplete") && session.cycles.account(p.getUUID()).prestige()==0,"Five completed skills cannot trigger NGP even with every aptitude");
var ready=full(ProgressRecord.INITIAL);bind(p,ready);
h.assertTrue(action(p,"ngp","","").equals("done") && session.cycles.account(p.getUUID()).prestige()==1,"All six skills suffice without either aptitude");
h.assertTrue(!ProgressEffects.get(p).names() && !ProgressEffects.get(p).atlas(),"NGP does not invent aptitudes");
h.succeed();
}
@GameTest public void resetPreservesFullInventoryAndCommunity(GameTestHelper h) throws Exception {
var before=full(ProgressRecord.INITIAL.buy("names",4).buy("atlas",4));
var p=player(h,before,GameType.SURVIVAL);var identity=session(p).inhabitants.get(p.getUUID());
for(int slot=0;slot<36;slot++)p.getInventory().setItem(slot,new ItemStack(Items.DIAMOND,64));
for(int slot=0;slot<18;slot++)SanctuaryOverflowAccess.of(p).setItem(slot,new ItemStack(Items.DIAMOND,64));
HotbarRows.select(p,5);
p.getInventory().setItem(36,new ItemStack(Items.DIAMOND_BOOTS));
p.getEnderChestInventory().setItem(0,new ItemStack(Items.EMERALD,19));
p.awardStat(Stats.JUMP,3);int jumps=p.getStats().getValue(Stats.CUSTOM.get(Stats.JUMP));
byte[] colors=new byte[256];Arrays.fill(colors,(byte)42);
var atlas=AtlasService.observations(p);atlas.observe(new AtlasStore.Tile(-220,300,colors));atlas.save();int tiles=atlas.size();
var pos=p.blockPosition();DemeureService.record(p,pos,2);var footprint=DemeureService.view(p.level().getServer(),DemeureService.key(p,pos));
p.giveExperienceLevels(60);p.setHealth(5);p.getFoodData().setFoodLevel(18);p.setAirSupply(300);
int oldRevision=before.revision();int journalRevision=session(p).cycles.revision();
h.assertTrue(action(p,"ngp","","").equals("done"),"Full inventory does not prevent NGP");
var after=ProgressEffects.get(p);
h.assertTrue(after.complete()==false && after.cycle()==1 && after.names() && after.atlas() && after.inventoryRows()==1,"Six skills reset and aptitudes remain");
h.assertTrue(p.getMaxHealth()==6 && p.getHealth()==5 && p.getFoodData().getFoodLevel()==6 && p.getAirSupply()==90
&& p.experienceLevel==0 && p.totalExperience==0 && p.experienceProgress==0,"3/3/3 capacities, zero XP and no free healing");
h.assertTrue(count(p)==54*64 && SanctuaryInventoryCapacity.visibleRows(p)==6,"All 54 full rows of items remain visible for recovery");
h.assertTrue(HotbarRows.selected(p)==0,"New Game+ returns a formerly extended hotbar to the unlocked base");
var last=p.inventoryMenu.getSlot(63);
h.assertTrue(last.mayPickup(p) && !last.mayPlace(new ItemStack(Items.DIAMOND)),"Last locked row is withdrawal-only after NGP");
p.inventoryMenu.clicked(63,0,ContainerInput.PICKUP,p);
h.assertTrue(p.inventoryMenu.getCarried().getCount()==64,"Real menu retrieves the last locked stack");
p.inventoryMenu.clicked(63,0,ContainerInput.PICKUP,p);
h.assertTrue(p.inventoryMenu.getCarried().getCount()==64 && last.getItem().isEmpty(),"Forged insertion cannot reuse a locked row");
p.inventoryMenu.setCarried(ItemStack.EMPTY);last.set(new ItemStack(Items.DIAMOND,64));
h.assertTrue(p.getInventory().getItem(36).is(Items.DIAMOND_BOOTS) && p.getEnderChestInventory().getItem(0).getCount()==19,"Equipment and Ender chest preserved");
h.assertTrue(session(p).inhabitants.get(p.getUUID()).equals(identity) && atlas.size()==tiles && AtlasService.canRead(p,false)
&& Objects.equals(footprint,DemeureService.view(p.level().getServer(),DemeureService.key(p,pos)))
&& p.getStats().getValue(Stats.CUSTOM.get(Stats.JUMP))==jumps,"Identity, Atlas, Demeure and native stats stay attached to the inhabitant");
p.giveExperienceLevels(10);
h.assertTrue(!ProgressionService.purchase(p,"vitality",oldRevision) && p.experienceLevel==10,"Old-cycle purchase packet is rejected");
h.assertTrue(ProgressionService.purchase(p,"vitality",after.revision()) && p.experienceLevel==9,"New-cycle purchase remains playable");
h.assertTrue(CycleService.perform(p,new CyclePayloads.Action("ngp","","",journalRevision,oldRevision)).equals("stale")
&& session(p).cycles.account(p.getUUID()).charges()==1,"Replayed NGP grants nothing");
h.succeed();
}
@GameTest public void durableDecisionRecoversBeforeAndAfterPlayerSave(GameTestHelper h) throws Exception {
var full=full(ProgressRecord.INITIAL.buy("atlas",4));var p=player(h,full,GameType.SURVIVAL);p.giveExperienceLevels(40);
SanctuaryOverflowAccess.of(p).setItem(17,new ItemStack(Items.DIAMOND,17));
var old=save(p,h);
var session=session(p);session.cycles.complete(p.getUUID(),full,session.cycles.revision(),session.cycleConfig);
var disk=new CycleStore(h.getLevel().getServer().getWorldPath(LevelResource.ROOT).resolve("data/sanctuary-cycle-factions.json"),h.getLevel().getSeed());
h.assertTrue(disk.account(p.getUUID()).prestige()==1,"Completion is on disk before player mutation");
var loaded=Progression003GameTests.player(h);loaded.load(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),old.buildResult()));
// Native Player.load restores the authenticated profile UUID, not the NBT UUID.
// Each helper mock has a fresh profile; represent the same authenticated inhabitant.
loaded.setUUID(p.getUUID());
var recovered=CycleService.reconcile(loaded,ProgressRecord.parse(loaded.getAttachedOrThrow(ProgressionService.RECORD)));
h.assertTrue(recovered.cycle()==1 && recovered.atlas() && loaded.experienceLevel==0 && SanctuaryOverflowAccess.of(loaded).getItem(17).getCount()==17,"Crash before reset recovers once without touching items");
loaded.giveExperienceLevels(12);h.assertTrue(ProgressionService.purchase(loaded,"hunger",recovered.revision()),"Purchases work after crash recovery");
var saved=save(loaded,h);var again=Progression003GameTests.player(h);
again.load(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),saved.buildResult()));
again.setUUID(p.getUUID());
var kept=CycleService.reconcile(again,ProgressRecord.parse(again.getAttachedOrThrow(ProgressionService.RECORD)));
h.assertTrue(kept.hunger()==1 && again.experienceLevel==11 && session.cycles.account(p.getUUID()).charges()==1,"Reload after reset keeps newly earned XP, purchases and single reward");
h.succeed();
}
@GameTest public void ordinaryDeathKeepsNewCycleAndFaction(GameTestHelper h) throws Exception {
var p=player(h,full(ProgressRecord.INITIAL.buy("names",4)),GameType.SURVIVAL);
h.assertTrue(action(p,"ngp","","").equals("done"),"NGP accepted");
p.giveExperienceLevels(10);h.assertTrue(action(p,"create","","F"+p.getUUID().toString().substring(0,12)).equals("done"),"Faction founded");
p.giveExperienceLevels(10);h.assertTrue(ProgressionService.purchase(p,"building",ProgressEffects.get(p).revision()),"A new-cycle skill is purchased");
var before=ProgressEffects.get(p);var account=session(p).cycles.account(p.getUUID());var faction=session(p).cycles.membership(p.getUUID());
var respawn=Progression003GameTests.player(h);respawn.setUUID(p.getUUID());respawn.restoreFrom(p,false);
ServerPlayerEvents.AFTER_RESPAWN.invoker().afterRespawn(p,respawn,false);
h.assertTrue(before.equals(ProgressEffects.get(respawn)) && session(p).cycles.account(p.getUUID()).equals(account)
&& session(p).cycles.membership(p.getUUID()).equals(faction),"Ordinary death keeps cycle, purchased skills, aptitude, prestige and faction");
h.succeed();
}
@GameTest public void serverPermissionsInvitationsAndContributions(GameTestHelper h) throws Exception {
var a=player(h,full(ProgressRecord.INITIAL),GameType.SURVIVAL);var b=player(h,full(ProgressRecord.INITIAL),GameType.SURVIVAL);
var c=player(h,ProgressRecord.INITIAL,GameType.SURVIVAL);
h.assertTrue(action(a,"ngp","","").equals("done"),"Founder earns charge");
a.giveExperienceLevels(10);h.assertTrue(action(a,"create","","F"+a.getUUID().toString().substring(0,12)).equals("done"),"Founder pays levels for two places");
var faction=session(a).cycles.membership(a.getUUID());
h.assertTrue(action(a,"invite",session(a).inhabitants.get(b.getUUID()).name(),"").equals("done"),"Registered offline-capable name resolves to stable UUID");
h.assertTrue(action(b,"accept",faction.id().toString(),"").equals("done"),"Invited member joins free");
h.assertTrue(action(b,"invite",c.getUUID().toString(),"").equals("owner_only"),"Ordinary member cannot invite");
h.assertTrue(action(b,"ngp","","").equals("done") && action(b,"expand","","").equals("done"),"Member's NGP charge funds the common capacity");
h.assertTrue(session(a).cycles.membership(a.getUUID()).capacity()==3 && session(a).cycles.account(b.getUUID()).charges()==0,"Capacity and spent personal balance agree");
h.assertTrue(action(a,"transfer",b.getUUID().toString(),"").equals("done") && action(a,"dissolve","","").equals("owner_only"),"Transferred authority immediately applies");
h.assertTrue(action(b,"invite",c.getUUID().toString(),"").equals("done") && CycleService.view(c,"").invitations().size()==1,"Target can read its server-issued invitation");
h.succeed();
}
@GameTest public void pendingContainersSpectatorsAndPartialRestoreRefused(GameTestHelper h) throws Exception {
var p=player(h,full(ProgressRecord.INITIAL),GameType.SURVIVAL);
p.inventoryMenu.setCarried(new ItemStack(Items.DIAMOND));
h.assertTrue(action(p,"ngp","","").equals("close_container"),"Cursor items require closing inventory first");p.inventoryMenu.setCarried(ItemStack.EMPTY);
p.containerMenu=ChestMenu.threeRows(9,p.getInventory(),new SimpleContainer(27));
h.assertTrue(action(p,"ngp","","").equals("close_container"),"Open container blocks reset");p.containerMenu=p.inventoryMenu;
var spectator=player(h,full(ProgressRecord.INITIAL),GameType.SPECTATOR);
h.assertTrue(action(spectator,"ngp","","").equals("unavailable") && session(p).cycles.account(spectator.getUUID()).prestige()==0,"Spectators cannot trigger NGP");
boolean refused=false;try {CycleService.reconcile(p,full(ProgressRecord.INITIAL).nextCycle());} catch(java.io.IOException expected) {refused=true;}
h.assertTrue(refused && session(p).cycles.account(p.getUUID()).prestige()==0,"A player snapshot ahead of the journal fails closed");
h.succeed();
}
@GameTest public void configuredXpPreservationSurvivesRecovery(GameTestHelper h) throws Exception {
var p=player(h,full(ProgressRecord.INITIAL),GameType.SURVIVAL);p.giveExperienceLevels(27);
var config=new CycleConfig(3,2,10,2,1,100,true);var session=session(p);
session.cycles.complete(p.getUUID(),ProgressEffects.get(p),session.cycles.revision(),config);
CycleService.reconcile(p,ProgressEffects.get(p));
h.assertTrue(p.experienceLevel==27 && session.cycles.account(p.getUUID()).charges()==2,"Recovery uses the XP/reward policy recorded in the transaction");
h.succeed();
}
@GameTest public void factionPrefixFollowsMembershipAndPrestige(GameTestHelper h) throws Exception {
var owner=player(h,full(ProgressRecord.INITIAL),GameType.SURVIVAL);
String accountName=owner.getName().getString();var identity=session(owner).inhabitants.get(owner.getUUID());
h.assertTrue(action(owner,"ngp","","").equals("done"),"Prestige reached");owner.giveExperienceLevels(10);
h.assertTrue(action(owner,"create","","Les Arpenteurs").equals("done")&&action(owner,"expand","","").equals("done"),"Faction founded and prestige charge buys third place");
String display="Les Arpenteurs · "+accountName+" I";
h.assertTrue(owner.getDisplayName().getString().equals(display) && owner.getTabListDisplayName().getString().equals(display),"Chat/server and Tab use prefix, name and Roman prestige");
var member=player(h,ProgressRecord.INITIAL,GameType.SURVIVAL);
h.assertTrue(action(owner,"invite",member.getUUID().toString(),"").equals("done"),"Member invited");
var faction=session(owner).cycles.membership(owner.getUUID());
h.assertTrue(action(member,"accept",faction.id().toString(),"").equals("done"),"Member joins without prestige");
var shown=member.getDisplayName();
h.assertTrue(shown.getString().equals("Les Arpenteurs · "+member.getName().getString()),"New member gets prefix without a prestige suffix");
h.assertTrue(shown.getSiblings().get(0).getStyle().getColor().getValue()==faction.color()
&& shown.getSiblings().get(2).getStyle().getColor().getValue()==session(member).inhabitants.get(member.getUUID()).color(),"Faction and personal colours stay distinct");
var packet=new net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket(
net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket.Action.UPDATE_DISPLAY_NAME,member);
h.assertTrue(packet.entries().getFirst().displayName().getString().equals(shown.getString()),"Native player-list packet carries the display projection");
h.assertTrue(action(member,"leave","","").equals("done") && member.getDisplayName().getString().equals(member.getName().getString()),"Leaving removes prefix immediately");
h.assertTrue(action(owner,"dissolve","","").equals("done") && owner.getDisplayName().getString().equals(accountName+" I"),"Dissolution removes prefix and retains prestige");
h.assertTrue(owner.getName().getString().equals(accountName) && session(owner).inhabitants.get(owner.getUUID()).equals(identity),"Account and inhabitant identity never change");
h.succeed();
}
@GameTest public void foundingBeforePrestigeCostsTenLevels(GameTestHelper h)throws Exception{
var p=player(h,ProgressRecord.INITIAL,GameType.SURVIVAL);var store=session(p).cycles;int revision=store.revision();
h.assertTrue(action(p,"create","","F"+p.getUUID().toString().substring(0,12)).equals("levels_missing")&&store.revision()==revision,"Zero-level novice cannot found for free");
p.giveExperienceLevels(12);p.experienceProgress=.35f;
h.assertTrue(action(p,"create","","F"+p.getUUID().toString().substring(0,12)).equals("done"),"Novice can found before any prestige");
var faction=store.membership(p.getUUID());
h.assertTrue(p.experienceLevel==2&&p.experienceProgress==.35f&&faction.capacity()==2&&faction.owner().equals(p.getUUID())&&store.account(p.getUUID()).prestige()==0&&store.account(p.getUUID()).charges()==0,"Exactly ten whole levels, two places including the founder, no prestige spent");
p.giveExperienceLevels(10);int duplicateRevision=store.revision();
h.assertTrue(action(p,"create","","Autre faction").equals("already_member")&&p.experienceLevel==12&&store.revision()==duplicateRevision,"Refused duplicate creation costs nothing");
var invited=player(h,ProgressRecord.INITIAL,GameType.SURVIVAL);
h.assertTrue(action(p,"invite",invited.getUUID().toString(),"").equals("done")&&action(invited,"accept",faction.id().toString(),"").equals("done"),"Second player joins immediately without prestige or XP payment");
var third=player(h,ProgressRecord.INITIAL,GameType.SURVIVAL);
h.assertTrue(store.membership(p.getUUID()).members().size()==2&&CycleService.view(p,"").faction().capacity()==2&&invited.experienceLevel==0,"Both occupied places are sent in the client view and joining is free");
h.assertTrue(action(p,"invite",third.getUUID().toString(),"").equals("capacity_full")&&action(p,"expand","","").equals("charges_missing"),"Third player waits for prestige funding");
bind(p,full(ProgressRecord.INITIAL));h.assertTrue(action(p,"ngp","","").equals("done")&&action(p,"expand","","").equals("done"),"First prestige buys the third place");
h.assertTrue(action(p,"invite",third.getUUID().toString(),"").equals("done")&&action(third,"accept",faction.id().toString(),"").equals("done"),"Third player joins after the charge is spent");
h.assertTrue(store.membership(p.getUUID()).capacity()==3&&store.membership(p.getUUID()).members().size()==3&&store.account(p.getUUID()).charges()==0&&p.getAttachedOrElse(CycleService.XP_PAID,0)==10,"NGP retains the founding receipt and exactly one charge buys one place");h.succeed();
}
@GameTest public void foundingPaymentRecoversExactlyOnce(GameTestHelper h)throws Exception{
var p=player(h,ProgressRecord.INITIAL,GameType.SURVIVAL);p.giveExperienceLevels(12);var old=save(p,h);var session=session(p);
session.cycles.act(p.getUUID(),"create","","F"+p.getUUID().toString().substring(0,12),0x445566,session.cycles.revision(),session.cycleConfig);
var loaded=Progression003GameTests.player(h);loaded.load(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),old.buildResult()));loaded.setUUID(p.getUUID());
CycleService.reconcile(loaded,ProgressRecord.INITIAL);h.assertTrue(loaded.experienceLevel==2&&loaded.getAttachedOrElse(CycleService.XP_PAID,0)==10,"Committed faction debits an unsaved pre-purchase player snapshot");
loaded.giveExperienceLevels(5);var saved=save(loaded,h);var again=Progression003GameTests.player(h);again.load(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),saved.buildResult()));again.setUUID(p.getUUID());
CycleService.reconcile(again,ProgressRecord.INITIAL);h.assertTrue(again.experienceLevel==7,"Reloading paid receipt keeps subsequently earned levels");
var respawn=Progression003GameTests.player(h);respawn.setUUID(p.getUUID());respawn.restoreFrom(again,false);ServerPlayerEvents.AFTER_RESPAWN.invoker().afterRespawn(again,respawn,false);
h.assertTrue(respawn.getAttachedOrElse(CycleService.XP_PAID,0)==10,"Death preserves payment receipt");
again.setAttached(CycleService.XP_PAID,20);boolean refused=false;try{CycleService.reconcile(again,ProgressRecord.INITIAL);}catch(java.io.IOException expected){refused=true;}
h.assertTrue(refused&&again.experienceLevel==7,"Partial restoration cannot silently discard an ahead-of-journal receipt");h.succeed();
}
private static ServerPlayer player(GameTestHelper h,ProgressRecord progress,GameType mode) throws Exception {
var p=Progression003GameTests.player(h,mode);p.setUUID(UUID.randomUUID());
var session=session(p);var identities=session.inhabitants;
identities.create(p.getUUID(),"cycle"+p.getUUID().toString().substring(0,8),"Cycle fixture",identities.available().getFirst());
bind(p,progress);return p;
}
private static ProgressionService.Session session(ServerPlayer p) {return ProgressionService.session(p.level().getServer());}
private static void bind(ServerPlayer p,ProgressRecord progress) {p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);}
private static String action(ServerPlayer p,String action,String subject,String value) {
return CycleService.perform(p,new CyclePayloads.Action(action,subject,value,session(p).cycles.revision(),ProgressEffects.get(p).revision()));
}
private static ProgressRecord full(ProgressRecord value) {for(String skill:ProgressRecord.SKILLS)while(value.rank(skill)<ProgressRecord.cap(skill))value=value.buy(skill,1);return value;}
private static int count(ServerPlayer p) {int total=0;for(int i=0;i<36;i++)total+=p.getInventory().getItem(i).getCount();for(int i=0;i<18;i++)total+=SanctuaryOverflowAccess.of(p).getItem(i).getCount();return total;}
private static TagValueOutput save(ServerPlayer p,GameTestHelper h) {var out=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());p.saveWithoutId(out);return out;}
}
@@ -0,0 +1,62 @@
package fr.koka.sanctuary.gametest;
import fr.koka.demeure.*;
import fr.koka.sanctuary.atlas.*;
import fr.koka.sanctuary.progression.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.*;
import net.minecraft.world.item.context.*;
import net.minecraft.world.level.GameType;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.*;
public final class Demeure006GameTests {
@GameTest
public void confirmedGesturesAndPersonalAtlasBoundary(GameTestHelper helper) {
var level=helper.getLevel();var server=level.getServer();
var player=(ServerPlayer)helper.makeMockServerPlayer(GameType.SURVIVAL);
player.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(player,ProgressRecord.INITIAL);
player.setPos(helper.absoluteVec(new Vec3(5.5,3,5.5)));
var support=helper.absolutePos(new BlockPos(1,1,1));
level.setBlockAndUpdate(support,Blocks.STONE.defaultBlockState());
level.setBlockAndUpdate(support.above(),Blocks.AIR.defaultBlockState());
var key=DemeureService.key(player,support);
double before=score(player,key);
helper.assertTrue(place(player,Items.COBBLESTONE,support),"Real block placement succeeds");
helper.assertTrue(Math.abs(score(player,key)-before-.05)<1e-8,"Confirmed placement adds one footprint contribution");
level.setBlockAndUpdate(support.above(),Blocks.AIR.defaultBlockState());
helper.assertTrue(!place(player,Items.OAK_SAPLING,support),"Invalid sapling placement fails");
helper.assertTrue(Math.abs(score(player,key)-before-.05)<1e-8,"Failed placement adds no footprint");
player.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.DIAMOND_PICKAXE));
helper.assertTrue(player.gameMode.destroyBlock(support),"Native break succeeds");
helper.assertTrue(Math.abs(score(player,key)-before-.1)<1e-8,"Native successful break adds one contribution");
byte[] colors=new byte[256];java.util.Arrays.fill(colors,(byte)42);
var tile=new AtlasStore.Tile(key.x(),key.z(),colors);
AtlasService.observations(player).observe(tile);
helper.assertTrue(AtlasDemeurePayload.forTiles(player,false,java.util.List.of(tile)).isEmpty(),"Locked map never transmits Demeure metadata");
var progress=ProgressRecord.INITIAL.buy("atlas",4);
player.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(player,progress);
helper.assertTrue(AtlasDemeurePayload.forTiles(player,false,java.util.List.of(tile)).size()==1,"Known chunk has a footprint after map unlock");
var hidden=new AtlasStore.Tile(key.x()+100,key.z()+100,colors);
helper.assertTrue(AtlasDemeurePayload.forTiles(player,false,java.util.List.of(hidden)).isEmpty(),"Caller-supplied tiles cannot disclose unobserved footprints");
helper.assertTrue(AtlasDemeurePayload.forTiles(player,true,java.util.List.of(tile)).isEmpty(),"Operator view requires actual permission");
var spectator=(ServerPlayer)helper.makeMockServerPlayer(GameType.SPECTATOR);
spectator.setAttached(ProgressionService.RECORD,progress.json());
helper.assertTrue(DemeureService.record(spectator,support,2)==0,"Spectators leave no habitation influence");
helper.succeed();
}
private static double score(ServerPlayer player,FootprintStore.Key key) {
var view=DemeureService.view(player.level().getServer(),key);
return view==null ? 0 : view.influences().stream().filter(s -> s.subject().id().equals(player.getUUID().toString())).mapToDouble(FootprintStore.Share::score).sum();
}
private static boolean place(ServerPlayer player,Item item,BlockPos support) {
player.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(item));
return ((BlockItem)item).place(new BlockPlaceContext(new UseOnContext(player,InteractionHand.MAIN_HAND,
new BlockHitResult(Vec3.atCenterOf(support),Direction.UP,support,false)))).consumesAction();
}
}
@@ -0,0 +1,85 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
import java.util.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.*;
import net.minecraft.core.dispenser.*;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.entity.projectile.Projectile;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.*;
import net.minecraft.world.phys.Vec3;
public final class Dispenser051GameTests {
@GameTest public void allNativeProjectileItemsFollowYawAndPitch(GameTestHelper h){
var p=player(h);equip(p,Items.DISPENSER);var session=HeadBlockService.session(p);var inventory=(DispenserBlockEntity)session.entity;
var ammunition=DispenserBlock.DISPENSER_REGISTRY.entrySet().stream().filter(e->e.getValue() instanceof ProjectileDispenseBehavior).map(Map.Entry::getKey).toList();
h.assertTrue(ammunition.size()==13,"All thirteen native projectile items covered");
for(var item:ammunition)for(var angle:new float[][]{{0,0},{90,0},{180,0},{-90,0},{37,-48},{-126,64},{0,-90},{0,90}}){
p.setYRot(angle[0]);p.setXRot(angle[1]);p.setPos(p.position().add(.013,0,.017));
int loaded=Math.min(2,new ItemStack(item).getMaxStackSize());
inventory.setItem(0,new ItemStack(item,loaded));
HeadBlockService.scoped(session,()->{session.state.tick(h.getLevel(),session.pos(),h.getLevel().getRandom());return null;});
var shots=shots(p);h.assertTrue(shots.size()==1,"Exactly one native projectile for "+item);
var shot=shots.getFirst();var look=p.getLookAngle();
h.assertTrue(shot.getDeltaMovement().normalize().dot(look)>.9,"Aim follows yaw and pitch for "+item+" at "+Arrays.toString(angle));
var muzzle=p.getEyePosition().add(0,.4925,0).add(look.scale(.35));
h.assertTrue(shot.position().distanceTo(muzzle)<.00001,"Spawn follows sub-block head position");
h.assertTrue(shot.getOwner()==p,"Wearer owns the shot: "+item);
h.assertTrue(inventory.getItem(0).getCount()==loaded-1,"One ammunition consumed: "+item);
float power=((ProjectileItem)item).createDispenseConfig().power();
h.assertTrue(shot.getDeltaMovement().length()>power*.65&&shot.getDeltaMovement().length()<power*1.35,"Native launch speed retained");
shot.discard();
}
cleanup(p);h.succeed();
}
@GameTest(maxTicks=20) public void delayedDamageTriggerUsesTheNewLookAndPosition(GameTestHelper h){
var p=player(h);equip(p,Items.DISPENSER);var session=HeadBlockService.session(p);var inventory=(DispenserBlockEntity)session.entity;
inventory.setItem(0,new ItemStack(Items.ARROW,2));p.setYRot(180);p.setXRot(0);HeadBlockService.pulse(p);
h.assertTrue(shots(p).isEmpty(),"Native four-tick launch delay retained");
p.setYRot(57);p.setXRot(-61);p.setPos(p.position().add(.42,0,-.38));
h.runAfterDelay(5,()->{
session.tick();var shots=shots(p);h.assertTrue(shots.size()==1,"Damage pulse emits one projectile");
h.assertTrue(shots.getFirst().getDeltaMovement().normalize().dot(p.getLookAngle())>.97,"Uses current look at firing time");
h.assertTrue(inventory.getItem(0).getCount()==1,"Single ammunition consumed after delay");
shots.forEach(Entity::discard);cleanup(p);h.succeed();
});
}
@GameTest public void placedDispenserKeepsItsOwnFacingEvenInsideAnotherHeadScope(GameTestHelper h){
var p=player(h);equip(p,Items.DISPENSER);p.setYRot(90);p.setXRot(-60);var session=HeadBlockService.session(p);
var at=session.pos().east(3);var state=Blocks.DISPENSER.defaultBlockState().setValue(DispenserBlock.FACING,Direction.SOUTH);
h.getLevel().setBlockAndUpdate(at,state);var inventory=(DispenserBlockEntity)h.getLevel().getBlockEntity(at);
for(boolean scoped:new boolean[]{false,true}){
inventory.setItem(0,new ItemStack(Items.ARROW,2));
if(scoped)HeadBlockService.scoped(session,()->{state.tick(h.getLevel(),at,h.getLevel().getRandom());return null;});
else state.tick(h.getLevel(),at,h.getLevel().getRandom());
var shots=shots(p);h.assertTrue(shots.size()==1,"Placed dispenser emits one projectile");var shot=shots.getFirst();
h.assertTrue(shot.getOwner()==null&&shot.getDeltaMovement().normalize().dot(new Vec3(0,0,1))>.97,"Ordinary dispenser stays ownerless and follows its block facing");
h.assertTrue(shot.getX()>p.getX()+2,"Origin stays at the placed dispenser");shot.discard();
}
cleanup(p);h.succeed();
}
@GameTest public void dropperStillDropsArrowsAndDownwardShotDoesNotHitWearer(GameTestHelper h){
var p=player(h);equip(p,Items.DROPPER);var dropper=HeadBlockService.session(p);((DispenserBlockEntity)dropper.entity).setItem(0,new ItemStack(Items.ARROW,2));
HeadBlockService.scoped(dropper,()->{dropper.state.tick(h.getLevel(),dropper.pos(),h.getLevel().getRandom());return null;});
var drops=h.getLevel().getEntitiesOfClass(ItemEntity.class,p.getBoundingBox().inflate(8));
h.assertTrue(shots(p).isEmpty()&&drops.stream().filter(e->e.getItem().is(Items.ARROW)).mapToInt(e->e.getItem().getCount()).sum()==1,"Dropper ejects one arrow item, never a shot");drops.forEach(Entity::discard);
equip(p,Items.DISPENSER);p.setXRot(90);
var session=HeadBlockService.session(p);((DispenserBlockEntity)session.entity).setItem(0,new ItemStack(Items.ARROW));
HeadBlockService.scoped(session,()->{session.state.tick(h.getLevel(),session.pos(),h.getLevel().getRandom());return null;});
var shot=shots(p).getFirst();float health=p.getHealth();shot.tick();
h.assertTrue(p.getHealth()==health,"Native owner immunity prevents immediate self-hit downwards");
shot.discard();cleanup(p);h.succeed();
}
private static List<Projectile> shots(ServerPlayer p){return p.level().getEntitiesOfClass(Projectile.class,p.getBoundingBox().inflate(8));}
private static ServerPlayer player(GameTestHelper h){var p=Progression003GameTests.player(h);p.setUUID(UUID.randomUUID());p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());ProgressEffects.apply(p,ProgressRecord.INITIAL);p.connection.handleAcceptPlayerLoad(new net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket());h.getLevel().addNewPlayer(p);return p;}
private static void equip(ServerPlayer p,Item item){AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,new ItemStack(item));}
private static void cleanup(ServerPlayer p){p.closeContainer();AccessoryAccess.of(p).clearContent();p.discard();}
}
@@ -0,0 +1,57 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.lighting.ItemLightEmission;
import fr.koka.sanctuary.progression.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.BlockItemStateProperties;
import net.minecraft.world.level.block.*;
public final class Dynamic030GameTests {
@GameTest public void allRegisteredBlockItemsUseTheirNativeEmission(GameTestHelper h) {
int count=0;
for(Item item:BuiltInRegistries.ITEM)if(item instanceof BlockItem block) {
var stack=new ItemStack(item);
h.assertTrue(ItemLightEmission.of(stack)==block.getBlock().defaultBlockState().getLightEmission(),"Native emission: "+item);count++;
}
h.assertTrue(count>500,"Checked complete block-item registry");h.succeed();
}
@GameTest public void litStateAndStackCountControlEmission(GameTestHelper h) {
var candle=new ItemStack(Items.CANDLE,64);
h.assertTrue(ItemLightEmission.of(candle)==0,"Unlit candles stay dark regardless of stack count");
candle.set(DataComponents.BLOCK_STATE,BlockItemStateProperties.EMPTY.with(CandleBlock.LIT,true).with(CandleBlock.CANDLES,4));
h.assertTrue(ItemLightEmission.of(candle)==12,"Four lit candles use actual block-state emission");
candle.setCount(1);h.assertTrue(ItemLightEmission.of(candle)==12,"Stack quantity never changes light");
candle.set(DataComponents.BLOCK_STATE,candle.get(DataComponents.BLOCK_STATE).with(CandleBlock.LIT,false));
h.assertTrue(ItemLightEmission.of(candle)==0,"Extinguishing is immediately reflected, despite cached default");
h.assertTrue(ItemLightEmission.of(new ItemStack(Items.SOUL_TORCH))==10 && ItemLightEmission.of(new ItemStack(Items.LANTERN))==15,"Native soul and lantern intensities");
h.assertTrue(ItemLightEmission.of(new ItemStack(Items.LAVA_BUCKET))==15 && ItemLightEmission.of(new ItemStack(Items.DIAMOND))==0,"Luminous non-block mapping excludes ordinary items");h.succeed();
}
@GameTest public void cosmeticCakeFollowsServerLightingAndAppearance(GameTestHelper h) {
var p=Progression003GameTests.player(h);p.setPos(h.absoluteVec(new net.minecraft.world.phys.Vec3(1,2,1)));
p.setUUID(java.util.UUID.randomUUID());p.setAttached(ProgressionService.RECORD,ProgressRecord.INITIAL.json());
ProgressEffects.apply(p,ProgressRecord.INITIAL);
((SanctuaryExtendedInventoryMenu)p.inventoryMenu).sanctuary$ensureExtendedInventory();
AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,new ItemStack(Items.CAKE));AccessoryService.publish(p);
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.CANDLE));
h.assertTrue(HeadCosmeticService.interact(p,p),"Place cosmetic candle");
h.assertTrue(ItemLightEmission.of(HeadCosmeticService.appearance(p))==0,"Unlit composite appearance is dark");
h.startSequence().thenIdle(5).thenExecute(()->{
p.setItemInHand(InteractionHand.MAIN_HAND,new ItemStack(Items.FLINT_AND_STEEL));
h.assertTrue(HeadCosmeticService.interact(p,p),"Server lights candle");
h.assertTrue(ItemLightEmission.of(HeadCosmeticService.appearance(p))==3,"Synced cosmetic cake emits native light");
var drop=HeadCosmeticService.appearance(p).copy();
h.assertTrue(ItemLightEmission.of(drop)==3,"Dropping preserves lit composite stack");
h.assertTrue(!p.isOnFire(),"Visual light creates no player fire");
}).thenIdle(5).thenExecute(()->{
p.setItemInHand(InteractionHand.MAIN_HAND,ItemStack.EMPTY);HeadCosmeticService.interact(p,p);
h.assertTrue(ItemLightEmission.of(HeadCosmeticService.appearance(p))==0,"Extinguished appearance stops emitting");
}).thenSucceed();
}
}
@@ -0,0 +1,8 @@
package fr.koka.sanctuary.gametest;
import java.util.concurrent.atomic.AtomicLong;
public final class Dynamic030RenderProbe {
public static volatile boolean active;
public static final AtomicLong litTerrainSamples=new AtomicLong();
}
@@ -0,0 +1,218 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.companion.*;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.progression.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents;
import net.fabricmc.fabric.api.event.player.AttackEntityCallback;
import net.minecraft.core.component.DataComponents;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.ProblemReporter;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.*;
import net.minecraft.world.phys.Vec3;
public final class Familiar020GameTests {
@GameTest public void eggSizeSurvivesStorageAndChangesOfOwner(GameTestHelper h){
var p=player(h);var egg=AccessoryAccess.of(p).getItem(0).copy();int size=FamiliarSize.value(egg);
h.assertTrue(size>=120&&size<=6000&&FamiliarSize.value(FamiliarService.active(p).egg())==size,"First equipment rolls and synchronizes an egg-specific size");
var random=net.minecraft.util.RandomSource.create(42);
h.assertTrue(ItemStack.isSameItemSameComponents(egg,FamiliarSize.initialize(egg,random)),"Reequipping cannot reroll the trait");
var output=output(h);p.saveWithoutId(output);var loaded=player(h);
loaded.load(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),output.buildResult()));
var stored=AccessoryAccess.of(loaded).getItem(0);
h.assertTrue(ItemStack.isSameItemSameComponents(egg,stored),"Native inventory save/load preserves the exact egg and size");
var other=player(h);stored.set(DataComponents.CUSTOM_NAME,Component.literal("Géant de poche"));
AccessoryAccess.of(other).setItem(0,stored);other.tickCount+=10;FamiliarService.tick(other);
h.assertTrue(FamiliarSize.value(FamiliarService.active(other).egg())==size,"Name changes and transfer to another inhabitant keep the same size");h.succeed();
}
@GameTest public void sizeDistributionAndUnknownDataAreBounded(GameTestHelper h){
var random=net.minecraft.util.RandomSource.create(42);int tiny=0,ordinary=0,giant=0;
for(int i=0;i<10000;i++){int n=FamiliarSize.roll(random);h.assertTrue(n>=120&&n<=6000,"Every size lies in the visual range");if(n<=350)tiny++;else if(n>=2500)giant++;else ordinary++;}
h.assertTrue(tiny>300&&tiny<700&&giant>300&&giant<700&&ordinary>8500,"Ordinary sizes dominate, with rare tiny and giant eggs");
var egg=new ItemStack(Items.CAT_SPAWN_EGG);net.minecraft.world.item.component.CustomData.update(DataComponents.CUSTOM_DATA,egg,tag->{
tag.putString("other_mod","preserved");var unknown=new net.minecraft.nbt.CompoundTag();unknown.putInt("schema",99);unknown.putInt("per_mille",100000);tag.put(FamiliarSize.KEY,unknown);
});
var before=egg.copy();h.assertTrue(FamiliarSize.multiplier(egg)==1&&ItemStack.isSameItemSameComponents(before,FamiliarSize.initialize(egg,random)),"Unknown size data stays intact with neutral rendering");h.succeed();
}
@GameTest public void carriesPlayerOnHeadWithoutFriendlyDamage(GameTestHelper h){
var carrier=player(h);var rider=player(h);rider.setPos(carrier.position().add(0,0,1.5));rider.setShiftKeyDown(true);
double speed=carrier.getAttributeValue(Attributes.MOVEMENT_SPEED);
h.assertTrue(CarryService.interact(rider,carrier),"Sneak interaction mounts native player on player");
h.assertTrue(rider.getVehicle()==carrier&&Math.abs((rider.getY()+.7040625)-(carrier.getY()+1.8759375))<.00001,
"The seated pelvis rests on the head; feet hang below it");
h.assertTrue(Math.abs(carrier.getAttributeValue(Attributes.MOVEMENT_SPEED)-speed*.75)<.0001,"Carrier is slowed by 25 percent");
h.assertTrue(!CarryService.mayDismount(rider),"The initial held sneak does not immediately dismount");
h.assertTrue(!SanctuarySitting.request(carrier),"Carrying cannot accidentally turn a double-sneak gesture into sitting");
var saved=output(h);rider.saveWithoutId(saved);
h.assertTrue(!saved.buildResult().contains("RootVehicle"),"A carried player never saves the other player as its vehicle");
float before=carrier.getHealth();rider.attack(carrier);
h.assertTrue(carrier.getHealth()==before&&!carrier.hurtServer(h.getLevel(),carrier.damageSources().playerAttack(rider),2),"Direct and indirect rider damage refused");
h.assertTrue(!rider.hurtServer(h.getLevel(),rider.damageSources().playerAttack(carrier),2),"Carrier cannot hurt its passenger");
CarryService.detach(rider);
h.assertTrue(!rider.isPassenger()&&!carrier.isRemoved()&&Math.abs(carrier.getAttributeValue(Attributes.MOVEMENT_SPEED)-speed)<.0001,"Detaching never removes the other player and restores speed");h.succeed();
}
@GameTest public void carriedAnimalSavesOnceWithNativeIdentity(GameTestHelper h){
var p=player(h);var cow=EntityTypes.COW.create(h.getLevel(),EntitySpawnReason.COMMAND);cow.setPos(p.position().add(0,0,2));
cow.setCustomName(Component.literal("Pamplemousse"));cow.setNoAi(true);cow.setHealth(7);h.getLevel().addFreshEntity(cow);
try{
h.assertTrue(CarryService.interact(p,cow)&&cow.shouldBeSaved(),"Original animal remains eligible for world saving while carried");
var output=output(h);h.assertTrue(cow.save(output),"Native entity storage can save a carried animal as a root");
var playerOutput=output(h);p.saveWithoutId(playerOutput);
h.assertTrue(!playerOutput.buildResult().contains("Passengers"),"No duplicate animal is embedded in player data");
var loaded=EntityTypes.COW.create(h.getLevel(),EntitySpawnReason.COMMAND);
loaded.load(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),output.buildResult()));
h.assertTrue(loaded.getUUID().equals(cow.getUUID())&&loaded.getCustomName().getString().equals("Pamplemousse")&&loaded.getHealth()==7
&&loaded.isNoAi()&&!loaded.isPassenger()&&!CarryService.marked(loaded),"UUID, name, health and prior AI survive without a saved carry state");
h.assertTrue(CarryService.interact(p,cow)&&!cow.isPassenger()&&!cow.isRemoved(),"Second interaction puts the same animal down");
}finally{CarryService.detach(p);cow.discard();}h.succeed();
}
@GameTest public void carryRejectsCrowdedHeadAndSecondSeat(GameTestHelper h){
var p=player(h);var cow=EntityTypes.COW.create(h.getLevel(),EntitySpawnReason.COMMAND);cow.setPos(p.position().add(2,0,0));h.getLevel().addFreshEntity(cow);
try{
h.getLevel().setBlockAndUpdate(p.blockPosition().above(2),Blocks.STONE.defaultBlockState());
h.assertTrue(!CarryService.interact(p,cow)&&!cow.isPassenger(),"An occupied head space refuses carrying");
h.getLevel().setBlockAndUpdate(p.blockPosition().above(2),Blocks.AIR.defaultBlockState());
h.assertTrue(CarryService.interact(p,cow),"Clear head space accepts carrying");
var other=player(h);other.setPos(p.position().add(0,0,2));
h.assertTrue(!CarryService.interact(other,p),"An occupied head cannot acquire a second direct passenger");
}finally{CarryService.detach(p);cow.discard();}h.succeed();
}
@GameTest public void tappingAndRenamingFamiliarUsesNoDamage(GameTestHelper h){
var p=player(h);var pet=FamiliarService.active(p);pet.setPos(p.position().add(0,0,1.5));float health=pet.getHealth();
var sword=new ItemStack(Items.DIAMOND_SWORD);p.setItemInHand(InteractionHand.MAIN_HAND,sword);
var result=AttackEntityCallback.EVENT.invoker().interact(p,h.getLevel(),InteractionHand.MAIN_HAND,pet,null);
h.assertTrue(result.consumesAction()&&pet.hurtTime>0&&pet.getHealth()==health&&sword.getDamageValue()==0,"Tap animates without damage or tool wear");
var tag=new ItemStack(Items.NAME_TAG,2);tag.set(DataComponents.CUSTOM_NAME,Component.literal("Nuage"));p.setItemInHand(InteractionHand.MAIN_HAND,tag);
pet.interact(p,InteractionHand.MAIN_HAND,Vec3.ZERO);
h.assertTrue(tag.getCount()==1&&pet.getCustomName().getString().equals("Nuage")&&AccessoryAccess.of(p).getItem(0).getCustomName().getString().equals("Nuage"),"Native name tag renames both pet and equipped egg");
var named=AccessoryAccess.of(p).getItem(0).copy();AccessoryAccess.of(p).setItem(0,ItemStack.EMPTY);FamiliarService.tick(p);
h.assertTrue(pet.isRemoved()&&FamiliarService.active(p)==null,"Removing egg despawns the familiar");
AccessoryAccess.of(p).setItem(0,named);p.tickCount+=10;FamiliarService.tick(p);
h.assertTrue(FamiliarService.active(p).getCustomName().getString().equals("Nuage"),"Reequipped named egg keeps the name");h.succeed();
}
@GameTest public void studyChargesOnceAndSurvivesDeathAndPrestige(GameTestHelper h) throws Exception {
var p=player(h);p.giveExperienceLevels(10);
h.assertTrue(CompanionStudy.buy(p)&&p.experienceLevel==6&&!CompanionStudy.buy(p)&&p.experienceLevel==6,"Study purchase charges exactly once");
var output=output(h);p.saveWithoutId(output);var loaded=player(h);
loaded.load(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),output.buildResult()));
h.assertTrue(CompanionStudy.learned(loaded.getAttached(CompanionStudy.RECEIPT)),"Study receipt survives native save/load");
var respawn=player(h);respawn.setUUID(p.getUUID());respawn.restoreFrom(p,false);ServerPlayerEvents.AFTER_RESPAWN.invoker().afterRespawn(p,respawn,false);
h.assertTrue(CompanionStudy.learned(respawn.getAttached(CompanionStudy.RECEIPT)),"Study remains unlocked after death");
p=respawn; // The old life has been retired by the actual respawn callbacks.
var session=ProgressionService.session(p.level().getServer());
session.inhabitants.create(p.getUUID(),"study"+p.getUUID().toString().substring(0,8),"Study fixture",session.inhabitants.available().getFirst());
var progress=ProgressEffects.get(p);for(String skill:ProgressRecord.SKILLS)while(progress.rank(skill)<ProgressRecord.cap(skill))progress=progress.buy(skill,1);
p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);
String receipt=p.getAttached(CompanionStudy.RECEIPT);
var result=fr.koka.sanctuary.cycle.CycleService.perform(p,new fr.koka.sanctuary.cycle.CyclePayloads.Action("ngp","","",session.cycles.revision(),progress.revision()));
h.assertTrue(result.equals("done")&&receipt.equals(p.getAttached(CompanionStudy.RECEIPT)),"A real prestige preserves the exact study purchase receipt");h.succeed();
}
@GameTest public void studyCorruptionAndConsentDefaults(GameTestHelper h){
var p=player(h);h.assertTrue(CompanionService.record(p).acceptAllies(),"New inhabitants accept familiar assistance");
CompanionService.save(p,CompanionService.record(p).consent(false));CompanionService.clear(p);
h.assertTrue(!CompanionService.record(p).acceptAllies(),"An existing refusal is respected");
String raw="{\"schema\":99}";p.setAttached(CompanionStudy.RECEIPT,raw);p.giveExperienceLevels(10);
h.assertTrue(!CompanionStudy.buy(p)&&p.experienceLevel==10&&p.getAttached(CompanionStudy.RECEIPT).equals(raw),"Bad study receipt is preserved and never charged");h.succeed();
}
@GameTest public void divingRequiresSprintTakeoffAndCannotRepeat(GameTestHelper h){
var p=player(h);p.setOnGround(false);p.setDeltaMovement(0,.3,.3);
h.assertTrue(!posture(p,3),"A regular fall cannot start a dive");
sprintJump(p);
double oldSpeed=p.getDeltaMovement().horizontalDistance();double oldY=p.getDeltaMovement().y;
h.assertTrue(posture(p,3)&&LyingService.posture(p)==3,"W after sprint takeoff starts dive");
h.assertTrue(p.getDeltaMovement().horizontalDistance()>oldSpeed&&p.getDeltaMovement().y<=oldY,"Dive is a forward impulse without extra height");
h.assertTrue(!posture(p,0)&&LyingService.posture(p)==3&&!posture(p,3),"Repeated posture requests cannot cancel the dive or grant a second airborne impulse");
p.setOnGround(true);LyingService.tick(p);h.assertTrue(LyingService.posture(p)==0,"Landing finishes dive");h.succeed();
}
@GameTest public void divingEndsInWaterAndKeepsSwimmingLocked(GameTestHelper h){
var p=player(h);sprintJump(p);h.assertTrue(posture(p,3),"Sprint jump dive begins");
var at=p.blockPosition();for(int y=0;y<3;y++)h.getLevel().setBlockAndUpdate(at.above(y),Blocks.WATER.defaultBlockState());
p.baseTick();LyingService.tick(p);p.updateSwimming();
h.assertTrue(p.isInWater()&&LyingService.posture(p)==0&&!p.isSwimming(),"Water ends dry dive without unlocking swim movements");h.succeed();
}
@GameTest public void longDiveKeepsItsNativePoseUntilContact(GameTestHelper h) throws Exception {
var p=airborneDive(h);p.fallDistance=12;p.setDeltaMovement(0,-1,0);
for(int tick=0;tick<240;tick++){
p.tickCount++;p.setShiftKeyDown(tick%2==0);p.setXRot(tick%90);p.setYRot(tick*5);
h.assertTrue(!posture(p,0)&&!posture(p,2)&&!posture(p,3),"Posture keys cannot cancel or restart a dive before contact");
LyingService.tick(p);updatePose(p);
h.assertTrue(LyingService.posture(p)==3&&p.getPose()==Pose.SWIMMING,"The native low pose survives well beyond the former 30-tick limit");
}
h.assertTrue(p.fallDistance==12&&p.getDeltaMovement().equals(new Vec3(0,-1,0)),"Holding the posture adds no impulse and does not erase falling distance");
p.setOnGround(true);LyingService.tick(p);h.succeed();
}
@GameTest public void diveEndsOnANativeWallCollision(GameTestHelper h) throws Exception {
var p=airborneDive(h);var at=p.blockPosition();
for(int x=-1;x<=1;x++)for(int y=0;y<=2;y++)h.getLevel().setBlockAndUpdate(at.offset(x,y,2),Blocks.STONE.defaultBlockState());
p.move(MoverType.SELF,new Vec3(0,0,3));
h.assertTrue(p.horizontalCollision&&!p.onGround(),"Native movement hits the wall while still airborne");
LyingService.tick(p);h.assertTrue(LyingService.posture(p)==0&&!posture(p,3),"A wall ends the posture without granting another airborne impulse");h.succeed();
}
@GameTest public void diveEndsOnANativeCeilingCollision(GameTestHelper h) throws Exception {
var p=airborneDive(h);var at=p.blockPosition();
for(int x=-1;x<=1;x++)for(int z=-1;z<=1;z++)h.getLevel().setBlockAndUpdate(at.offset(x,1,z),Blocks.STONE.defaultBlockState());
p.move(MoverType.SELF,new Vec3(0,2,0));
h.assertTrue(p.verticalCollision&&!p.onGround(),"Native movement hits the ceiling rather than the ground");
LyingService.tick(p);h.assertTrue(LyingService.posture(p)==0,"An upward collision ends the dive too");h.succeed();
}
@GameTest public void diveEndsOnNativeLandingAndCanStartAfterANewJump(GameTestHelper h) throws Exception {
var p=airborneDive(h);p.move(MoverType.SELF,new Vec3(0,-5,0));
h.assertTrue(p.onGround()&&p.verticalCollision,"Native movement really reaches the arena floor");
LyingService.tick(p);updatePose(p);
h.assertTrue(LyingService.posture(p)==0&&p.getPose()==Pose.STANDING,"Landing restores the standing pose in clear space");
p.verticalCollision=false;sprintJump(p);
h.assertTrue(posture(p,3),"A new grounded sprint jump permits a new dive");
p.setOnGround(true);LyingService.tick(p);h.succeed();
}
@GameTest public void diveStillStopsOnDeathOrFlight(GameTestHelper h) throws Exception {
var dead=airborneDive(h);dead.setHealth(0);LyingService.tick(dead);
h.assertTrue(LyingService.posture(dead)==0,"Removing the timer never preserves a dive after death");
var flying=airborneDive(h);flying.getAbilities().flying=true;LyingService.tick(flying);
h.assertTrue(LyingService.posture(flying)==0,"Creative flight exits the temporary dive posture");h.succeed();
}
@GameTest public void slowFallClearsPreviouslyAccumulatedFall(GameTestHelper h){
var p=player(h);Companion019GameTests.equip(p,CompanionType.CHICKEN);p.setOnGround(false);p.fallDistance=30;p.setDeltaMovement(0,-1,0);
var action=CompanionActions.prepare(p,CompanionType.CHICKEN,-1);h.assertTrue(action.success(),"Chicken can cushion an existing fall");action.commit().run();
CompanionEffects.tick(h.getLevel().getServer());
h.assertTrue(p.fallDistance==0&&p.getDeltaMovement().y>=-.101,"Slow descent discards the old dangerous fall accumulation");
CompanionEffects.removeOwner(p);p.fallDistance=8;float health=p.getHealth();p.causeFallDamage(p.fallDistance,1,p.damageSources().fall());
h.assertTrue(p.getHealth()<health,"A new unprotected fall still causes native damage");h.succeed();
}
@GameTest public void teleportCanCrossWallButNeverLandInsideIt(GameTestHelper h){
var p=player(h);Companion019GameTests.equip(p,CompanionType.ENDERMITE);p.setYRot(0);p.setXRot(0);
var origin=p.blockPosition();for(int x=-2;x<=2;x++)for(int y=0;y<4;y++)h.getLevel().setBlockAndUpdate(origin.offset(x,y,1),Blocks.STONE.defaultBlockState());
var destination=CompanionWorld.teleportDestination(p,3);
h.assertTrue(destination!=null&&destination.z>p.getZ()+2,"An unseen free landing beyond the wall is found");
var action=CompanionActions.prepare(p,CompanionType.ENDERMITE,-1);h.assertTrue(action.success(),"Endermite teleport accepts wall crossing");action.commit().run();
h.assertTrue(p.position().distanceToSqr(destination)<.001&&h.getLevel().noCollision(p),"Teleport arrives in free space");
h.getLevel().setBlockAndUpdate(p.blockPosition(),Blocks.STONE.defaultBlockState());h.assertTrue(!CompanionWorld.safe(p,p.position(),true),"A filled landing is refused");h.succeed();
}
@GameTest public void sonarDetectsLivingMobBehindWall(GameTestHelper h){
var p=player(h);var cow=EntityTypes.COW.create(h.getLevel(),EntitySpawnReason.COMMAND);cow.setNoAi(true);cow.setPos(p.position().add(0,0,3));h.getLevel().addFreshEntity(cow);
for(int x=-2;x<=2;x++)for(int y=0;y<4;y++)h.getLevel().setBlockAndUpdate(p.blockPosition().offset(x,y,1),Blocks.STONE.defaultBlockState());
try{h.assertTrue(!CompanionWorld.visible(p,cow)&&CompanionQueries.sonar(p,12,3,100).success(),"Sonar works behind solid walls without turning ordinary targeting into wall vision");}finally{cow.discard();}h.succeed();
}
private static ServerPlayer player(GameTestHelper h){return Companion019GameTests.player(h,CompanionType.CAT);}
private static TagValueOutput output(GameTestHelper h){return TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());}
private static boolean posture(ServerPlayer p,int state){return LyingService.request(p,new LyingService.Request(state,ProgressEffects.get(p).revision()));}
private static void updatePose(ServerPlayer p) throws Exception {
var method=net.minecraft.world.entity.player.Player.class.getDeclaredMethod("updatePlayerPose");method.setAccessible(true);method.invoke(p);
}
private static ServerPlayer airborneDive(GameTestHelper h) throws Exception {
var p=player(h);sprintJump(p);p.setPos(p.position().add(0,3,0));
h.assertTrue(posture(p,3),"A valid sprint jump starts a dive in clear air");updatePose(p);return p;
}
private static void sprintJump(ServerPlayer p){
var progress=ProgressRecord.INITIAL.buy("hunger",1);p.setAttached(ProgressionService.RECORD,progress.json());ProgressEffects.apply(p,progress);
p.getFoodData().setFoodLevel(8);p.setOnGround(true);p.setSprinting(true);DiveService.track(p);p.setYRot(0);p.setXRot(0);p.setOnGround(false);p.setDeltaMovement(0,.3,.3);
}
}
@@ -0,0 +1,103 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.companion.CompanionType;
import fr.koka.sanctuary.cosmetics.*;
import fr.koka.sanctuary.mixin.FamiliarVoiceAccess;
import io.netty.channel.embedded.EmbeddedChannel;
import java.util.List;
import net.fabricmc.fabric.api.event.player.AttackEntityCallback;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.network.Connection;
import net.minecraft.network.protocol.game.ClientboundSoundPacket;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.network.ServerCommonPacketListenerImpl;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.*;
import net.minecraft.world.item.*;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.Vec3;
public final class Familiar029GameTests {
@GameTest public void everyEggBroadcastsItsNativeHurtSound(GameTestHelper h) throws Exception {
var p=player(h,CompanionType.CAT);var channel=channel(p);var players=h.getLevel().getServer().getPlayerList().getPlayers();players.add(p);
try{
for(var type:CompanionType.values()){
Companion019GameTests.equip(p,type);var pet=FamiliarService.active(p);pet.setPos(p.position().add(0,0,1.5));
var nativeMob=(Mob)SpawnEggItem.getType(pet.egg()).create(h.getLevel(),EntitySpawnReason.MOB_SUMMONED);nativeMob.setBaby(true);
var access=(FamiliarVoiceAccess)nativeMob;var expected=access.sanctuary$hurtSound(p.damageSources().playerAttack(p));
long before=entities(h.getLevel());channel.outboundMessages().clear();pet.tap(p);var sounds=sounds(channel);
h.assertTrue(expected==null?sounds.isEmpty():sounds.size()==1,"Exactly one native sound packet per voiced egg: "+type);
if(expected!=null){var packet=sounds.getFirst();h.assertTrue(packet.getSound().value()==expected
&&packet.getSource()==nativeMob.getSoundSource()&&packet.getVolume()==access.sanctuary$soundVolume()
&&Float.isFinite(packet.getPitch())&&packet.getPitch()>0,"Species, sound category, volume and valid pitch: "+type);}
h.assertTrue(entities(h.getLevel())==before&&pet.getHealth()==pet.getMaxHealth(),"Voice lookup never spawns another entity or hurts the familiar: "+type);
}
}finally{players.remove(p);}h.succeed();
}
@GameTest public void directionalImpulseMovesThePetAndCooldownPreventsSpam(GameTestHelper h) throws Exception {
var p=player(h,CompanionType.CAT);var pet=FamiliarService.active(p);var sword=new ItemStack(Items.DIAMOND_SWORD);p.setItemInHand(InteractionHand.MAIN_HAND,sword);
var channel=channel(p);var players=h.getLevel().getServer().getPlayerList().getPlayers();players.add(p);
try{
for(var direction:List.of(new Vec3(1,0,0),new Vec3(-1,0,0),new Vec3(0,0,1),new Vec3(0,0,-1))){
pet.setPos(p.position().add(direction.scale(1.5)));pet.setDeltaMovement(Vec3.ZERO);pet.setOnGround(true);pet.hurtTime=0;
var from=pet.position();channel.outboundMessages().clear();tap(p,pet);
var impulse=pet.getDeltaMovement();h.assertTrue(impulse.dot(direction)>.39&&impulse.y>.39&&pet.syncVelocity,"Hit pushes away from the attacker, with a native grounded lift and network synchronization");
tap(p,pet);h.assertTrue(pet.getDeltaMovement().equals(impulse)&&sounds(channel).size()==1,"Immediate repeated hits neither stack velocity nor repeat the cry");
tick(pet);h.assertTrue(pet.position().subtract(from).dot(direction)>0,"Native ticking actually moves the pet away");
h.assertTrue(pet.hurtTime>0&&pet.getHealth()==pet.getMaxHealth()&&pet.getTarget()==null&&sword.getDamageValue()==0,"Hurt animation has no health, aggro or weapon cost");
}
h.assertTrue(sounds(channel).getFirst().getPitch()>=1.3F&&sounds(channel).getFirst().getPitch()<=1.7F,"Baby cat uses its native higher voice");
}finally{players.remove(p);}h.succeed();
}
@GameTest public void flyingAndAquaticFollowCannotEraseTheImpulse(GameTestHelper h){
var p=player(h,CompanionType.GHAST);
for(var type:List.of(CompanionType.GHAST,CompanionType.WITHER,CompanionType.ENDER_DRAGON,CompanionType.COD)){
Companion019GameTests.equip(p,type);var pet=FamiliarService.active(p);
pet.setPos(p.position().add(0,type==CompanionType.COD?0:2,1.5));pet.setOnGround(type==CompanionType.COD);pet.setDeltaMovement(Vec3.ZERO);pet.tickCount=23;
var from=pet.position();pet.tap(p);tick(pet);
h.assertTrue(pet.getZ()>from.z&&pet.getHealth()==pet.getMaxHealth(),"Flight following or a dry fish flop cannot cancel the initial recoil: "+type);
}h.succeed();
}
@GameTest public void guardianUsesWaterVariantAndSilentPetsStaySilent(GameTestHelper h) throws Exception {
var p=player(h,CompanionType.GUARDIAN);var pet=FamiliarService.active(p);pet.setPos(p.position().add(0,0,1.5));
var players=h.getLevel().getServer().getPlayerList().getPlayers();var channel=channel(p);players.add(p);
try{
channel.outboundMessages().clear();pet.tap(p);h.assertTrue(sounds(channel).getFirst().getSound().value()==SoundEvents.GUARDIAN_HURT_LAND,"Dry guardian cries on land");
var at=pet.blockPosition();for(int y=0;y<3;y++)h.getLevel().setBlockAndUpdate(at.above(y),Blocks.WATER.defaultBlockState());pet.baseTick();
h.assertTrue(pet.isInWater(),"Native water fixture");pet.hurtTime=0;channel.outboundMessages().clear();pet.tap(p);
h.assertTrue(sounds(channel).getFirst().getSound().value()==SoundEvents.GUARDIAN_HURT,"The same cached voice switches to its underwater variant");
pet.hurtTime=0;pet.setSilent(true);channel.outboundMessages().clear();pet.tap(p);
h.assertTrue(sounds(channel).isEmpty()&&pet.hurtTime>0,"Native Silent suppresses audio without removing visual feedback");
}finally{players.remove(p);}h.succeed();
}
@GameTest public void carriedPairAndInvulnerabilityStillApply(GameTestHelper h){
var p=player(h,CompanionType.CAT);var pet=FamiliarService.active(p);pet.setPos(p.position().add(0,0,1.5));pet.setDeltaMovement(Vec3.ZERO);
try{
h.assertTrue(CarryService.interact(p,pet),"Carrier fixture");tap(p,pet);pet.tap(p);
h.assertTrue(pet.hurtTime==0&&pet.getDeltaMovement().equals(Vec3.ZERO),"A carried familiar cannot be struck by its own stack");
CarryService.release(pet,true);float hp=pet.getHealth();
h.assertTrue(!pet.hurtServer(h.getLevel(),pet.damageSources().playerAttack(p),100)&&pet.getHealth()==hp,"Feedback never enables real damage");
}finally{CarryService.detach(p);}h.succeed();
}
@GameTest public void recoilRespectsWallsThenNormalFollowingResumes(GameTestHelper h){
var p=player(h,CompanionType.CAT);var pet=FamiliarService.active(p);pet.setPos(p.position().add(0,0,1.5));pet.setDeltaMovement(Vec3.ZERO);pet.setOnGround(true);
var wall=p.blockPosition().offset(0,0,3);
for(int x=-1;x<=1;x++)for(int y=0;y<4;y++)h.getLevel().setBlockAndUpdate(wall.offset(x,y,0),Blocks.STONE.defaultBlockState());
pet.tap(p);for(int i=0;i<12;i++)tick(pet);
h.assertTrue(pet.getBoundingBox().maxZ<=wall.getZ()+.001&&h.getLevel().noBlockCollision(pet,pet.getBoundingBox().deflate(.02)),"Recoil follows physical movement and cannot push the pet through a solid wall");
pet.setPos(p.position().add(4,0,0));pet.setDeltaMovement(Vec3.ZERO);double distance=pet.distanceToSqr(p);
for(int i=0;i<24;i++)tick(pet);
h.assertTrue(pet.distanceToSqr(p)<distance&&pet.getHealth()==pet.getMaxHealth(),"Normal following resumes after the short recoil pause: before="+distance+", after="+pet.distanceToSqr(p)+", velocity="+pet.getDeltaMovement()+", speed="+pet.getSpeed()+", ground="+pet.onGround()+", pathDone="+pet.getNavigation().isDone()+", tick="+pet.tickCount);h.succeed();
}
private static ServerPlayer player(GameTestHelper h,CompanionType type){return Companion019GameTests.player(h,type);}
private static void tap(ServerPlayer p,FamiliarEntity pet){AttackEntityCallback.EVENT.invoker().interact(p,p.level(),InteractionHand.MAIN_HAND,pet,null);}
private static EmbeddedChannel channel(ServerPlayer p) throws Exception {
var field=ServerCommonPacketListenerImpl.class.getDeclaredField("connection");field.setAccessible(true);var connection=(Connection)field.get(p.connection);
var channel=Connection.class.getDeclaredField("channel");channel.setAccessible(true);return (EmbeddedChannel)channel.get(connection);
}
private static List<ClientboundSoundPacket> sounds(EmbeddedChannel channel){channel.runPendingTasks();return channel.outboundMessages().stream().filter(ClientboundSoundPacket.class::isInstance).map(ClientboundSoundPacket.class::cast).toList();}
private static long entities(net.minecraft.server.level.ServerLevel level){long count=0;for(var e:level.getAllEntities())count++;return count;}
private static void tick(FamiliarEntity pet){pet.commonTick();pet.tick();}
}
@@ -0,0 +1,66 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.food.*;
import fr.koka.sanctuary.grave.*;
import fr.koka.sanctuary.progression.*;
import fr.koka.sanctuary.cycle.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.core.component.DataComponents;
import net.minecraft.world.item.*;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.level.storage.*;
import net.minecraft.util.ProblemReporter;
import net.minecraft.resources.Identifier;
import net.minecraft.stats.Stats;
import java.util.*;
public final class FoodKnowledge039GameTests {
private static ServerPlayer player(GameTestHelper h){var p=GravesFood038GameTests.player(h,1);p.setUUID(UUID.randomUUID());return p;}
private static void eat(ServerPlayer p,Item item){var stack=new ItemStack(item,2);stack.get(DataComponents.CONSUMABLE).onConsume(p.level(),p,stack);}
@GameTest public void actualConsumptionLearnsOnlyTheEatenFood(GameTestHelper h){
var p=player(h);var apple=new ItemStack(Items.APPLE);p.getInventory().setItem(0,apple);p.getFoodData().setFoodLevel(0);
h.assertTrue(!FoodKnowledgeService.knows(p,apple),"Holding is not tasting");p.startUsingItem(InteractionHand.MAIN_HAND);p.stopUsingItem();
h.assertTrue(!FoodKnowledgeService.knows(p,apple),"Cancelled eating is not tasting");p.awardStat(Stats.ITEM_USED.get(Items.CARROT));
h.assertTrue(!FoodKnowledgeService.knows(p,new ItemStack(Items.CARROT)),"Planting/use counters cannot reveal nutrition");eat(p,Items.APPLE);
h.assertTrue(FoodKnowledgeService.knows(p,apple),"Native completed food listener learns apple");eat(p,Items.BEEF);
h.assertTrue(!FoodKnowledgeService.knows(p,new ItemStack(Items.COOKED_BEEF)),"Raw and cooked foods are distinct");
h.assertTrue(!FoodKnowledgeService.knows(player(h),apple),"Knowledge belongs to the eater");h.succeed();
}
@GameTest public void studyCostsExactlyFourAndCannotBeRepurchased(GameTestHelper h){
var p=player(h);p.setExperienceLevels(3);h.assertTrue(!FoodKnowledgeService.buy(p)&&p.experienceLevel==3,"Insufficient XP retained");p.setExperienceLevels(9);
h.assertTrue(FoodKnowledgeService.buy(p)&&p.experienceLevel==5,"Buy four levels");var raw=p.getAttached(FoodKnowledgeService.RECORD);
h.assertTrue(!FoodKnowledgeService.buy(p)&&p.experienceLevel==5&&raw.equals(p.getAttached(FoodKnowledgeService.RECORD)),"Duplicate buy neither charges nor changes receipt");
h.assertTrue(FoodKnowledgeService.knows(p,new ItemStack(Items.GOLDEN_CARROT)),"Study reveals untasted food");h.succeed();
}
@GameTest public void knowledgeAndReceiptSurviveSaveDeathAndPrestige(GameTestHelper h)throws Exception{
var p=player(h);eat(p,Items.APPLE);p.setExperienceLevels(12);h.assertTrue(FoodKnowledgeService.buy(p),"Study purchased");
var raw=p.getAttached(FoodKnowledgeService.RECORD);var out=TagValueOutput.createWithContext(ProblemReporter.DISCARDING,h.getLevel().registryAccess());p.saveWithoutId(out);
var loaded=player(h);loaded.load(TagValueInput.create(ProblemReporter.DISCARDING,h.getLevel().registryAccess(),out.buildResult()));loaded.setUUID(p.getUUID());
h.assertTrue(raw.equals(loaded.getAttached(FoodKnowledgeService.RECORD))&&loaded.experienceLevel==8,"Knowledge and payment save together");
var respawn=player(h);respawn.setUUID(p.getUUID());respawn.restoreFrom(loaded,false);ServerPlayerEvents.AFTER_RESPAWN.invoker().afterRespawn(loaded,respawn,false);
h.assertTrue(raw.equals(respawn.getAttached(FoodKnowledgeService.RECORD)),"Death preserves attachment");
var session=ProgressionService.session(p.level().getServer());session.inhabitants.create(p.getUUID(),"food"+p.getUUID().toString().substring(0,8),"",session.inhabitants.available().getFirst());
var r=ProgressRecord.INITIAL;for(var 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);
h.assertTrue(CycleService.perform(p,new CyclePayloads.Action("ngp","","",session.cycles.revision(),r.revision())).equals("done"),"Real prestige accepted");
h.assertTrue(raw.equals(p.getAttached(FoodKnowledgeService.RECORD))&&FoodKnowledgeService.knows(p,new ItemStack(Items.APPLE)),"Prestige preserves knowledge and receipt");h.succeed();
}
@GameTest public void oldBalancedDietProofSeedsButCorruptRecordsStayUntouched(GameTestHelper h){
var p=player(h);var advancement=p.level().getServer().getAdvancements().get(Identifier.withDefaultNamespace("husbandry/balanced_diet"));p.getAdvancements().award(advancement,"apple");
h.assertTrue(FoodKnowledgeService.knows(p,new ItemStack(Items.APPLE))&&!FoodKnowledgeService.knows(p,new ItemStack(Items.CARROT)),"Only confirmed legacy eating is imported");
p.setAttached(FoodKnowledgeService.RECORD,"{broken");p.setExperienceLevels(12);eat(p,Items.CARROT);
h.assertTrue(!FoodKnowledgeService.buy(p)&&p.experienceLevel==12&&p.getAttached(FoodKnowledgeService.RECORD).equals("{broken"),"Invalid data stays untouched without charging");h.succeed();
}
@GameTest public void gravesUseDeathOrdinalAndPreserveOwnerProfile(GameTestHelper h){
var p=player(h);p.awardStat(Stats.DEATHS,13);var head=GraveService.head(p,List.of(new ItemStack(Items.DIAMOND)));
var marker=head.get(DataComponents.CUSTOM_DATA).copyTag().getCompound(GraveService.MARKER).orElseThrow();
h.assertTrue(marker.getIntOr("death_number",0)==14,"Next death uses native lifetime counter");
var name=(net.minecraft.network.chat.contents.TranslatableContents)head.get(DataComponents.CUSTOM_NAME).getContents();
h.assertTrue(name.getKey().equals("sanctuary.grave.numbered_name")&&name.getArgs()[1].equals("14"),"Name contains only player and death ordinal");
h.assertTrue(head.get(DataComponents.PROFILE).partialProfile().id().equals(p.getGameProfile().id()),"Cosmetic retains real profile");
var empty=head.copy();empty.set(DataComponents.CONTAINER,net.minecraft.world.item.component.ItemContainerContents.EMPTY);
h.assertTrue(empty.get(DataComponents.CUSTOM_NAME).equals(head.get(DataComponents.CUSTOM_NAME)),"Empty head keeps death number");h.succeed();
}
}
@@ -0,0 +1,75 @@
package fr.koka.sanctuary.gametest;
import fr.koka.sanctuary.grave.*;
import fr.koka.sanctuary.inventory.*;
import fr.koka.sanctuary.cosmetics.AccessoryService;
import fr.koka.sanctuary.progression.*;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.core.BlockPos;
import net.minecraft.core.component.DataComponents;
import net.minecraft.world.item.*;
import net.minecraft.world.item.component.*;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.SkullBlockEntity;
import net.minecraft.world.level.gamerules.GameRules;
import net.minecraft.world.inventory.ContainerInput;
import java.util.*;
public final class GraveInventory039GameTests {
@GameTest public void nativeDeathKeepsPhysicalCellsGearAndSize(GameTestHelper h){
var p=GravesFood038GameTests.player(h,6);HotbarRows.select(p,2);
p.getInventory().setItem(0,new ItemStack(Items.DIAMOND,3));p.getInventory().setItem(11,new ItemStack(Items.EMERALD,5));SanctuaryOverflowAccess.of(p).setItem(17,new ItemStack(Items.APPLE,2));
p.setItemSlot(EquipmentSlot.HEAD,new ItemStack(Items.IRON_HELMET));p.setItemSlot(EquipmentSlot.OFFHAND,new ItemStack(Items.SHIELD));AccessoryAccess.of(p).setItem(AccessoryContainer.HEAD,new ItemStack(Items.GLASS));AccessoryAccess.of(p).setItem(AccessoryContainer.CAPE,new ItemStack(AccessoryService.ZERO_CAPE));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,new ItemStack(Items.CHICKEN_SPAWN_EGG));
p.inventoryMenu.setCarried(new ItemStack(Items.GOLD_INGOT,4));p.inventoryMenu.getSlot(1).set(new ItemStack(Items.OAK_LOG,2));
var level=h.getLevel();var at=h.absolutePos(new BlockPos(1,2,1));level.setBlockAndUpdate(at.below(),Blocks.STONE.defaultBlockState());p.setPos(at.getX()+.5,at.getY(),at.getZ()+.5);
boolean keep=level.getGameRules().get(GameRules.KEEP_INVENTORY);level.getGameRules().set(GameRules.KEEP_INVENTORY,false,level.getServer());
try{
p.die(p.damageSources().genericKill());var skull=(SkullBlockEntity)level.getBlockEntity(at);var head=GraveService.head(skull);var info=GraveLayout.read(head);var source=GraveService.container(skull);
h.assertTrue(info.rows()==6&&info.visibleRows()==6&&info.hotbar()==2,"Snapshot retains six rows and selected physical hotbar");
h.assertTrue(source.getItem(18).is(Items.DIAMOND)&&source.getItem(18).getCount()==3&&source.getItem(11).is(Items.EMERALD)&&source.getItem(53).is(Items.APPLE),"Storage stays in physical cells despite shifted hotbar");
h.assertTrue(source.getItem(54).is(Items.IRON_HELMET)&&source.getItem(58).is(Items.GLASS)&&source.getItem(59).is(AccessoryService.ZERO_CAPE)&&source.getItem(60).is(Items.CHICKEN_SPAWN_EGG)&&source.getItem(61).is(Items.SHIELD),"Armour and all accessories keep their dedicated slots");
h.assertTrue(source.getItem(62).is(Items.OAK_LOG)&&source.getItem(66).is(Items.GOLD_INGOT),"Crafting and cursor remain separately recoverable");
source.removeItem(18,1);h.assertTrue(source.getItem(18).getCount()==2&&source.getItem(0).isEmpty()&&source.getItem(11).is(Items.EMERALD),"Partial withdrawal never compacts cells");
var broken=Block.getDrops(skull.getBlockState(),level,at,skull).getFirst();h.assertTrue(GraveLayout.read(broken).equals(info),"Breaking preserves the recorded layout");
}finally{level.getGameRules().set(GameRules.KEEP_INVENTORY,keep,level.getServer());}h.succeed();
}
@GameTest public void withdrawalOnlyAndEquipmentRoutingWithTwoLooters(GameTestHelper h){
var owner=GravesFood038GameTests.player(h,1);var a=GravesFood038GameTests.player(h,1);var b=GravesFood038GameTests.player(h,1);
var items=new ArrayList<ItemStack>(Collections.nCopies(108,ItemStack.EMPTY));items.set(18,new ItemStack(Items.DIAMOND,12));items.set(54,new ItemStack(Items.IRON_HELMET));items.set(58,new ItemStack(Items.GLASS));items.set(59,new ItemStack(AccessoryService.ZERO_CAPE));items.set(60,new ItemStack(Items.CHICKEN_SPAWN_EGG));
var source=new GraveContainer(ItemContainerContents.fromItems(items),()->true,s->{});var info=new GraveLayout.Info(6,6,2);
var ma=new GraveInventoryMenu(10,a.getInventory(),source,ItemStack.EMPTY,()->true,info);var mb=new GraveInventoryMenu(11,b.getInventory(),source,ItemStack.EMPTY,()->true,info);
((SanctuaryExtendedInventoryMenu)(Object)ma).sanctuary$ensureExtendedInventory();((SanctuaryExtendedInventoryMenu)(Object)mb).sanctuary$ensureExtendedInventory();
for(int i=0;i<108;i++)h.assertTrue(!ma.getSlot(i).mayPlace(new ItemStack(Items.STONE)),"Every death cell rejects deposits");
ma.setCarried(new ItemStack(Items.DIRT,3));ma.clicked(0,0,ContainerInput.PICKUP,a);h.assertTrue(source.getItem(0).isEmpty()&&ma.getCarried().getCount()==3,"Real click cannot insert into an empty death cell");ma.setCarried(ItemStack.EMPTY);
ma.quickMoveStack(a,18);mb.quickMoveStack(b,18);h.assertTrue(a.getInventory().contains(new ItemStack(Items.DIAMOND))&&b.getInventory().isEmpty(),"Shared withdrawal cannot duplicate");
ma.quickMoveStack(a,54);ma.quickMoveStack(a,59);ma.quickMoveStack(a,60);ma.quickMoveStack(a,58);
h.assertTrue(a.getItemBySlot(EquipmentSlot.HEAD).is(Items.IRON_HELMET)&&AccessoryAccess.of(a).getItem(AccessoryContainer.CAPE).is(AccessoryService.ZERO_CAPE)&&AccessoryAccess.of(a).getItem(AccessoryContainer.FAMILIAR).is(Items.CHICKEN_SPAWN_EGG),"Shift recovery equips helmet, cape and egg");
h.assertTrue(AccessoryAccess.of(a).getItem(AccessoryContainer.HEAD).isEmpty()&&a.getInventory().contains(new ItemStack(Items.GLASS)),"Cosmetic is stored, never automatically equipped");h.succeed();
}
@GameTest public void legacyLayoutIsNotInventedAndPrestigeOverflowStaysVisible(GameTestHelper h){
var p=GravesFood038GameTests.player(h,1);SanctuaryOverflowAccess.of(p).setItem(17,new ItemStack(Items.EMERALD));var layout=GraveLayout.capture(p);
h.assertTrue(layout.info.rows()==1&&layout.info.visibleRows()==6,"Locked occupied rows remain visible after prestige");
h.assertTrue(GraveLayout.read(GraveService.head(p,List.of(new ItemStack(Items.DIAMOND))))==null,"Old compact heads remain legacy recovery chests");h.succeed();
}
@GameTest public void currentInventoryCraftingAndCloseRemainNative(GameTestHelper h){
var p=GravesFood038GameTests.player(h,1);var source=new GraveContainer(ItemContainerContents.EMPTY,()->true,s->{});
var menu=new GraveInventoryMenu(12,p.getInventory(),source,ItemStack.EMPTY,()->true,new GraveLayout.Info(1,1,0));((SanctuaryExtendedInventoryMenu)(Object)menu).sanctuary$ensureExtendedInventory();p.containerMenu=menu;
menu.getSlot(GraveInventoryMenu.CRAFT+1).setByPlayer(new ItemStack(Items.OAK_LOG,2));
h.assertTrue(menu.getSlot(GraveInventoryMenu.CRAFT).getItem().is(Items.OAK_PLANKS),"Current player uses the native crafting grid and recipes");
menu.clicked(GraveInventoryMenu.CRAFT,0,ContainerInput.QUICK_MOVE,p);
int planks=0;for(int i=0;i<9;i++)if(p.getInventory().getItem(i).is(Items.OAK_PLANKS))planks+=p.getInventory().getItem(i).getCount();
h.assertTrue(planks==8&&source.isEmpty(),"Shift crafting stays in current inventory without depositing into the grave");
h.assertTrue(menu.getSlot(GraveInventoryMenu.CRAFT).isFake(),"Native crafting output stays excluded from inventory drag transfers");
var crafted=net.minecraft.stats.Stats.ITEM_CRAFTED.get(Items.OAK_PLANKS);int before=p.getStats().getValue(crafted);
menu.getSlot(GraveInventoryMenu.CRAFT+1).setByPlayer(new ItemStack(Items.OAK_LOG));menu.clicked(GraveInventoryMenu.CRAFT,0,ContainerInput.PICKUP,p);
h.assertTrue(menu.getCarried().is(Items.OAK_PLANKS)&&menu.getCarried().getCount()==4&&p.getStats().getValue(crafted)==before+4,"Ordinary pickup retains native crafted-item statistics");
p.getInventory().add(menu.getCarried());menu.setCarried(ItemStack.EMPTY);
menu.getSlot(GraveInventoryMenu.CRAFT+1).setByPlayer(new ItemStack(Items.OAK_LOG));menu.clicked(GraveInventoryMenu.CRAFT,4,ContainerInput.SWAP,p);
h.assertTrue(p.getInventory().getItem(4).is(Items.OAK_PLANKS)&&p.getStats().getValue(crafted)==before+8,"Number-key craft retains native result callbacks");
menu.getSlot(GraveInventoryMenu.CRAFT+1).setByPlayer(new ItemStack(Items.DIAMOND,3));menu.removed(p);
h.assertTrue(p.inventoryMenu.getSlot(1).getItem().isEmpty()&&p.getInventory().contains(new ItemStack(Items.DIAMOND)),"Closing returns unfinished crafting inputs");h.succeed();
}
}

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