commit a3f73619485ec986fa692e22c88ffce8467dbf8a Author: Zoe Date: Sat Jul 4 13:08:10 2026 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65dcfc1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Compiled output +build/ +out/ + +# Gradle +.gradle/ +!gradle/wrapper/gradle-wrapper.jar + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.vscode/ + +# Minecraft runtime +run/ + +# OS +.DS_Store +Thumbs.db + +# Java +*.class +hs_err_pid* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1133d8d --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# OurVillagers + +A server-side Fabric mod that makes two changes to villager mechanics: + +1. **Always Zombify** — Villagers *always* convert to zombie villagers when killed by a zombie, regardless of difficulty. + +2. **Shared Cure Discounts** — When *any* player cures a zombie villager, *all* players get the trading discount. + +## How It Works + +### Always Zombify +**`ZombieMixin`** injects at the head of `Zombie.killedEntity()`. When the target is a villager, it unconditionally attempts conversion via `convertVillagerToZombieVillager()` and cancels the vanilla logic. This bypasses both the difficulty check and the 50% random skip on Normal difficulty. + +### Shared Cure Discounts +**`VillagerGossipsMixin`** injects at the head of `GossipContainer.getReputation()`. It scans all players' gossip data for the highest `MAJOR_POSITIVE` value (the cure discount). If any player has cured the villager, that bonus is applied to every player's trade prices. + +## Building + +```bash +./gradlew build +# Output: build/libs/ourvillagers-1.0.0.jar +``` + +Requires Java 25+. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..f8d4fdb --- /dev/null +++ b/build.gradle @@ -0,0 +1,48 @@ +plugins { + id 'net.fabricmc.fabric-loom' version "${loom_version}" + id 'maven-publish' +} + +version = project.mod_version +group = project.maven_group + +loom { + splitEnvironmentSourceSets() + + mods { + "ourvillagers" { + sourceSet sourceSets.main + } + } +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + implementation "net.fabricmc:fabric-loader:${project.loader_version}" + implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" +} + +processResources { + def version = project.version + inputs.property "version", version + + filesMatching("fabric.mod.json") { + expand "version": version + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.release = 25 +} + +java { + withSourcesJar() + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 +} + +jar { + from("LICENSE") { + rename { "${it}_${project.base.archivesName.get()}" } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..d31bf8e --- /dev/null +++ b/gradle.properties @@ -0,0 +1,12 @@ +org.gradle.jvmargs=-Xmx1G +org.gradle.parallel=true +org.gradle.configuration-cache=false + +minecraft_version=26.2 +loader_version=0.19.2 +loom_version=1.16-SNAPSHOT + +mod_version=1.0.0 +maven_group=xyz.zoeissleeping + +fabric_api_version=0.149.1+26.2 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..5dd3c01 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..a35af47 --- /dev/null +++ b/gradlew @@ -0,0 +1,108 @@ +#!/bin/sh + +# +# Gradle start up script for POSIX generated by Gradle. +# + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +app_path=$0 +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld -- "$app_path" ) + link=${ls#*' -> '} + case $link in + /*) app_path=$link ;; + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in + CYGWIN* ) cygwin=true ;; + Darwin* ) darwin=true ;; + MSYS* | MINGW* ) msys=true ;; + NonStop* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 ; then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + ;; + esac + case $MAX_FD in + '' | soft) :;; + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + ;; + esac +fi + +# Collect all arguments for the java command, stracks://issues.gradle.org/browse/GRADLE-2871 +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + JAVACMD=$( cygpath --unix "$JAVACMD" ) +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and/or whitespace +# * $APP_HOME is assumed to not contain whitespace +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$@" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..4354098 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,58 @@ +@rem Gradle startup script for Windows +@if "%DEBUG%"=="" @echo off +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %OS%==Windows_NT endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..6964698 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'ourvillagers' diff --git a/src/main/java/xyz/zoeissleeping/OurVillagersMod.java b/src/main/java/xyz/zoeissleeping/OurVillagersMod.java new file mode 100644 index 0000000..5480b9b --- /dev/null +++ b/src/main/java/xyz/zoeissleeping/OurVillagersMod.java @@ -0,0 +1,15 @@ +package xyz.zoeissleeping; + +import net.fabricmc.api.ModInitializer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class OurVillagersMod implements ModInitializer { + public static final String MOD_ID = "ourvillagers"; + public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); + + @Override + public void onInitialize() { + LOGGER.info("[OurVillagers] Loaded! Villagers always zombify and cure discounts are shared."); + } +} diff --git a/src/main/java/xyz/zoeissleeping/mixin/VillagerGossipsMixin.java b/src/main/java/xyz/zoeissleeping/mixin/VillagerGossipsMixin.java new file mode 100644 index 0000000..bfbbb3b --- /dev/null +++ b/src/main/java/xyz/zoeissleeping/mixin/VillagerGossipsMixin.java @@ -0,0 +1,105 @@ +package xyz.zoeissleeping.mixin; + +import net.minecraft.world.entity.ai.gossip.GossipContainer; +import net.minecraft.world.entity.ai.gossip.GossipType; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.UUID; +import java.util.function.Predicate; + +/** + * Shares curing discounts across ALL players. + * + * If any player has MAJOR_POSITIVE gossip (from curing a zombie villager), + * that bonus is applied to every player's reputation calculation. + */ +@Mixin(GossipContainer.class) +public abstract class VillagerGossipsMixin { + + @Shadow + private Map gossips; + + @Shadow + public abstract int getReputation(UUID target, Predicate gossipTypeFilter); + + @Unique + private static Method weightedValueMethod; + + @Unique + private static int ourVillagers$getWeightedValue(Object entityGossips, Predicate filter) { + try { + if (weightedValueMethod == null) { + weightedValueMethod = Class.forName("net.minecraft.world.entity.ai.gossip.GossipContainer$EntityGossips") + .getMethod("weightedValue", Predicate.class); + } + return (int) weightedValueMethod.invoke(entityGossips, filter); + } catch (Exception e) { + return 0; + } + } + + @Inject( + method = "getReputation", + at = @At("HEAD"), + cancellable = true + ) + private void shareCuredGossip( + UUID target, + Predicate gossipTypeFilter, + CallbackInfoReturnable cir + ) { + if (!gossipTypeFilter.test(GossipType.MAJOR_POSITIVE)) { + return; + } + + // Find max MAJOR_POSITIVE across all players + Predicate majorPositiveFilter = gt -> gt == GossipType.MAJOR_POSITIVE; + int globalMajorPositive = 0; + for (Object entry : gossips.entrySet()) { + Map.Entry mapEntry = (Map.Entry) entry; + int val = ourVillagers$getWeightedValue(mapEntry.getValue(), majorPositiveFilter); + if (val > globalMajorPositive) { + globalMajorPositive = val; + } + } + + if (globalMajorPositive <= 0) { + return; + } + + Object targetGossips = gossips.get(target); + + int playerMajorPositive = targetGossips == null + ? 0 + : ourVillagers$getWeightedValue(targetGossips, majorPositiveFilter); + + if (globalMajorPositive <= playerMajorPositive) { + return; + } + + int result = 0; + for (GossipType type : GossipType.values()) { + if (!gossipTypeFilter.test(type)) { + continue; + } + int value; + if (type == GossipType.MAJOR_POSITIVE) { + value = globalMajorPositive; + } else { + value = targetGossips == null + ? 0 + : ourVillagers$getWeightedValue(targetGossips, gt -> gt == type); + } + result += value; + } + + cir.setReturnValue(result); + } +} diff --git a/src/main/java/xyz/zoeissleeping/mixin/ZombieMixin.java b/src/main/java/xyz/zoeissleeping/mixin/ZombieMixin.java new file mode 100644 index 0000000..d8ee559 --- /dev/null +++ b/src/main/java/xyz/zoeissleeping/mixin/ZombieMixin.java @@ -0,0 +1,30 @@ +package xyz.zoeissleeping.mixin; + +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.npc.villager.Villager; +import net.minecraft.world.entity.monster.zombie.Zombie; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(Zombie.class) +public abstract class ZombieMixin { + + @Shadow + public abstract boolean convertVillagerToZombieVillager(ServerLevel level, Villager villager); + + @Inject(method = "killedEntity", at = @At("HEAD"), cancellable = true) + private void ourvillagers$forceVillagerConversion(ServerLevel level, LivingEntity entity, DamageSource source, CallbackInfoReturnable cir) { + if (entity instanceof Villager villager) { + if (this.convertVillagerToZombieVillager(level, villager)) { + cir.setReturnValue(false); + } else { + cir.setReturnValue(true); + } + } + } +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..fe7ae12 --- /dev/null +++ b/src/main/resources/fabric.mod.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "id": "ourvillagers", + "version": "${version}", + "name": "OurVillagers", + "description": "Villagers always zombify and share cure discounts across all players!", + "authors": ["zoeissleeping"], + "contact": {}, + "license": "MIT", + "icon": "", + "environment": "*", + "entrypoints": { + "main": ["xyz.zoeissleeping.OurVillagersMod"] + }, + "mixins": ["ourvillagers.mixins.json"], + "depends": { + "fabricloader": ">=0.19.2", + "minecraft": "~26.2", + "java": ">=25", + "fabric-api": "*" + } +} diff --git a/src/main/resources/ourvillagers.mixins.json b/src/main/resources/ourvillagers.mixins.json new file mode 100644 index 0000000..e759ba7 --- /dev/null +++ b/src/main/resources/ourvillagers.mixins.json @@ -0,0 +1,13 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "xyz.zoeissleeping.mixin", + "compatibilityLevel": "JAVA_25", + "mixins": [ + "ZombieMixin", + "VillagerGossipsMixin" + ], + "injectors": { + "defaultRequire": 1 + } +}