Compare commits

...
Author SHA1 Message Date
koka e6740ff891 fix(sanctuary): réserver les requêtes aux panneaux dédiés
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 12:59:08 +02:00
koka 054c8e4ad4 Merge pull request 'fix(sanctuary): resynchroniser les capes à la reconnexion (#13)' (#66) from fix/cape-reconnect-sync into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #66
2026-08-29 10:54:41 +00:00
koka 4b11d217c2 fix(sanctuary): resynchroniser les capes à la reconnexion
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 12:43:32 +02:00
koka 3923b8547c Merge pull request 'feat(sanctuary): nommer tous les zombies façon Xbox 360 (#33)' (#64) from feature/zombie-xbox-gamertags into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #64
2026-08-29 10:37:23 +00:00
koka 76915e2f44 feat(sanctuary): nommer les zombies façon Xbox 360
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 12:30:11 +02:00
koka 5aaaf3831c Merge pull request 'fix(onlyfun): retirer les raccourcis de gameplay du Shop (#34)' (#63) from fix/shop-gameplay-bypasses into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #63
2026-08-29 10:27:03 +00:00
koka 37beda56ec fix(onlyfun): retirer les raccourcis du Shop
Verification 26.2 / check (pull_request) Canceled after 0s
2026-08-29 12:13:15 +02:00
koka 5d48917cf4 Merge pull request 'fix(sanctuary): séparer les statuts du Shop (#32)' (#60) from fix/shop-timer-overlap into main
Verification 26.2 / check (push) Canceled after 0s
Reviewed-on: #60
2026-08-29 10:09:04 +00:00
17 changed files with 279 additions and 51 deletions
+8 -2
View File
@@ -63,6 +63,12 @@ reçoivent la bande neutre de 96 rubis jusqu’à leur override explicite.
- blocs techniques ou créateurs directs de récompenses/farms : spawners, trial spawners, vault,
command blocks, structure/test blocks, barrier, light, bedrock et assimilés ;
- équipements uniques en titane et les deux monnaies elles-mêmes.
- tout le namespace `itsalive:*` afin que cultures, machines, recettes, plats et créatures restent
obtenus par le gameplay dIt's Alive ;
- les plats vanilla déjà préparés : aliments cuits, soupes et ragoûts, pain, gâteaux, biscuits et
tartes ;
- `minecraft:enchanted_golden_apple`, qui reste une récompense d'exploration exceptionnelle.
La même politique bloque ces identifiants à la sortie du générateur de loot, même si un futur
résultat aléatoire tente de les sélectionner.
Les identifiants économiques, techniques et de lootbox restent également bloqués à la sortie du
générateur de loot. Les exclusions culinaires sont propres au Shop : elles empêchent lachat et
la sauvegarde dune nouvelle offre sans supprimer un colis déjà payé ni les autres sources de jeu.
@@ -23,6 +23,12 @@ public final class OnlyFunItemPolicy {
"nether_portal", "end_portal", "end_gateway", "frosted_ice", "farmland", "dirt_path",
"dragon_egg", "filled_map", "written_book", "enchanted_book"
);
private static final Set<String> VANILLA_SHOP_GAMEPLAY_BYPASSES = Set.of(
"baked_potato", "beetroot_soup", "bread", "cake",
"cooked_beef", "cooked_chicken", "cooked_cod", "cooked_mutton",
"cooked_porkchop", "cooked_rabbit", "cooked_salmon", "cookie", "dried_kelp",
"enchanted_golden_apple", "mushroom_stew", "pumpkin_pie", "rabbit_stew", "suspicious_stew"
);
private OnlyFunItemPolicy() {}
@@ -35,7 +41,8 @@ public final class OnlyFunItemPolicy {
if (id == null || isCurrencyOre(id)) return true;
String namespace = id.getNamespace().toLowerCase(Locale.ROOT);
String path = normalizedPath(id);
if (namespace.equals("canaplia")) return true;
if (namespace.equals("canaplia") || namespace.equals("itsalive")) return true;
if (namespace.equals("minecraft") && VANILLA_SHOP_GAMEPLAY_BYPASSES.contains(path)) return true;
if (namespace.equals("anotherworld") && isProtectedAnotherWorldItem(path)) return true;
if (TECHNICAL_OR_REWARD_BLOCKS.contains(path) || path.startsWith("infested_")
|| path.contains("command_block")) return true;
@@ -39,7 +39,9 @@ public final class OnlyFunShopCatalog implements ShopCatalogProvider {
}
@Override public boolean isAvailable(MinecraftServer server, String itemId) {
return OnlyFunBossShopGate.isAvailable(server, itemId);
Identifier id = Identifier.tryParse(itemId);
return id != null && !OnlyFunItemPolicy.excludedFromShop(id)
&& OnlyFunBossShopGate.isAvailable(server, itemId);
}
/** Registry-derived, deterministic and duplicate-free view of the enabled CSV rows. */
@@ -2,6 +2,7 @@ package fr.koka99cab.sanctuary26.onlyfun.shop;
import fr.koka99cab.sanctuary26.onlyfun.policy.OnlyFunItemPolicy;
import fr.koka99cab.sanctuary26.sanctuary.shop.ShopOffer;
import fr.koka99cab.sanctuary26.sanctuary.shop.ShopOfferCatalog;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@@ -60,6 +61,26 @@ public final class OnlyFunShopCatalogSmoke {
assertConfigured("anotherworld:golden_egg", 1, 2048, 192);
assertConfigured("iliketomoveit:magic_carpet", 1, 1536, 128);
assertConfigured("iliketomoveit:magic_cloud", 1, 3072, 192);
OnlyFunShopCatalog.initialize();
for (String excludedId : List.of(
"itsalive:apple_pie", "itsalive:basil", "itsalive:culinary_recipe_book",
"itsalive:fermentation_barrel", "itsalive:infected_spawn_egg",
"minecraft:baked_potato", "minecraft:beetroot_soup", "minecraft:bread",
"minecraft:cake", "minecraft:cooked_beef", "minecraft:cooked_chicken",
"minecraft:cooked_cod", "minecraft:cooked_mutton", "minecraft:cooked_porkchop",
"minecraft:cooked_rabbit", "minecraft:cooked_salmon", "minecraft:cookie",
"minecraft:dried_kelp", "minecraft:enchanted_golden_apple", "minecraft:mushroom_stew",
"minecraft:pumpkin_pie", "minecraft:rabbit_stew", "minecraft:suspicious_stew")) {
Identifier id = Identifier.parse(excludedId);
require(OnlyFunItemPolicy.excludedFromShop(id), "Gameplay bypass reached Shop policy: " + id);
require(find(all, excludedId) == null, "Gameplay bypass reached Shop catalogue: " + id);
require(ShopOfferCatalog.find(null, excludedId) == null,
"Server lookup accepted a forbidden saved offer: " + id);
}
for (String rawFoodId : List.of("minecraft:apple", "minecraft:carrot", "minecraft:potato")) {
require(!OnlyFunItemPolicy.excludedFromShop(Identifier.parse(rawFoodId)),
"Raw ingredient was removed with finished food: " + rawFoodId);
}
require(OnlyFunBossShopGate.requiresBoss("iliketomoveit:magic_carpet"),
"Magic Carpet must remain boss-gated");
require(OnlyFunBossShopGate.requiresBoss("iliketomoveit:magic_cloud"),
+10 -5
View File
@@ -60,15 +60,20 @@ Les quêtes possèdent leur propre sauvegarde (`black_market_quests`, version 1)
panneau actif contient un contrat réel : ajouter des panneaux augmente donc le nombre
de quêtes quotidiennes disponibles.
Activation d'un panneau vanilla libre :
Création d'un panneau de quête serveur :
1. Tenir huit gemmes du niveau voulu : émeraude pour facile, rubis pour moyen, saphir
pour difficile.
2. S'accroupir et faire clic droit sur le panneau. Les huit gemmes sont consommées et
le panneau est protégé au nom du joueur qui l'a créé.
1. Fabriquer le panneau dédié du niveau voulu autour d'un panneau vanilla : huit
émeraudes pour facile, huit rubis pour moyen ou huit saphirs pour difficile.
2. Poser ce panneau dédié. Il devient immédiatement un panneau de quête protégé au nom
du joueur qui l'a créé.
3. Le renouvellement a lieu une fois par jour réel, autour de minuit UTC avec un
décalage déterministe compris entre zéro et deux heures.
Un panneau vanilla libre ne peut plus être converti en panneau de quête avec des gemmes.
Le panneau rose `sanctuary:quest_request_sign` reste le seul support des demandes
communautaires. Les anciens panneaux vanilla qui possèdent déjà un contrat enregistré
restent utilisables jusqu'à leur destruction afin de préserver les réservations existantes.
Interactions :
- clic droit : réserver une quête libre, puis livrer ou vérifier sa propre quête ;
+3 -1
View File
@@ -4,7 +4,9 @@
Les noms sont déterministes à partir de l'UUID de la créature ; les répétitions sont autorisées.
- villageois, zombies, moutons, vaches, cochons et poules : prénoms humains comme Patrick, Henri ou Hervé ;
- villageois, moutons, vaches, cochons et poules : prénoms humains comme Patrick, Henri ou Hervé ;
- toute la famille Zombie, vanilla ou modée : gamertags volontairement « Xbox 360 » comme
`xXDarkSasuke69Xx`, `iTzKevin360` ou `xXNoScope420Xx` ;
- Endermen : sonorités extraterrestres rendues avec la police alien vanilla `minecraft:alt` ;
- Creepers : prénoms et mots en S, avec quelques sifflements ;
- squelettes : rythmes et onomatopées musicales ;
+21 -2
View File
@@ -70,6 +70,22 @@ tasks.register("shopHeaderLayoutSmoke", JavaExec) {
mainClass = "fr.koka99cab.sanctuary26.sanctuary.client.ShopDailyHeaderLayoutSmoke"
}
tasks.register("zombieNamesSmoke", JavaExec) {
group = "verification"
description = "Checks deterministic Xbox-360-style names for every vanilla Zombie subtype."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryZombieNamesSmoke"
}
tasks.register("capeJoinSyncSmoke", JavaExec) {
group = "verification"
description = "Checks delayed, bounded cape snapshot retries after player reconnects."
dependsOn tasks.named("testClasses")
classpath = sourceSets.test.runtimeClasspath
mainClass = "fr.koka99cab.sanctuary26.sanctuary.gameplay.CapeJoinSyncQueueSmoke"
}
tasks.register("verifyBlackMarket") {
group = "verification"
description = "Checks the physical sign market, flash Shop, escrow ledger and mailbox delivery."
@@ -105,7 +121,9 @@ tasks.register("verifyBlackMarket") {
|| !service.contains("currencyStacks")
|| !questData.contains('SAVE_KEY = "black_market_quests"')
|| !quests.contains("ServerLivingEntityEvents.AFTER_DEATH")
|| !quests.contains("QuestBoardTier.ACTIVATION_COST")
|| !quests.contains("SanctuaryRegistries.questBoardTier")
|| quests.contains("activationTier(")
|| quests.contains("QuestBoardTier.ACTIVATION_COST")
|| !quests.contains("addTreasury(claim.depositCurrency(), claim.depositAmount())")) {
throw new GradleException("The physical Black Market escrow/delivery contract is incomplete")
}
@@ -220,7 +238,8 @@ tasks.register("verifySanctuary") {
inputs.files(fileTree("tools"))
dependsOn tasks.named("progressionModelSmoke"), tasks.named("serverIdentityModelSmoke"),
tasks.named("shopDeliveryTimingSmoke"), tasks.named("blackMarketModelSmoke"),
tasks.named("questBoardModelSmoke"), tasks.named("shopHeaderLayoutSmoke"),
tasks.named("questBoardModelSmoke"), tasks.named("shopHeaderLayoutSmoke"), tasks.named("zombieNamesSmoke"),
tasks.named("capeJoinSyncSmoke"),
tasks.named("verifyBlackMarket")
doLast {
@@ -62,8 +62,8 @@ public final class SanctuaryMod implements ModInitializer {
SanctuaryProgressionGates.initialize();
SanctuaryPlayerColors.initialize();
ShopService.initialize();
// Community request signs must claim their interactions before the generic
// gem activation callback used by ordinary signs.
// Community request signs must claim their interactions before the other
// physical sign services.
QuestRequestService.initialize();
QuestBoardService.initialize();
BlackMarketService.initialize();
@@ -33,6 +33,15 @@ public final class SanctuaryMobNames {
"Raymond", "Colette", "Norbert", "Mireille", "Didier", "Chantal", "Marcel", "Brigitte",
"Jean-Michel", "Françoise", "Pascal", "Sylvie", "René", "Martine", "Gilbert", "Odette"
};
private static final String[] ZOMBIE_GAMERTAGS = {
"xXDarkSasuke69Xx", "xXSniperDu59Xx", "OoKilleur93oO", "iTzKevin360",
"xXNoScope420Xx", "DarkKiller2009", "LeBGduMinecraft26", "xXShadowNinja76Xx",
"MLGRaptor67", "FazeKevinDu13", "QuickScope360", "NarutoPro59",
"xXGhostRider666Xx", "TheDarkCreeper42", "L3G3NDKILL3R", "xXEnderBoss77Xx",
"SkyZzOfficiel62", "xXPikaKiller25Xx", "DarkAngel666", "iiTzWarrior360ii",
"xXDragonFeu974Xx", "ProGamerDu59", "xXHeadShot360Xx", "BoGoss2008",
"RoiDuNoScope93", "xXDeathNinja13Xx", "TeamDark59", "KevinLeBoss360"
};
private static final String[] ENDER = {
"Xyr-9", "Vaeluun", "Orryx", "Nhal", "Zyphra", "Qor'eth", "Uulma", "Thess-7",
"Ixxa", "Vhoor", "Aen-Null", "Kryss", "Omnû", "Yl'zar", "Nexil", "Rhaum"
@@ -92,6 +101,7 @@ public final class SanctuaryMobNames {
}
static String generate(Entity entity) {
if (usesZombieGamertags(entity.getClass())) return zombieName(entity.getUUID());
String[] family = switch (entity) {
case EnderMan ignored -> ENDER;
case Creeper ignored -> CREEPERS;
@@ -99,7 +109,6 @@ public final class SanctuaryMobNames {
case Wolf ignored -> DOGS;
case Cat ignored -> CATS;
case Villager ignored -> HUMANS;
case Zombie ignored -> HUMANS;
case Cow ignored -> HUMANS;
case Pig ignored -> HUMANS;
case Sheep ignored -> HUMANS;
@@ -112,6 +121,14 @@ public final class SanctuaryMobNames {
return pick(family, entity.getUUID());
}
static boolean usesZombieGamertags(Class<?> entityClass) {
return entityClass != null && Zombie.class.isAssignableFrom(entityClass);
}
static String zombieName(UUID uuid) {
return pick(ZOMBIE_GAMERTAGS, uuid);
}
private static String pick(String[] values, UUID uuid) {
long mixed = uuid.getMostSignificantBits() ^ Long.rotateLeft(uuid.getLeastSignificantBits(), 23);
mixed = (mixed ^ (mixed >>> 30)) * 0xBF58476D1CE4E5B9L;
@@ -0,0 +1,46 @@
package fr.koka99cab.sanctuary26.sanctuary.gameplay;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/** Bounded reconnect retry state for a player's complete cape snapshot. */
final class CapeJoinSyncQueue {
enum Decision { WAIT, SYNC, DROP }
private final int retryTicks;
private final Map<UUID, Integer> remainingTicks = new LinkedHashMap<>();
CapeJoinSyncQueue(int retryTicks) {
if (retryTicks < 1) throw new IllegalArgumentException("Cape retry window must be positive");
this.retryTicks = retryTicks;
}
void schedule(UUID playerId) {
remainingTicks.put(playerId, retryTicks);
}
List<UUID> pendingPlayerIds() {
return List.copyOf(remainingTicks.keySet());
}
Decision poll(UUID playerId, boolean connected, boolean channelReady) {
Integer remaining = remainingTicks.get(playerId);
if (remaining == null) return Decision.DROP;
if (!connected) {
remainingTicks.remove(playerId);
return Decision.DROP;
}
if (channelReady) {
remainingTicks.remove(playerId);
return Decision.SYNC;
}
if (remaining <= 1) {
remainingTicks.remove(playerId);
return Decision.DROP;
}
remainingTicks.put(playerId, remaining - 1);
return Decision.WAIT;
}
}
@@ -1,13 +1,18 @@
package fr.koka99cab.sanctuary26.sanctuary.gameplay;
import fr.koka99cab.sanctuary26.sanctuary.network.CapeSyncPayload;
import java.util.UUID;
import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.fabricmc.fabric.api.networking.v1.PlayerLookup;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
/** Server authority and multiplayer distribution for equipped cape visuals. */
public final class SanctuaryCapeService {
private static final int JOIN_SYNC_RETRY_TICKS = 100;
private static final CapeJoinSyncQueue JOIN_SYNCS = new CapeJoinSyncQueue(JOIN_SYNC_RETRY_TICKS);
private static boolean initialized;
private SanctuaryCapeService() {
@@ -17,6 +22,7 @@ public final class SanctuaryCapeService {
if (initialized) return;
initialized = true;
ServerPlayerEvents.AFTER_RESPAWN.register((oldPlayer, newPlayer, alive) -> sync(newPlayer));
ServerTickEvents.END_SERVER_TICK.register(SanctuaryCapeService::tickJoinSyncs);
}
public static void join(ServerPlayer joined) {
@@ -24,6 +30,10 @@ public final class SanctuaryCapeService {
send(joined, online);
}
sync(joined);
// JOIN can run while the reconnecting client's play channel or player list is
// still settling. Replay the complete snapshot on a later server tick so a
// missed initial packet never requires removing and re-equipping the cape.
JOIN_SYNCS.schedule(joined.getUUID());
}
public static void sync(ServerPlayer owner) {
@@ -32,6 +42,24 @@ public final class SanctuaryCapeService {
}
}
private static void tickJoinSyncs(MinecraftServer server) {
for (UUID playerId : JOIN_SYNCS.pendingPlayerIds()) {
ServerPlayer receiver = server.getPlayerList().getPlayer(playerId);
CapeJoinSyncQueue.Decision decision = JOIN_SYNCS.poll(playerId, receiver != null,
receiver != null && ServerPlayNetworking.canSend(receiver, CapeSyncPayload.ID));
if (decision == CapeJoinSyncQueue.Decision.SYNC) sendSnapshot(receiver);
}
}
private static void sendSnapshot(ServerPlayer receiver) {
boolean includedReceiver = false;
for (ServerPlayer owner : PlayerLookup.all(receiver.level().getServer())) {
send(receiver, owner);
includedReceiver |= owner.getUUID().equals(receiver.getUUID());
}
if (!includedReceiver) send(receiver, receiver);
}
private static void send(ServerPlayer receiver, ServerPlayer owner) {
if (!ServerPlayNetworking.canSend(receiver, CapeSyncPayload.ID)) return;
String capeId = SanctuaryCapes.itemId(SanctuaryInventoryCapacity.equippedCape(owner));
@@ -99,6 +99,13 @@ public final class SanctuaryRegistries {
return java.util.List.of(EMERALD_QUEST_SIGN, RUBY_QUEST_SIGN, SAPPHIRE_QUEST_SIGN);
}
public static QuestBoardTier questBoardTier(Block block) {
for (QuestSignSet signs : questSigns()) {
if (block == signs.standing() || block == signs.wall()) return signs.tier();
}
return null;
}
public static boolean isQuestRequestSign(Block block) {
return block == QUEST_REQUEST_SIGN.standing() || block == QUEST_REQUEST_SIGN.wall();
}
@@ -1,6 +1,7 @@
package fr.koka99cab.sanctuary26.sanctuary.shop;
import fr.koka99cab.sanctuary26.sanctuary.api.inventory.SanctuaryPlayerInventoryView;
import fr.koka99cab.sanctuary26.sanctuary.registry.SanctuaryRegistries;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashSet;
@@ -50,14 +51,9 @@ public final class QuestBoardService {
UseBlockCallback.EVENT.register((player, level, hand, hit) -> {
if (hand != InteractionHand.MAIN_HAND || !(level.getBlockEntity(hit.getBlockPos()) instanceof SignBlockEntity sign))
return InteractionResult.PASS;
QuestBoardTier heldTier = activationTier(player.getMainHandItem());
if (level.isClientSide()) {
return player.isSecondaryUseActive() && heldTier != null
&& player.getMainHandItem().getCount() >= QuestBoardTier.ACTIVATION_COST
? InteractionResult.SUCCESS : InteractionResult.PASS;
}
if (level.isClientSide()) return InteractionResult.PASS;
return player instanceof ServerPlayer serverPlayer
? interact(serverPlayer, (ServerLevel) level, hit.getBlockPos(), sign, heldTier)
? interact(serverPlayer, (ServerLevel) level, hit.getBlockPos(), sign)
: InteractionResult.PASS;
});
PlayerBlockBreakEvents.BEFORE.register((level, player, pos, state, blockEntity) -> {
@@ -81,34 +77,19 @@ public final class QuestBoardService {
}
private static InteractionResult interact(ServerPlayer player, ServerLevel level, BlockPos pos,
SignBlockEntity sign, QuestBoardTier heldTier) {
SignBlockEntity sign) {
QuestBoardData data = QuestBoardData.get(level.getServer());
QuestBoardData.Board board = data.boardAt(dimension(level), pos.asLong()).orElse(null);
if (board != null) {
handleBoard(player, level, sign, data, board);
return InteractionResult.SUCCESS_SERVER;
}
if (!player.isSecondaryUseActive() || heldTier == null
|| player.getMainHandItem().getCount() < QuestBoardTier.ACTIVATION_COST) return InteractionResult.PASS;
if (BlackMarketData.get(level.getServer()).listingAt(dimension(level), pos.asLong()).isPresent()) {
message(player, "occupied"); return InteractionResult.CONSUME;
}
if (BlackMarketData.get(level.getServer()).isBanned(player.getUUID())) {
player.sendSystemMessage(Component.translatable("message.sanctuary.black_market.banned"));
return InteractionResult.CONSUME;
}
ItemStack held = player.getMainHandItem();
held.shrink(QuestBoardTier.ACTIVATION_COST);
player.getInventory().setChanged();
if (!createBoard(player, level, pos, sign, heldTier)) {
held.grow(QuestBoardTier.ACTIVATION_COST); message(player, "occupied"); return InteractionResult.CONSUME;
}
if (board == null) return InteractionResult.PASS;
handleBoard(player, level, sign, data, board);
return InteractionResult.SUCCESS_SERVER;
}
/** Activates one of the three crafted quest signs without charging its material cost twice. */
public static void activatePlacedSign(ServerPlayer player, ServerLevel level, BlockPos pos,
SignBlockEntity sign, QuestBoardTier tier) {
QuestBoardTier placedTier = SanctuaryRegistries.questBoardTier(level.getBlockState(pos).getBlock());
if (!canActivateDedicatedSign(placedTier, tier)) return;
if (BlackMarketData.get(level.getServer()).isBanned(player.getUUID())) {
player.sendSystemMessage(Component.translatable("message.sanctuary.black_market.banned"));
return;
@@ -116,6 +97,10 @@ public final class QuestBoardService {
if (!createBoard(player, level, pos, sign, tier)) message(player, "occupied");
}
static boolean canActivateDedicatedSign(QuestBoardTier placedTier, QuestBoardTier requestedTier) {
return placedTier != null && placedTier == requestedTier;
}
private static boolean createBoard(ServerPlayer player, ServerLevel level, BlockPos pos,
SignBlockEntity sign, QuestBoardTier tier) {
QuestBoardData data = QuestBoardData.get(level.getServer());
@@ -466,13 +451,6 @@ public final class QuestBoardService {
if (item != null) player.getInventory().placeItemBackInInventory(new ItemStack(item, deposit.amount));
}
private static QuestBoardTier activationTier(ItemStack stack) {
if (stack.isEmpty()) return null;
Identifier id = BuiltInRegistries.ITEM.getKey(stack.getItem());
for (QuestBoardTier tier : QuestBoardTier.values()) if (tier.currencyItem().equals(id)) return tier;
return null;
}
private static ServerPlayer killer(LivingEntity entity, DamageSource source) {
Entity attacker = source.getEntity();
if (attacker instanceof ServerPlayer player) return player;
@@ -5,13 +5,12 @@ import java.util.Locale;
import net.minecraft.ChatFormatting;
import net.minecraft.resources.Identifier;
/** Physical quest-board tiers. The activation material is also the default refundable bond. */
/** Physical quest-board tiers. Each tier currency is also its default refundable bond. */
public enum QuestBoardTier {
EMERALD("emerald", Identifier.withDefaultNamespace("emerald"), 1, Duration.ofMinutes(15), ChatFormatting.GREEN),
RUBY("ruby", Identifier.fromNamespaceAndPath("anotherworld", "ruby"), 1, Duration.ofMinutes(45), ChatFormatting.RED),
SAPPHIRE("sapphire", Identifier.fromNamespaceAndPath("anotherworld", "sapphire"), 1, Duration.ofMinutes(90), ChatFormatting.AQUA);
public static final int ACTIVATION_COST = 8;
private final String id;
private final Identifier currencyItem;
private final int deposit;
@@ -0,0 +1,44 @@
package fr.koka99cab.sanctuary26.sanctuary.client;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import net.minecraft.world.entity.monster.Creeper;
import net.minecraft.world.entity.monster.zombie.Drowned;
import net.minecraft.world.entity.monster.zombie.Husk;
import net.minecraft.world.entity.monster.zombie.Zombie;
import net.minecraft.world.entity.monster.zombie.ZombieVillager;
import net.minecraft.world.entity.monster.zombie.ZombifiedPiglin;
/** Executable coverage for deterministic Xbox-360-style names across the complete Zombie family. */
public final class SanctuaryZombieNamesSmoke {
private SanctuaryZombieNamesSmoke() {
}
public static void main(String[] args) {
for (Class<?> zombieClass : new Class<?>[] {
Zombie.class, Husk.class, Drowned.class, ZombieVillager.class, ZombifiedPiglin.class
}) {
require(SanctuaryMobNames.usesZombieGamertags(zombieClass),
"Zombie subtype missed gamertag family: " + zombieClass.getSimpleName());
}
require(!SanctuaryMobNames.usesZombieGamertags(Creeper.class),
"Non-zombie monster entered gamertag family");
UUID stableId = UUID.fromString("12345678-1234-5678-9abc-def012345678");
require(SanctuaryMobNames.zombieName(stableId).equals(SanctuaryMobNames.zombieName(stableId)),
"Zombie gamertag is not deterministic");
Set<String> observed = new HashSet<>();
for (int index = 0; index < 4_096; index++) {
String name = SanctuaryMobNames.zombieName(new UUID(index * 31L, ~((long) index * 97L)));
require(name.matches("[A-Za-z0-9_]+"), "Gamertag contains an invalid character: " + name);
require(name.chars().anyMatch(Character::isDigit), "Gamertag has no Xbox-style digits: " + name);
observed.add(name);
}
require(observed.size() >= 24, "Zombie gamertag pool is too small: " + observed.size());
}
private static void require(boolean condition, String message) {
if (!condition) throw new IllegalStateException(message);
}
}
@@ -0,0 +1,41 @@
package fr.koka99cab.sanctuary26.sanctuary.gameplay;
import java.util.UUID;
/** Executable coverage for delayed and bounded cape synchronization after reconnect. */
public final class CapeJoinSyncQueueSmoke {
private CapeJoinSyncQueueSmoke() {
}
public static void main(String[] args) {
CapeJoinSyncQueue queue = new CapeJoinSyncQueue(3);
UUID playerId = UUID.fromString("12345678-1234-5678-9abc-def012345678");
queue.schedule(playerId);
require(queue.pendingPlayerIds().size() == 1, "Reconnect did not schedule one cape snapshot");
require(queue.poll(playerId, true, false) == CapeJoinSyncQueue.Decision.WAIT,
"Unavailable play channel was not retried");
queue.schedule(playerId);
require(queue.pendingPlayerIds().size() == 1, "Repeated join duplicated the cape retry");
require(queue.poll(playerId, true, true) == CapeJoinSyncQueue.Decision.SYNC,
"Ready play channel did not release the cape snapshot");
require(queue.pendingPlayerIds().isEmpty(), "Successful cape sync stayed pending");
queue.schedule(playerId);
require(queue.poll(playerId, false, false) == CapeJoinSyncQueue.Decision.DROP,
"Disconnected player retained a cape retry");
queue.schedule(playerId);
require(queue.poll(playerId, true, false) == CapeJoinSyncQueue.Decision.WAIT,
"Cape retry window ended one tick too early");
require(queue.poll(playerId, true, false) == CapeJoinSyncQueue.Decision.WAIT,
"Cape retry window ended two ticks too early");
require(queue.poll(playerId, true, false) == CapeJoinSyncQueue.Decision.DROP,
"Cape retry escaped its bounded window");
require(queue.pendingPlayerIds().isEmpty(), "Expired cape retry stayed pending");
}
private static void require(boolean condition, String message) {
if (!condition) throw new IllegalStateException(message);
}
}
@@ -29,6 +29,12 @@ public final class QuestBoardModelSmoke {
require(reserved.withObjective(objective.advance(20)).objective().complete(), "Kill progress did not complete");
require(QuestBoardTier.SAPPHIRE.reservationMillis() > QuestBoardTier.RUBY.reservationMillis(),
"Difficulty deadline order is invalid");
require(!QuestBoardService.canActivateDedicatedSign(null, QuestBoardTier.EMERALD),
"A vanilla sign can still become a quest board");
require(!QuestBoardService.canActivateDedicatedSign(QuestBoardTier.EMERALD, QuestBoardTier.RUBY),
"A mismatched dedicated sign accepted the wrong quest tier");
require(QuestBoardService.canActivateDedicatedSign(QuestBoardTier.SAPPHIRE, QuestBoardTier.SAPPHIRE),
"A dedicated quest sign rejected its own tier");
long now = 1_800_000_000_000L;
long refresh = QuestBoardGenerator.nextRefreshAt("board", now);
require(refresh > now && refresh - now < 28L * 60L * 60L * 1_000L,