diff --git a/CHANGELOG.md b/CHANGELOG.md index 82db418..428e72e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve ## Non distribué -Modules modifiés : **It's Alive `0.0.0-alpha.36`**, **Sanctuary `0.0.0-alpha.119`** et **Ambiance `0.0.0-alpha.15`**. Le pack distribué reste en `26.2.0-alpha.210` jusqu’à la prochaine release. +Modules modifiés : **It's Alive `0.0.0-alpha.36`**, **Sanctuary `0.0.0-alpha.119`**, **Ambiance `0.0.0-alpha.15`** et **Only Fun `0.0.0-alpha.22`**. Le pack distribué reste en `26.2.0-alpha.210` jusqu’à la prochaine release. - remplace les pages noires du Livre interdit par l’affichage clair du livre vanilla ; - conserve les seize entrées, les découvertes personnelles, le partage sur pupitre, les IDs, les données v3 et le protocole réseau 4. @@ -17,6 +17,9 @@ Modules modifiés : **It's Alive `0.0.0-alpha.36`**, **Sanctuary `0.0.0-alpha.11 - ajoute le disque blanc, à fabriquer puis renommer avec une URL YouTube HTTPS avant de l’insérer dans un jukebox ; - valide strictement l’hôte et l’identifiant vidéo côté serveur, ne transmet qu’un identifiant borné de onze caractères et limite chaque client à quatre lectures de trente minutes maximum ; - diffuse le son en mono positionnel dans le canal des disques, sans nouvelle donnée persistante ; Ambiance conserve ses données v2 et passe son protocole réseau de 5 à 6. +- importe le cœur GPL historique d’Abjection dans Only Fun, sans onzième JAR : `/piss`, `/vomit`, `/shit` et `/smoke` retrouvent leurs durées, jets, impacts et messages ; +- restaure `abjection:shit_ball` ainsi que le bloc boueux `abjection:shit_cube`, sa recette, son butin et ses ressources originales en FR/EN/RU ; +- expose une API serveur unique pour les commandes et le futur raccourci, nettoie les actions temporaires à la mort, en spectateur ou à la déconnexion, et conserve les données v2 ainsi que le protocole réseau 2 d’Only Fun. ## `26.2.0-alpha.210` diff --git a/build.gradle b/build.gradle index b1d9558..683ddbe 100644 --- a/build.gradle +++ b/build.gradle @@ -3439,7 +3439,7 @@ tasks.register("verifyProgressivePauseMenuRelease") { } if (rootProject.pack_version != "26.2.0-alpha.210" || rootProject.sanctuary_version != "0.0.0-alpha.119" - || rootProject.onlyfun_version != "0.0.0-alpha.21" + || rootProject.onlyfun_version != "0.0.0-alpha.22" || release.pack_version != rootProject.pack_version || index.versionId != rootProject.pack_version || migration.source?.modules != [sanctuary: "0.0.0-alpha.108", onlyfun: "0.0.0-alpha.17"] || migration.target?.modules != [sanctuary: "0.0.0-alpha.109", onlyfun: "0.0.0-alpha.17"] @@ -3636,9 +3636,17 @@ project(":onlyfun") { mainClass = "fr.koka99cab.sanctuary26.onlyfun.loot.OnlyFunGeneratorSmoke" } + tasks.register("abjectionActionContractSmoke", JavaExec) { + group = "verification" + description = "Checks the bounded historical Abjection action contract." + dependsOn tasks.named("testClasses") + classpath = sourceSets.test.runtimeClasspath + mainClass = "fr.koka99cab.sanctuary26.onlyfun.api.abjection.AbjectionActionContractSmoke" + } + tasks.register("verifyOnlyFun") { group = "verification" - description = "Checks Only Fun alpha.19 lootboxes, CSV Shop catalog and anchored Cobble Dungeons." + description = "Checks Only Fun alpha.21, including the historical Abjection server actions." inputs.files(fileTree("src/main/java")) inputs.files(fileTree("src/main/resources")) inputs.file(file("art/shop/shop_catalog_editable.csv")) @@ -3721,9 +3729,45 @@ project(":onlyfun") { def shopCsv = file("art/shop/shop_catalog_editable.csv") def shopCsvText = shopCsv.getText("UTF-8") def shopCsvLines = shopCsv.readLines("UTF-8") + def abjectionLanguages = ["fr_fr", "en_us", "ru_ru"].collectEntries { locale -> + [(locale): new JsonSlurper().parse(file( + "src/main/resources/assets/abjection/lang/${locale}.json"))] + } + def shitCubeTexture = javax.imageio.ImageIO.read(file( + "src/main/resources/assets/abjection/textures/block/shit_cube.png")) + def shitBallTexture = javax.imageio.ImageIO.read(file( + "src/main/resources/assets/abjection/textures/item/shit_ball.png")) + def shitCubeRecipe = new JsonSlurper().parse(file( + "src/main/resources/data/abjection/recipe/shit_cube.json")) if (manifest.custom.sanctuary26.data_version != 2 - || project.version.toString() != "0.0.0-alpha.21" + || project.version.toString() != "0.0.0-alpha.22" || manifest.depends?.sanctuary != "*" + || manifest.provides != ["abjection"] + || manifest.custom.sanctuary26.network_protocol != 2 + || !javaText.contains('NAMESPACE = "abjection"') + || !javaText.contains('public static StartResult start(ServerPlayer player, Action action)') + || !javaText.contains('PISS(15 * 20, "piss")') + || !javaText.contains('VOMIT(8 * 20, "vomit")') + || !javaText.contains('SHIT(6 * 20, "shit")') + || !javaText.contains('SMOKE(10 * 20, "smoke")') + || !javaText.contains('ServerPlayConnectionEvents.DISCONNECT') + || !javaText.contains('action == Action.SHIT && !restarted') + || !javaText.contains('new ItemStack(AbjectionRegistries.SHIT_BALL)') + || !javaText.contains('BlockBehaviour.Properties.ofFullCopy(Blocks.MUD)') + || shitCubeTexture == null || shitCubeTexture.width != 16 || shitCubeTexture.height != 16 + || shitBallTexture == null || shitBallTexture.width != 16 || shitBallTexture.height != 16 + || shitCubeRecipe.key?.get("#") != "abjection:shit_ball" + || shitCubeRecipe.result?.id != "abjection:shit_cube" + || !file("src/main/resources/data/abjection/loot_table/blocks/shit_cube.json").isFile() + || !file("src/main/resources/data/minecraft/tags/block/mineable/shovel.json").text + .contains('"abjection:shit_cube"') + || abjectionLanguages.any { locale, translations -> + !["item.abjection.shit_ball", "block.abjection.shit_cube", + "command.abjection.piss.started", "command.abjection.vomit.started", + "command.abjection.shit.started", "command.abjection.smoke.started"].every { + translations.containsKey(it) + } + } || !javaText.contains("ANOMALY_DENOMINATOR = 256") || !javaText.contains("PITY_START = 24") || !javaText.contains("class LootboxItem") @@ -3870,6 +3914,7 @@ project(":onlyfun") { } tasks.named("check") { + dependsOn tasks.named("abjectionActionContractSmoke") dependsOn tasks.named("onlyFunGeneratorSmoke") dependsOn tasks.named("verifyOnlyFun") } @@ -4136,7 +4181,7 @@ tasks.register("verifyShopPricingLayoutRelease") { def release = new JsonSlurper().parse(file("pack/release.json")) def index = new JsonSlurper().parse(file("pack/prism/modrinth.index.json")) if (rootProject.pack_version != "26.2.0-alpha.210" - || rootProject.onlyfun_version != "0.0.0-alpha.21" + || rootProject.onlyfun_version != "0.0.0-alpha.22" || rootProject.sanctuary_version != "0.0.0-alpha.119" || release.pack_version != rootProject.pack_version || index.versionId != rootProject.pack_version || migration.source?.pack_version != "26.2.0-alpha.192" @@ -4230,7 +4275,7 @@ tasks.register("verifyShopCsvBossGatesRelease") { def catalog = file("onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/shop/OnlyFunShopCatalog.java").text def offerCatalog = file("sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopOfferCatalog.java").text if (rootProject.pack_version != "26.2.0-alpha.210" - || rootProject.onlyfun_version != "0.0.0-alpha.21" + || rootProject.onlyfun_version != "0.0.0-alpha.22" || rootProject.sanctuary_version != "0.0.0-alpha.119" || release.pack_version != rootProject.pack_version || index.versionId != rootProject.pack_version || migration.source?.pack_version != "26.2.0-alpha.194" @@ -5499,7 +5544,7 @@ tasks.register("verifyCanapliaAlpha209Release") { if (rootProject.pack_version != "26.2.0-alpha.210" || rootProject.ambiance_version != "0.0.0-alpha.15" || rootProject.itsalive_version != "0.0.0-alpha.36" - || rootProject.onlyfun_version != "0.0.0-alpha.21" + || rootProject.onlyfun_version != "0.0.0-alpha.22" || migration.source?.pack_version != "26.2.0-alpha.207" || migration.source?.modules != expectedSource || migration.target?.pack_version != "26.2.0-alpha.208" @@ -5607,7 +5652,7 @@ tasks.register("verifyAlpha210Release") { } if (rootProject.pack_version != "26.2.0-alpha.210" || rootProject.sanctuary_version != "0.0.0-alpha.119" - || rootProject.onlyfun_version != expectedTarget.onlyfun + || rootProject.onlyfun_version != "0.0.0-alpha.22" || rootProject.iliketomoveit_version != expectedTarget.iliketomoveit || rootProject.itsalive_version != "0.0.0-alpha.36" || migration.source?.pack_version != "26.2.0-alpha.209" diff --git a/gradle.properties b/gradle.properties index 8a030e2..8711127 100644 --- a/gradle.properties +++ b/gradle.properties @@ -26,7 +26,7 @@ masterkey_version=0.0.0-alpha.8 masterkey_lifecycle=active iliketomoveit_version=0.0.0-alpha.15 iliketomoveit_lifecycle=active -onlyfun_version=0.0.0-alpha.21 +onlyfun_version=0.0.0-alpha.22 onlyfun_lifecycle=active sanctuary_version=0.0.0-alpha.119 sanctuary_lifecycle=active diff --git a/onlyfun/ABJECTION.md b/onlyfun/ABJECTION.md new file mode 100644 index 0000000..c5656c2 --- /dev/null +++ b/onlyfun/ABJECTION.md @@ -0,0 +1,22 @@ +# Abjection dans Only Fun + +Le runtime historique GPL-3.0-or-later de `Fun/abjection` est intégré au JAR **Only Fun**. Le +namespace `abjection` et les IDs `abjection:shit_ball` et `abjection:shit_cube` sont conservés ; le +manifeste Only Fun fournit l’alias Fabric `abjection`, sans créer un onzième module. + +## ABJ-01 — actions serveur + +- `/piss` émet son jet pendant 15 secondes et peut activer un bouton touché ; +- `/vomit` émet son jet pendant 8 secondes ; +- `/shit` émet son jet pendant 6 secondes et crée régulièrement la même + `abjection:shit_ball` que les lots suivants ; +- `/smoke` émet de la fumée pendant 10 secondes. + +`AbjectionActions.start` est l’unique API publique de déclenchement. Chaque nouvelle demande +redémarre seulement l’action correspondante, sans dupliquer la boule initiale de `/shit`. L’état +reste en mémoire serveur, n’est jamais sauvegardé et est supprimé à la mort, au passage spectateur +ou à la déconnexion. + +Le bloc `abjection:shit_cube` reprend les propriétés de la boue, l’apparence 16×16, la recette +neuf boules, le butin et l’outil pelle historiques. Les textes sont disponibles en français, +anglais et russe. Only Fun reste en données v2 et protocole réseau 2 pour ABJ-01. diff --git a/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/OnlyFunMod.java b/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/OnlyFunMod.java index f8b38b4..5255e27 100644 --- a/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/OnlyFunMod.java +++ b/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/OnlyFunMod.java @@ -1,6 +1,9 @@ package fr.koka99cab.sanctuary26.onlyfun; import fr.koka99cab.sanctuary26.onlyfun.chickencarry.ChickenCarryController; +import fr.koka99cab.sanctuary26.onlyfun.abjection.AbjectionRegistries; +import fr.koka99cab.sanctuary26.onlyfun.api.abjection.AbjectionActions; +import fr.koka99cab.sanctuary26.onlyfun.command.AbjectionCommands; import fr.koka99cab.sanctuary26.onlyfun.network.OnlyFunNetworking; import fr.koka99cab.sanctuary26.onlyfun.registry.OnlyFunRegistries; import fr.koka99cab.sanctuary26.onlyfun.registry.OnlyFunCreativeTab; @@ -31,6 +34,7 @@ public final class OnlyFunMod implements ModInitializer { @Override public void onInitialize() { + AbjectionRegistries.initialize(); OnlyFunRegistries.initialize(); OnlyFunCreativeTab.initialize(); OnlyFunGenerator.initialize(); @@ -42,6 +46,8 @@ public final class OnlyFunMod implements ModInitializer { OnlyFunNetworking.initialize(); ChickenCarryController.register(); OnlyFunCommands.register(); + AbjectionActions.initialize(); + AbjectionCommands.register(); LOGGER.info("[{}] Loot generator, natural Lucky Blocks, mob lootboxes and chicken rules ready (data v{}, network v{}).", DISPLAY_NAME, CURRENT_DATA_VERSION, OnlyFunNetworking.PROTOCOL_VERSION); } diff --git a/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/abjection/AbjectionRegistries.java b/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/abjection/AbjectionRegistries.java new file mode 100644 index 0000000..63d4e2c --- /dev/null +++ b/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/abjection/AbjectionRegistries.java @@ -0,0 +1,52 @@ +package fr.koka99cab.sanctuary26.onlyfun.abjection; + +import java.util.function.Function; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.Item; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockBehaviour; + +/** Historical Abjection registry IDs, owned and distributed by Only Fun. */ +public final class AbjectionRegistries { + public static final String NAMESPACE = "abjection"; + public static final Block SHIT_CUBE = registerBlock("shit_cube", Block::new, + BlockBehaviour.Properties.ofFullCopy(Blocks.MUD)); + public static final Item SHIT_BALL = registerItem("shit_ball", + properties -> new Item(properties.stacksTo(16).useItemDescriptionPrefix())); + public static final Item SHIT_CUBE_ITEM = registerBlockItem("shit_cube", SHIT_CUBE); + + private AbjectionRegistries() { + } + + public static Identifier id(String path) { + return Identifier.fromNamespaceAndPath(NAMESPACE, path); + } + + public static void initialize() { + // Class initialization owns registration; this method makes the startup order explicit. + } + + private static Item registerItem(String path, Function factory) { + ResourceKey key = ResourceKey.create(Registries.ITEM, id(path)); + return Registry.register(BuiltInRegistries.ITEM, key, + factory.apply(new Item.Properties().setId(key))); + } + + private static Block registerBlock(String path, Function factory, + BlockBehaviour.Properties properties) { + ResourceKey key = ResourceKey.create(Registries.BLOCK, id(path)); + return Registry.register(BuiltInRegistries.BLOCK, key, factory.apply(properties.setId(key))); + } + + private static Item registerBlockItem(String path, Block block) { + ResourceKey key = ResourceKey.create(Registries.ITEM, id(path)); + return Registry.register(BuiltInRegistries.ITEM, key, + new BlockItem(block, new Item.Properties().setId(key).useBlockDescriptionPrefix())); + } +} diff --git a/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/api/abjection/AbjectionActions.java b/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/api/abjection/AbjectionActions.java new file mode 100644 index 0000000..65b4752 --- /dev/null +++ b/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/api/abjection/AbjectionActions.java @@ -0,0 +1,383 @@ +package fr.koka99cab.sanctuary26.onlyfun.api.abjection; + +import fr.koka99cab.sanctuary26.onlyfun.abjection.AbjectionRegistries; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.UUID; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.minecraft.core.BlockPos; +import net.minecraft.core.particles.ColorParticleOption; +import net.minecraft.core.particles.DustParticleOptions; +import net.minecraft.core.particles.ItemParticleOption; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.HumanoidArm; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.block.ButtonBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraft.world.phys.Vec3; + +/** Public server API shared by Abjection commands and bounded client requests. */ +public final class AbjectionActions { + public enum Action { + PISS(15 * 20, "piss"), + VOMIT(8 * 20, "vomit"), + SHIT(6 * 20, "shit"), + SMOKE(10 * 20, "smoke"); + + private final int durationTicks; + private final String id; + + Action(int durationTicks, String id) { + this.durationTicks = durationTicks; + this.id = id; + } + + public int durationTicks() { + return durationTicks; + } + + public String id() { + return id; + } + } + + public enum StartResult { + STARTED, + RESTARTED, + REJECTED + } + + private static final Map> ACTIVE = new HashMap<>(); + private static final int STREAM_SEGMENTS = 64; + private static final double STREAM_STEP_LENGTH = 0.05D; + private static final double STREAM_STEP_SCALE = STREAM_STEP_LENGTH / 0.28D; + private static final DustParticleOptions PISS_BRIGHT = new DustParticleOptions(0xEDD62E, 0.24F); + private static final DustParticleOptions PISS_GOLD = new DustParticleOptions(0xD8B825, 0.2F); + private static final DustParticleOptions PISS_AMBER = new DustParticleOptions(0xC6941A, 0.19F); + private static final DustParticleOptions PISS_DEEP_GOLD = new DustParticleOptions(0xB88714, 0.17F); + private static final DustParticleOptions VOMIT_GREEN = new DustParticleOptions(0x88A436, 0.24F); + private static final DustParticleOptions VOMIT_YELLOW = new DustParticleOptions(0xB6B342, 0.2F); + private static final DustParticleOptions VOMIT_ORANGE = new DustParticleOptions(0xA96A32, 0.19F); + private static final DustParticleOptions VOMIT_BROWN = new DustParticleOptions(0x6E5828, 0.17F); + private static final DustParticleOptions SHIT_SPLAT = new DustParticleOptions(0x4A2B14, 1.4F); + private static final DustParticleOptions SHIT_MUD = new DustParticleOptions(0x6A4521, 0.22F); + private static final ColorParticleOption PISS_BRIGHT_MIST = + ColorParticleOption.create(ParticleTypes.ENTITY_EFFECT, 0xF0E35A); + private static final ColorParticleOption PISS_GOLD_MIST = + ColorParticleOption.create(ParticleTypes.ENTITY_EFFECT, 0xD8C43A); + private static final ColorParticleOption PISS_AMBER_MIST = + ColorParticleOption.create(ParticleTypes.ENTITY_EFFECT, 0xCDA128); + private static final ColorParticleOption VOMIT_GREEN_MIST = + ColorParticleOption.create(ParticleTypes.ENTITY_EFFECT, 0x95B33F); + private static final ColorParticleOption VOMIT_YELLOW_MIST = + ColorParticleOption.create(ParticleTypes.ENTITY_EFFECT, 0xC4C35B); + private static final ColorParticleOption VOMIT_ORANGE_MIST = + ColorParticleOption.create(ParticleTypes.ENTITY_EFFECT, 0xB97A3D); + private static final ItemParticleOption VOMIT_FLESH = + new ItemParticleOption(ParticleTypes.ITEM, Items.ROTTEN_FLESH); + private static final ItemParticleOption VOMIT_EYE = + new ItemParticleOption(ParticleTypes.ITEM, Items.SPIDER_EYE); + private static final ItemParticleOption VOMIT_POTATO = + new ItemParticleOption(ParticleTypes.ITEM, Items.POISONOUS_POTATO); + private static boolean initialized; + + private AbjectionActions() { + } + + public static void initialize() { + if (initialized) return; + initialized = true; + ServerTickEvents.END_SERVER_TICK.register(AbjectionActions::tick); + ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> clear(handler.player.getUUID())); + } + + public static StartResult start(ServerPlayer player, Action action) { + if (player == null || action == null || !player.isAlive() || player.isSpectator() + || player.level().getServer() == null) return StartResult.REJECTED; + boolean restarted = restart(player.getUUID(), action); + if (action == Action.SHIT && !restarted) spawnShitDrop(player.level(), player); + return restarted ? StartResult.RESTARTED : StartResult.STARTED; + } + + static boolean restart(UUID playerId, Action action) { + EnumMap actions = ACTIVE.computeIfAbsent(playerId, ignored -> + new EnumMap<>(Action.class)); + return actions.put(action, action.durationTicks()) != null; + } + + public static void clear(UUID playerId) { + ACTIVE.remove(playerId); + } + + static int remaining(UUID playerId, Action action) { + return ACTIVE.getOrDefault(playerId, new EnumMap<>(Action.class)).getOrDefault(action, 0); + } + + private static void tick(MinecraftServer server) { + Iterator>> players = ACTIVE.entrySet().iterator(); + while (players.hasNext()) { + Map.Entry> entry = players.next(); + ServerPlayer player = server.getPlayerList().getPlayer(entry.getKey()); + if (player == null || !player.isAlive() || player.isSpectator()) { + players.remove(); + continue; + } + Iterator> actions = entry.getValue().entrySet().iterator(); + while (actions.hasNext()) { + Map.Entry action = actions.next(); + emit(player, action.getKey(), action.getValue()); + int remaining = action.getValue() - 1; + if (remaining <= 0) actions.remove(); + else action.setValue(remaining); + } + if (entry.getValue().isEmpty()) players.remove(); + } + } + + private static void emit(ServerPlayer player, Action action, int remaining) { + switch (action) { + case PISS -> spawnPissStream(player); + case VOMIT -> spawnVomitStream(player); + case SHIT -> spawnShitStream(player, remaining); + case SMOKE -> spawnSmoke(player); + } + } + + private static void spawnPissStream(ServerPlayer player) { + ServerLevel level = player.level(); + Vec3 position = pissOrigin(player); + Vec3 velocity = pissVelocity(player); + for (int step = 0; step < STREAM_SEGMENTS; step++) { + Vec3 next = position.add(velocity.scale(STREAM_STEP_LENGTH)); + Vec3 midpoint = position.lerp(next, 0.5D); + level.sendParticles(pissCore(step), midpoint.x, midpoint.y, midpoint.z, 2, + 0.012D, 0.012D, 0.012D, 0.0D); + if ((step & 1) == 0) level.sendParticles(step % 4 == 0 ? PISS_DEEP_GOLD : PISS_BRIGHT, + midpoint.x, midpoint.y, midpoint.z, 2, 0.01D, 0.01D, 0.01D, 0.0D); + if (step % 3 == 0) level.sendParticles(pissMist(step), midpoint.x, midpoint.y, midpoint.z, + 2, 0.012D, 0.012D, 0.012D, 0.0D); + HitResult hit = clip(level, player, position, next); + if (hit.getType() != HitResult.Type.MISS) { + activateButton(level, player, hit); + pissImpact(level, hit.getLocation()); + return; + } + position = next; + velocity = velocity.add(0.0D, -0.03D * STREAM_STEP_SCALE, 0.0D) + .scale(Math.pow(0.985D, STREAM_STEP_SCALE)); + if (position.y < level.getMinY()) return; + } + } + + private static void spawnVomitStream(ServerPlayer player) { + ServerLevel level = player.level(); + Vec3 position = vomitOrigin(player); + Vec3 velocity = vomitVelocity(player); + level.sendParticles(ParticleTypes.WHITE_SMOKE, position.x, position.y, position.z, + 2, 0.012D, 0.01D, 0.012D, 0.002D); + for (int step = 0; step < STREAM_SEGMENTS; step++) { + Vec3 next = position.add(velocity.scale(STREAM_STEP_LENGTH)); + Vec3 midpoint = position.lerp(next, 0.5D); + level.sendParticles(vomitCore(step), midpoint.x, midpoint.y, midpoint.z, 2, + 0.012D, 0.012D, 0.012D, 0.0D); + if ((step & 1) == 0) level.sendParticles(step % 4 == 0 ? VOMIT_BROWN : VOMIT_GREEN, + midpoint.x, midpoint.y, midpoint.z, 2, 0.01D, 0.01D, 0.01D, 0.0D); + HitResult hit = clip(level, player, position, next); + if (hit.getType() != HitResult.Type.MISS) { + vomitImpact(level, hit.getLocation()); + return; + } + position = next; + velocity = velocity.add(0.0D, -0.038D * STREAM_STEP_SCALE, 0.0D) + .scale(Math.pow(0.982D, STREAM_STEP_SCALE)); + if (position.y < level.getMinY()) return; + } + } + + private static void spawnShitStream(ServerPlayer player, int remaining) { + ServerLevel level = player.level(); + Vec3 position = shitOrigin(player); + Vec3 velocity = shitVelocity(player); + if (remaining < Action.SHIT.durationTicks() && remaining % 20 == 0) spawnShitDrop(level, player); + for (int step = 0; step < STREAM_SEGMENTS; step++) { + Vec3 next = position.add(velocity.scale(STREAM_STEP_LENGTH)); + Vec3 midpoint = position.lerp(next, 0.5D); + float progress = step / (float) (STREAM_SEGMENTS - 1); + float spread = 0.002F + progress * 0.018F; + level.sendParticles(new DustParticleOptions(0x5B3417, + Mth.lerp(progress * progress, 0.05F, 1.55F)), midpoint.x, midpoint.y, midpoint.z, + 1, spread, spread, spread, 0.0D); + if ((step & 1) == 0) level.sendParticles(new DustParticleOptions(0x75461F, + Mth.lerp(progress * progress, 0.04F, 0.85F)), midpoint.x, midpoint.y, midpoint.z, + 1, spread, spread, spread, 0.0D); + HitResult hit = clip(level, player, position, next); + if (hit.getType() != HitResult.Type.MISS) { + level.sendParticles(SHIT_SPLAT, hit.getLocation().x, hit.getLocation().y, + hit.getLocation().z, 2, 0.03D, 0.02D, 0.03D, 0.0D); + level.sendParticles(SHIT_MUD, hit.getLocation().x, hit.getLocation().y, + hit.getLocation().z, 4, 0.04D, 0.025D, 0.04D, 0.0D); + return; + } + position = next; + velocity = velocity.add(0.0D, -0.032D * STREAM_STEP_SCALE, 0.0D) + .scale(Math.pow(0.986D, STREAM_STEP_SCALE)); + if (position.y < level.getMinY()) return; + } + } + + private static void spawnSmoke(ServerPlayer player) { + ServerLevel level = player.level(); + Vec3 mouth = mouthOrigin(player); + level.sendParticles(ParticleTypes.SMOKE, mouth.x, mouth.y, mouth.z, + 2, 0.015D, 0.01D, 0.015D, 0.003D); + if (level.getRandom().nextInt(3) == 0) level.sendParticles(ParticleTypes.WHITE_SMOKE, + mouth.x, mouth.y, mouth.z, 1, 0.01D, 0.008D, 0.01D, 0.002D); + } + + private static HitResult clip(ServerLevel level, ServerPlayer player, Vec3 from, Vec3 to) { + return level.clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.ANY, player)); + } + + private static Vec3 pissOrigin(ServerPlayer player) { + Vec3 forward = Vec3.directionFromRotation(0.0F, player.getYRot()).normalize(); + Vec3 right = new Vec3(-forward.z, 0.0D, forward.x); + double side = player.getMainArm() == HumanoidArm.RIGHT ? -0.12D : 0.12D; + return player.position().add(0.0D, player.getBbHeight() * 0.55D, 0.0D) + .add(forward.scale(0.24D)).add(right.scale(side)); + } + + private static Vec3 pissVelocity(ServerPlayer player) { + float pitch = Mth.clamp(player.getXRot() + 22.0F, 6.0F, 55.0F); + return Vec3.directionFromRotation(pitch, player.getYRot()).normalize().scale(1.05D) + .add(player.getDeltaMovement().multiply(0.18D, 0.0D, 0.18D)); + } + + private static Vec3 vomitOrigin(ServerPlayer player) { + Vec3 forward = Vec3.directionFromRotation(player.getXRot(), player.getYRot()).normalize(); + return player.position().add(0.0D, player.getBbHeight() * 0.82D, 0.0D) + .add(forward.scale(0.18D)); + } + + private static Vec3 vomitVelocity(ServerPlayer player) { + float pitch = Mth.clamp(player.getXRot() + 8.0F, -18.0F, 42.0F); + return Vec3.directionFromRotation(pitch, player.getYRot()).normalize().scale(0.8D) + .add(player.getDeltaMovement().multiply(0.15D, 0.0D, 0.15D)); + } + + private static Vec3 shitOrigin(ServerPlayer player) { + Vec3 backward = Vec3.directionFromRotation(0.0F, player.getYRot()).normalize().scale(-1.0D); + return player.position().add(0.0D, player.getBbHeight() * 0.44D, 0.0D) + .add(backward.scale(0.16D)); + } + + private static Vec3 shitVelocity(ServerPlayer player) { + Vec3 backward = Vec3.directionFromRotation(0.0F, player.getYRot()).normalize().scale(-1.0D); + return backward.scale(0.68D).add(0.0D, 0.08D, 0.0D) + .add(player.getDeltaMovement().multiply(0.1D, 0.0D, 0.1D)); + } + + private static Vec3 mouthOrigin(ServerPlayer player) { + Vec3 forward = Vec3.directionFromRotation(player.getXRot(), player.getYRot()).normalize(); + return player.position().add(0.0D, player.getBbHeight() * 0.82D, 0.0D) + .add(forward.scale(0.12D)); + } + + private static void spawnShitDrop(ServerLevel level, ServerPlayer player) { + Vec3 backward = Vec3.directionFromRotation(0.0F, player.getYRot()).normalize().scale(-1.0D); + Vec3 position = shitOrigin(player).add(backward.scale(0.08D)).add(0.0D, -0.08D, 0.0D); + Vec3 velocity = backward.scale(0.05D).add(0.0D, 0.02D, 0.0D); + ItemEntity item = new ItemEntity(level, position.x, position.y, position.z, + new ItemStack(AbjectionRegistries.SHIT_BALL), velocity.x, velocity.y, velocity.z); + item.setPickUpDelay(10); + item.setThrower(player); + level.addFreshEntity(item); + } + + private static DustParticleOptions pissCore(int step) { + return switch (step % 3) { + case 0 -> PISS_BRIGHT; + case 1 -> PISS_GOLD; + default -> PISS_AMBER; + }; + } + + private static ColorParticleOption pissMist(int step) { + return switch (step % 3) { + case 0 -> PISS_BRIGHT_MIST; + case 1 -> PISS_GOLD_MIST; + default -> PISS_AMBER_MIST; + }; + } + + private static DustParticleOptions vomitCore(int step) { + return switch (step % 3) { + case 0 -> VOMIT_GREEN; + case 1 -> VOMIT_YELLOW; + default -> VOMIT_ORANGE; + }; + } + + private static ItemParticleOption vomitChunk(ServerLevel level) { + return switch (level.getRandom().nextInt(3)) { + case 0 -> VOMIT_FLESH; + case 1 -> VOMIT_EYE; + default -> VOMIT_POTATO; + }; + } + + private static void activateButton(ServerLevel level, ServerPlayer player, HitResult hit) { + if (!(hit instanceof BlockHitResult blockHit)) return; + BlockPos pos = blockHit.getBlockPos(); + BlockState state = level.getBlockState(pos); + if (state.getBlock() instanceof ButtonBlock button && !state.getValue(ButtonBlock.POWERED)) { + button.press(state, level, pos, player); + } + } + + private static void pissImpact(ServerLevel level, Vec3 position) { + for (DustParticleOptions particle : new DustParticleOptions[] {PISS_BRIGHT, PISS_GOLD, PISS_AMBER}) { + level.sendParticles(particle, position.x, position.y, position.z, 3, + 0.02D, 0.02D, 0.02D, 0.0D); + } + for (ColorParticleOption particle : new ColorParticleOption[] { + PISS_BRIGHT_MIST, PISS_GOLD_MIST, PISS_AMBER_MIST}) { + level.sendParticles(particle, position.x, position.y, position.z, 2, + 0.03D, 0.022D, 0.03D, 0.0D); + } + level.sendParticles(ParticleTypes.BUBBLE_POP, position.x, position.y, position.z, + 8, 0.05D, 0.03D, 0.05D, 0.01D); + } + + private static void vomitImpact(ServerLevel level, Vec3 position) { + for (DustParticleOptions particle : new DustParticleOptions[] { + VOMIT_GREEN, VOMIT_YELLOW, VOMIT_ORANGE, VOMIT_BROWN}) { + level.sendParticles(particle, position.x, position.y, position.z, 3, + 0.02D, 0.02D, 0.02D, 0.0D); + } + for (ColorParticleOption particle : new ColorParticleOption[] { + VOMIT_GREEN_MIST, VOMIT_YELLOW_MIST, VOMIT_ORANGE_MIST}) { + level.sendParticles(particle, position.x, position.y, position.z, 2, + 0.03D, 0.022D, 0.03D, 0.0D); + } + level.sendParticles(ParticleTypes.SPLASH, position.x, position.y, position.z, + 4, 0.035D, 0.015D, 0.035D, 0.01D); + level.sendParticles(ParticleTypes.SNEEZE, position.x, position.y, position.z, + 2, 0.03D, 0.02D, 0.03D, 0.0D); + level.sendParticles(ParticleTypes.ITEM_SLIME, position.x, position.y, position.z, + 3, 0.03D, 0.02D, 0.03D, 0.0D); + for (int index = 0; index < 2; index++) level.sendParticles(vomitChunk(level), + position.x, position.y, position.z, 1, 0.03D, 0.02D, 0.03D, 0.0D); + } +} diff --git a/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/command/AbjectionCommands.java b/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/command/AbjectionCommands.java new file mode 100644 index 0000000..d553932 --- /dev/null +++ b/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/command/AbjectionCommands.java @@ -0,0 +1,36 @@ +package fr.koka99cab.sanctuary26.onlyfun.command; + +import com.mojang.brigadier.Command; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import fr.koka99cab.sanctuary26.onlyfun.api.abjection.AbjectionActions; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +/** Historical root commands, routed through the shared server action API. */ +public final class AbjectionCommands { + private AbjectionCommands() { + } + + public static void register() { + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> { + for (AbjectionActions.Action action : AbjectionActions.Action.values()) { + dispatcher.register(Commands.literal(action.id()) + .executes(context -> start(context.getSource(), action))); + } + }); + } + + private static int start(CommandSourceStack source, AbjectionActions.Action action) + throws CommandSyntaxException { + ServerPlayer player = source.getPlayerOrException(); + AbjectionActions.StartResult result = AbjectionActions.start(player, action); + if (result == AbjectionActions.StartResult.REJECTED) return 0; + String suffix = result == AbjectionActions.StartResult.RESTARTED ? "restarted" : "started"; + source.sendSuccess(() -> Component.translatable( + "command.abjection." + action.id() + "." + suffix), false); + return Command.SINGLE_SUCCESS; + } +} diff --git a/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/registry/OnlyFunCreativeTab.java b/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/registry/OnlyFunCreativeTab.java index 5e22903..3efe9c6 100644 --- a/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/registry/OnlyFunCreativeTab.java +++ b/onlyfun/src/main/java/fr/koka99cab/sanctuary26/onlyfun/registry/OnlyFunCreativeTab.java @@ -1,6 +1,7 @@ package fr.koka99cab.sanctuary26.onlyfun.registry; import fr.koka99cab.sanctuary26.onlyfun.OnlyFunMod; +import fr.koka99cab.sanctuary26.onlyfun.abjection.AbjectionRegistries; import net.minecraft.core.Registry; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.Registries; @@ -27,6 +28,8 @@ public final class OnlyFunCreativeTab { output.accept(OnlyFunRegistries.CAPE_LOOTBOX); output.accept(OnlyFunRegistries.SPAWN_EGG_LOOTBOX); output.accept(OnlyFunRegistries.ANOMALY_LOOTBOX); + output.accept(AbjectionRegistries.SHIT_BALL); + output.accept(AbjectionRegistries.SHIT_CUBE_ITEM); }) .build() ); diff --git a/onlyfun/src/main/resources/assets/abjection/blockstates/shit_cube.json b/onlyfun/src/main/resources/assets/abjection/blockstates/shit_cube.json new file mode 100644 index 0000000..34193f5 --- /dev/null +++ b/onlyfun/src/main/resources/assets/abjection/blockstates/shit_cube.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "abjection:block/shit_cube" + } + } +} diff --git a/onlyfun/src/main/resources/assets/abjection/items/shit_ball.json b/onlyfun/src/main/resources/assets/abjection/items/shit_ball.json new file mode 100644 index 0000000..dd54751 --- /dev/null +++ b/onlyfun/src/main/resources/assets/abjection/items/shit_ball.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "abjection:item/shit_ball" + } +} diff --git a/onlyfun/src/main/resources/assets/abjection/items/shit_cube.json b/onlyfun/src/main/resources/assets/abjection/items/shit_cube.json new file mode 100644 index 0000000..2bac49c --- /dev/null +++ b/onlyfun/src/main/resources/assets/abjection/items/shit_cube.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "abjection:item/shit_cube" + } +} diff --git a/onlyfun/src/main/resources/assets/abjection/lang/en_us.json b/onlyfun/src/main/resources/assets/abjection/lang/en_us.json new file mode 100644 index 0000000..59656f1 --- /dev/null +++ b/onlyfun/src/main/resources/assets/abjection/lang/en_us.json @@ -0,0 +1,12 @@ +{ + "item.abjection.shit_ball": "Shit Ball", + "block.abjection.shit_cube": "Shit Cube", + "command.abjection.piss.started": "Here we go for 15 seconds.", + "command.abjection.piss.restarted": "The timer restarts for 15 seconds.", + "command.abjection.vomit.started": "Yuck. You vomit for 8 seconds.", + "command.abjection.vomit.restarted": "You vomit again for 8 seconds.", + "command.abjection.shit.started": "You shit in a stream for 6 seconds.", + "command.abjection.shit.restarted": "The shit stream restarts for 6 seconds.", + "command.abjection.smoke.started": "You blow smoke for 10 seconds.", + "command.abjection.smoke.restarted": "You keep smoking for 10 seconds." +} diff --git a/onlyfun/src/main/resources/assets/abjection/lang/fr_fr.json b/onlyfun/src/main/resources/assets/abjection/lang/fr_fr.json new file mode 100644 index 0000000..8934f87 --- /dev/null +++ b/onlyfun/src/main/resources/assets/abjection/lang/fr_fr.json @@ -0,0 +1,12 @@ +{ + "item.abjection.shit_ball": "Boule de merde", + "block.abjection.shit_cube": "Cube de merde", + "command.abjection.piss.started": "C'est parti pour 15 secondes.", + "command.abjection.piss.restarted": "Le timer repart pour 15 secondes.", + "command.abjection.vomit.started": "Beurk. Tu vomis pendant 8 secondes.", + "command.abjection.vomit.restarted": "Tu revomis pour 8 secondes.", + "command.abjection.shit.started": "Tu chies en jet pendant 6 secondes.", + "command.abjection.shit.restarted": "Le jet de merde repart pour 6 secondes.", + "command.abjection.smoke.started": "Tu souffles de la fumée pendant 10 secondes.", + "command.abjection.smoke.restarted": "Tu continues de fumer pendant 10 secondes." +} diff --git a/onlyfun/src/main/resources/assets/abjection/lang/ru_ru.json b/onlyfun/src/main/resources/assets/abjection/lang/ru_ru.json new file mode 100644 index 0000000..8df2eed --- /dev/null +++ b/onlyfun/src/main/resources/assets/abjection/lang/ru_ru.json @@ -0,0 +1,12 @@ +{ + "item.abjection.shit_ball": "Шарик дерьма", + "block.abjection.shit_cube": "Куб дерьма", + "command.abjection.piss.started": "Струя запущена на 15 секунд.", + "command.abjection.piss.restarted": "Таймер струи перезапущен на 15 секунд.", + "command.abjection.vomit.started": "Фу. Тебя рвёт 8 секунд.", + "command.abjection.vomit.restarted": "Тебя снова рвёт 8 секунд.", + "command.abjection.shit.started": "Ты испражняешься струёй 6 секунд.", + "command.abjection.shit.restarted": "Струя дерьма перезапущена на 6 секунд.", + "command.abjection.smoke.started": "Ты выпускаешь дым 10 секунд.", + "command.abjection.smoke.restarted": "Ты продолжаешь курить ещё 10 секунд." +} diff --git a/onlyfun/src/main/resources/assets/abjection/models/block/shit_cube.json b/onlyfun/src/main/resources/assets/abjection/models/block/shit_cube.json new file mode 100644 index 0000000..463b620 --- /dev/null +++ b/onlyfun/src/main/resources/assets/abjection/models/block/shit_cube.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "abjection:block/shit_cube" + } +} diff --git a/onlyfun/src/main/resources/assets/abjection/models/item/shit_ball.json b/onlyfun/src/main/resources/assets/abjection/models/item/shit_ball.json new file mode 100644 index 0000000..2b64edd --- /dev/null +++ b/onlyfun/src/main/resources/assets/abjection/models/item/shit_ball.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "abjection:item/shit_ball" + } +} diff --git a/onlyfun/src/main/resources/assets/abjection/models/item/shit_cube.json b/onlyfun/src/main/resources/assets/abjection/models/item/shit_cube.json new file mode 100644 index 0000000..16b3ed4 --- /dev/null +++ b/onlyfun/src/main/resources/assets/abjection/models/item/shit_cube.json @@ -0,0 +1,3 @@ +{ + "parent": "abjection:block/shit_cube" +} diff --git a/onlyfun/src/main/resources/assets/abjection/textures/block/shit_cube.png b/onlyfun/src/main/resources/assets/abjection/textures/block/shit_cube.png new file mode 100644 index 0000000..432c8cd Binary files /dev/null and b/onlyfun/src/main/resources/assets/abjection/textures/block/shit_cube.png differ diff --git a/onlyfun/src/main/resources/assets/abjection/textures/item/shit_ball.png b/onlyfun/src/main/resources/assets/abjection/textures/item/shit_ball.png new file mode 100644 index 0000000..c2e9e27 Binary files /dev/null and b/onlyfun/src/main/resources/assets/abjection/textures/item/shit_ball.png differ diff --git a/onlyfun/src/main/resources/data/abjection/loot_table/blocks/shit_cube.json b/onlyfun/src/main/resources/data/abjection/loot_table/blocks/shit_cube.json new file mode 100644 index 0000000..5e3c1b7 --- /dev/null +++ b/onlyfun/src/main/resources/data/abjection/loot_table/blocks/shit_cube.json @@ -0,0 +1,21 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "bonus_rolls": 0.0, + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ], + "entries": [ + { + "type": "minecraft:item", + "name": "abjection:shit_cube" + } + ], + "rolls": 1.0 + } + ], + "random_sequence": "abjection:blocks/shit_cube" +} diff --git a/onlyfun/src/main/resources/data/abjection/recipe/shit_cube.json b/onlyfun/src/main/resources/data/abjection/recipe/shit_cube.json new file mode 100644 index 0000000..ea75810 --- /dev/null +++ b/onlyfun/src/main/resources/data/abjection/recipe/shit_cube.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "category": "building", + "key": { + "#": "abjection:shit_ball" + }, + "pattern": [ + "###", + "###", + "###" + ], + "result": { + "count": 1, + "id": "abjection:shit_cube" + } +} diff --git a/onlyfun/src/main/resources/data/minecraft/tags/block/mineable/shovel.json b/onlyfun/src/main/resources/data/minecraft/tags/block/mineable/shovel.json new file mode 100644 index 0000000..f210e35 --- /dev/null +++ b/onlyfun/src/main/resources/data/minecraft/tags/block/mineable/shovel.json @@ -0,0 +1,6 @@ +{ + "replace": false, + "values": [ + "abjection:shit_cube" + ] +} diff --git a/onlyfun/src/main/resources/fabric.mod.json b/onlyfun/src/main/resources/fabric.mod.json index 2178b20..4701a7a 100644 --- a/onlyfun/src/main/resources/fabric.mod.json +++ b/onlyfun/src/main/resources/fabric.mod.json @@ -8,6 +8,9 @@ "KOKA99CAB" ], "license": "GPL-3.0-or-later", + "provides": [ + "abjection" + ], "icon": "assets/onlyfun/icon.png", "environment": "*", "entrypoints": { diff --git a/onlyfun/src/test/java/fr/koka99cab/sanctuary26/onlyfun/api/abjection/AbjectionActionContractSmoke.java b/onlyfun/src/test/java/fr/koka99cab/sanctuary26/onlyfun/api/abjection/AbjectionActionContractSmoke.java new file mode 100644 index 0000000..a149c93 --- /dev/null +++ b/onlyfun/src/test/java/fr/koka99cab/sanctuary26/onlyfun/api/abjection/AbjectionActionContractSmoke.java @@ -0,0 +1,42 @@ +package fr.koka99cab.sanctuary26.onlyfun.api.abjection; + +import java.util.Set; +import java.util.UUID; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; + +/** Executable regression check for the bounded historical action contract. */ +public final class AbjectionActionContractSmoke { + private AbjectionActionContractSmoke() { + } + + public static void main(String[] args) { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + require(AbjectionActions.Action.PISS.durationTicks() == 300, "piss duration changed"); + require(AbjectionActions.Action.VOMIT.durationTicks() == 160, "vomit duration changed"); + require(AbjectionActions.Action.SHIT.durationTicks() == 120, "shit duration changed"); + require(AbjectionActions.Action.SMOKE.durationTicks() == 200, "smoke duration changed"); + require(Set.of("piss", "vomit", "shit", "smoke").equals(Set.of( + AbjectionActions.Action.PISS.id(), AbjectionActions.Action.VOMIT.id(), + AbjectionActions.Action.SHIT.id(), AbjectionActions.Action.SMOKE.id())), + "historical command surface changed"); + + UUID playerId = UUID.fromString("00000000-0000-0000-0000-000000000057"); + require(!AbjectionActions.restart(playerId, AbjectionActions.Action.SHIT), + "first action incorrectly marked as a restart"); + require(AbjectionActions.remaining(playerId, AbjectionActions.Action.SHIT) == 120, + "initial duration was not installed"); + require(AbjectionActions.restart(playerId, AbjectionActions.Action.SHIT), + "second action was not marked as a restart"); + require(AbjectionActions.remaining(playerId, AbjectionActions.Action.SHIT) == 120, + "restart did not reset the full duration"); + AbjectionActions.clear(playerId); + require(AbjectionActions.remaining(playerId, AbjectionActions.Action.SHIT) == 0, + "cleanup left a temporary action behind"); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } +}