diff --git a/.agent/tasks/README.md b/.agent/tasks/README.md index 65c00779b..18340d153 100644 --- a/.agent/tasks/README.md +++ b/.agent/tasks/README.md @@ -161,11 +161,14 @@ Bug-ledger history lives in 2026-05-30) minus #2 (dropped 2026-05-31 as impl-trivia — see entry) minus the 2026-06-01/02 fixed batch — #8 (weight-rework) / #9 (mod-container, PR #22) / #10 (planetDefs tolerance, PR #22) / #11 (JEI guard, PR #22) / - #12 (TASK-45) / #13 (beds, PR #22) / #14 (railgun #61, TASK-49) = 4 live + #12 (TASK-45) / #13 (beds, PR #22) / #14 (railgun #61, TASK-49) / + #15 (canDoRainSnowIce density-scale, feature/better_weather) = 4 live (#1, #3, #5, #7). Entries #8–#14 were renumbered chronologically when the feature/postponed and 1.12 ledgers merged (2026-06-14), resolving a #8/#9 - collision. Batch #2 opened 2026-05-25; entry #5 added 2026-05-29; entry #7 - added 2026-05-31. Batch #1 fully drained by TASK-12 on 2026-05-23. Entries: + collision; #15 was added when feature/better_weather merged into 1.12 (its + own ledger #8 renumbered to avoid colliding). Batch #2 opened 2026-05-25; + entry #5 added 2026-05-29; entry #7 added 2026-05-31. Batch #1 fully drained + by TASK-12 on 2026-05-23. Entries: (1) `SatelliteRegistry.getNewSatellite` returns `null` for unknown types instead of the documented `SatelliteDefunct` fallback — pinned by `SatelliteRegistryFallbackTest._documentsKnownBug` pair. @@ -364,6 +367,20 @@ Bug-ledger history lives in silent unloaded-dest characterization) via the new `infra railgun-fire` probe. Fix (load dest dim on fire + per-cause feedback) tracked by TASK-49. Found 2026-06-02 during #61 investigation. + (15) ✅ **FIXED 2026-06-01 in `feature/better_weather`.** + `WorldProviderPlanet.canDoRainSnowIce` compared + `getAtmosphereDensity(pos)` — which returns the density already + divided by 100 (range ~0..2) — against the literal `75`, so the + predicate was effectively always false. Player-visible: AR planets + never accumulated rain/snow/ice through this gate regardless of + atmosphere. Fixed while wiring the new `minAtmosphereDensityForRain` + config threshold: the check now compares the raw 0..100 + `props.getAtmosphereDensity()` against the configurable threshold + (default 75), matching the scale used everywhere else + (XML `atmosphereDensity`, `AtmosphereTypes`). Found during the + feature/better_weather atmosphere-gate work. Does NOT change the live + count (found and fixed in the same change). Renumbered from its + original ledger #8 on the feature/better_weather → 1.12 merge. ## Done diff --git a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java index 952653e4c..77d1e825b 100644 --- a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java +++ b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java @@ -1127,6 +1127,8 @@ public void postInit(FMLPostInitializationEvent event) { MinecraftForge.EVENT_BUS.register(new EntityEventHandler()); // Async weather info injection MinecraftForge.EVENT_BUS.register(new zmaster587.advancedRocketry.world.weather.PlanetWeatherEventHandler()); + // Acid rain damage on planets flagged acidicRain + MinecraftForge.EVENT_BUS.register(new zmaster587.advancedRocketry.event.AcidRainHandler()); WirelessDataTickHandler wirelessTickHandler = new WirelessDataTickHandler(); MinecraftForge.EVENT_BUS.register(wirelessTickHandler); diff --git a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java index 36145de8a..bf9793134 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java +++ b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java @@ -202,6 +202,12 @@ public class ARConfiguration { public boolean logPlanetWeatherWrapping = true; @ConfigProperty public boolean forcePlanetWeatherWorldInfoWrapper = false; + @ConfigProperty(needsSync = true) + public float minAtmosphereDensityForRain = 75f; + @ConfigProperty + public float acidRainDamage = 1f; + @ConfigProperty + public int acidRainDamageInterval = 20; @ConfigProperty public float spaceLaserPowerMult; @ConfigProperty @@ -487,6 +493,9 @@ public static void loadPreInit() { arConfig.enableCustomPlanetWeather = config.get(PLANET, "enableCustomPlanetWeather", true, "Sub-toggle of perDimWorldInfo (no effect when that is false): if true, each AR planet has its own weather state (rain, thunder, /weather, isRaining); if false, weather delegates to the overworld while per-dimension time-of-day still applies.").getBoolean(); arConfig.logPlanetWeatherWrapping = config.get(PLANET, "logPlanetWeatherWrapping", true, "Log an info line every time an AR planet's WorldInfo is wrapped for per-dimension weather. Useful for diagnosing weather-wrapping issues; safe to disable in production.").getBoolean(); arConfig.forcePlanetWeatherWorldInfoWrapper = config.get(PLANET, "forcePlanetWeatherWorldInfoWrapper", false, "Force per-dimension weather wrapping on every secondary (non-overworld) dimension, including non-AR dims of other mods. Compatibility/debug flag — do NOT enable unless you know exactly what you are doing.").getBoolean(); + arConfig.minAtmosphereDensityForRain = (float) config.get(PLANET, "minAtmosphereDensityForRain", 75d, "Minimum atmosphere density (0-100 scale, same as planet atmosphereDensity) required for rain/snow and thunder on a planet. Below this, rain is suppressed regardless of the planet's weather markers, and thunder cannot occur. Thin/airless worlds stay clear.", 0d, 200d).getDouble(); + arConfig.acidRainDamage = (float) config.get(PLANET, "acidRainDamage", 1d, "Damage dealt to an unprotected player standing under open sky while it rains on a planet whose rain is acidic (acidicRain=true in the planet definition). Set 0 to disable acid-rain damage.", 0d, Float.MAX_VALUE).getDouble(); + arConfig.acidRainDamageInterval = config.get(PLANET, "acidRainDamageInterval", 20, "Ticks between successive acid-rain damage applications (20 = once per second).", 1, Integer.MAX_VALUE).getInt(); arConfig.blackListAllVanillaBiomes = config.getBoolean("blackListVanillaBiomes", PLANET, false, "Prevent vanilla biomes from spawning on planets."); arConfig.maxBiomesPerPlanet = config.get(PLANET, "maxBiomesPerPlanet", 99, "Maximum unique biomes per planet.").getInt(); diff --git a/src/main/java/zmaster587/advancedRocketry/command/sub/planet/PlanetCommand.java b/src/main/java/zmaster587/advancedRocketry/command/sub/planet/PlanetCommand.java index fde64b87a..022ad95ec 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/sub/planet/PlanetCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/sub/planet/PlanetCommand.java @@ -12,6 +12,7 @@ public PlanetCommand() { addSubcommand(new PlanetGenerateCommand()); addSubcommand(new PlanetSetCommand()); addSubcommand(new PlanetGetCommand()); + addSubcommand(new PlanetWeatherCommand()); addSubcommand(new CommandTreeHelp(this)); } diff --git a/src/main/java/zmaster587/advancedRocketry/command/sub/planet/PlanetWeatherCommand.java b/src/main/java/zmaster587/advancedRocketry/command/sub/planet/PlanetWeatherCommand.java new file mode 100644 index 000000000..798dcf026 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/command/sub/planet/PlanetWeatherCommand.java @@ -0,0 +1,79 @@ +package zmaster587.advancedRocketry.command.sub.planet; + +import net.minecraft.command.CommandException; +import net.minecraft.command.ICommandSender; +import net.minecraft.server.MinecraftServer; +import net.minecraft.util.text.TextComponentString; +import org.apache.commons.lang3.math.NumberUtils; +import zmaster587.advancedRocketry.api.ARConfiguration; +import zmaster587.advancedRocketry.command.sub.ARCommand; +import zmaster587.advancedRocketry.dimension.DimensionManager; +import zmaster587.advancedRocketry.dimension.DimensionProperties; +import zmaster587.advancedRocketry.world.weather.PlanetWeatherManager; +import zmaster587.advancedRocketry.world.weather.PlanetWeatherState; + +/** + * {@code /ar planet weather [dimId]} — prints a planet's weather profile (the + * static markers + acidic flag) together with its live state and the tick + * countdown to the next rain/thunder change. The profile values themselves are + * also editable through {@code /ar planet get|set rainMarker|...}; this + * command exists so a player can read the runtime state, which is not stored on + * {@link DimensionProperties}. + */ +public class PlanetWeatherCommand extends ARCommand { + + @Override + public String getName() { + return "weather"; + } + + @Override + public String getUsage(ICommandSender sender) { + return "commands.advancedrocketry.planet.weather.usage"; + } + + @Override + public void execute(MinecraftServer server, ICommandSender sender, String[] args) throws CommandException { + int dimId = args.length >= 1 && NumberUtils.isParsable(args[0]) + ? parseInt(args[0]) + : sender.getEntityWorld().provider.getDimension(); + + if (!DimensionManager.getInstance().isDimensionCreated(dimId)) { + throw invalidValue("Dimension", dimId); + } + + DimensionProperties props = DimensionManager.getInstance().getDimensionProperties(dimId); + + float density = props.getAtmosphereDensity(); + float threshold = ARConfiguration.getCurrentConfig().minAtmosphereDensityForRain; + boolean canRain = density >= threshold; + + send(sender, "=== Weather: " + props.getName() + " (dim " + dimId + ") ==="); + send(sender, "Profile: rain=" + markerWord(props.getRainMarker()) + + ", thunder=" + markerWord(props.getThunderMarker()) + + ", acidic=" + (props.isAcidicRain() ? "yes" : "no")); + send(sender, "Atmosphere: density " + (int) density + "/100 — rain " + + (canRain ? "allowed" : "suppressed") + " (min " + (int) threshold + ")"); + + PlanetWeatherState state = PlanetWeatherManager.getOrCreate(server, dimId); + if (state == null) { + send(sender, "Now: "); + return; + } + + String now = state.isThundering() ? "thunderstorm" : (state.isRaining() ? "raining" : "clear"); + send(sender, "Now: " + now); + send(sender, "Next change: rain in " + state.getRainTime() + "t, thunder in " + + state.getThunderTime() + "t"); + } + + private static String markerWord(int marker) { + if (marker > 0) return "always"; + if (marker < 0) return "never"; + return "dynamic"; + } + + private static void send(ICommandSender sender, String line) { + sender.sendMessage(new TextComponentString(line)); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/command/sub/redirect/WeatherCommand.java b/src/main/java/zmaster587/advancedRocketry/command/sub/redirect/WeatherCommand.java index da2e5e6fa..76a79e7ac 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/sub/redirect/WeatherCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/sub/redirect/WeatherCommand.java @@ -8,6 +8,7 @@ import net.minecraft.util.math.BlockPos; import net.minecraft.world.World; import net.minecraft.world.storage.WorldInfo; +import zmaster587.advancedRocketry.api.ARConfiguration; import zmaster587.advancedRocketry.command.sub.ARCommand; import zmaster587.advancedRocketry.dimension.DimensionProperties; import zmaster587.advancedRocketry.world.provider.WorldProviderPlanet; @@ -45,44 +46,46 @@ public void execute(MinecraftServer server, ICommandSender sender, String[] args DimensionProperties props = ((WorldProviderPlanet) world.provider).getDimensionProperties(); Preconditions.checkNotNull(props); + // A planet whose atmosphere is below the rain threshold can never + // hold precipitation: WorldProviderPlanet.updateWeather() forces it + // clear every tick, so refuse rain/thunder up front instead of + // "succeeding" and being silently reverted on the next tick. + final boolean canRain = props.getAtmosphereDensity() + >= ARConfiguration.getCurrentConfig().minAtmosphereDensityForRain; + WorldInfo worldinfo = world.getWorldInfo(); - if ("clear".equalsIgnoreCase(args[0])) { - // Check if clear weather is allowed - if (props.getRainMarker() == 1 || props.getThunderMarker() == 1) { - notifyCommandListener(sender, this, "commands.weather.always_not_clear"); - return; - } + String action = args[0].toLowerCase(java.util.Locale.ROOT); + if (!"clear".equals(action) && !"rain".equals(action) && !"thunder".equals(action)) { + throw new WrongUsageException("commands.weather.usage"); + } + + // Refuse up front anything WorldProviderPlanet.updateWeather() would + // revert next tick (a forcing marker, or — for rain/thunder — an + // atmosphere too thin to hold precipitation), instead of "succeeding" + // and being silently undone. + String refusal = weatherRefusalKey(action, props.getRainMarker(), + props.getThunderMarker(), canRain); + if (refusal != null) { + notifyCommandListener(sender, this, refusal); + return; + } + if ("clear".equals(action)) { worldinfo.setCleanWeatherTime(i); worldinfo.setRainTime(0); worldinfo.setThunderTime(0); worldinfo.setRaining(false); worldinfo.setThundering(false); notifyCommandListener(sender, this, "commands.weather.clear"); - } else if ("rain".equalsIgnoreCase(args[0])) { - // Check if raining is allowed - if (props.getRainMarker() == -1) { - notifyCommandListener(sender, this, "commands.weather.cannot_rain"); - return; - } - + } else if ("rain".equals(action)) { worldinfo.setCleanWeatherTime(0); worldinfo.setRainTime(i); worldinfo.setThunderTime(i); worldinfo.setRaining(true); worldinfo.setThundering(false); notifyCommandListener(sender, this, "commands.weather.rain"); - } else { - if (!"thunder".equalsIgnoreCase(args[0])) { - throw new WrongUsageException("commands.weather.usage"); - } - // Check if thunder is allowed - if (props.getThunderMarker() == -1) { - notifyCommandListener(sender, this, "commands.weather.cannot_thunder"); - return; - } - + } else { // thunder worldinfo.setCleanWeatherTime(0); worldinfo.setRainTime(i); worldinfo.setThunderTime(i); @@ -95,6 +98,40 @@ public void execute(MinecraftServer server, ICommandSender sender, String[] args } } + /** + * Whether {@code /advancedrocketry weather } can take effect on a + * planet, or would be reverted next tick by + * {@link WorldProviderPlanet#updateWeather()}. Returns the lang key of the + * refusal message, or {@code null} if the action is allowed. Pure function of + * the planet's weather markers + the atmosphere can-rain gate, so it is + * unit-tested directly ({@code WeatherCommandRefusalTest}). + * + *
    + *
  • clear — refused when a marker forces rain/thunder always-on;
  • + *
  • rain — refused when the rain marker is "never" ({@code -1}) or + * the atmosphere is too thin to hold rain;
  • + *
  • thunder — refused when the thunder marker is "never", or when + * rain itself is impossible (rain marker "never" or thin atmosphere), + * since {@code updateWeather} clears thunder whenever it is not raining.
  • + *
+ */ + public static String weatherRefusalKey(String action, int rainMarker, int thunderMarker, boolean canRain) { + if ("clear".equalsIgnoreCase(action)) { + return (rainMarker == 1 || thunderMarker == 1) ? "commands.weather.always_not_clear" : null; + } + if ("rain".equalsIgnoreCase(action)) { + if (rainMarker == -1) return "commands.weather.cannot_rain"; + if (!canRain) return "commands.weather.cannot_rain_atmosphere"; + return null; + } + if ("thunder".equalsIgnoreCase(action)) { + if (thunderMarker == -1) return "commands.weather.cannot_thunder"; + if (rainMarker == -1 || !canRain) return "commands.weather.cannot_thunder_norain"; + return null; + } + return null; + } + public List getTabCompletions(MinecraftServer server, ICommandSender sender, String[] args, @Nullable BlockPos targetPos) { if (args.length == 1) { return getListOfStringsMatchingLastWord(args, "clear", "rain", "thunder"); diff --git a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java index 6dbcc547b..da1b42638 100644 --- a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java +++ b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionProperties.java @@ -126,6 +126,7 @@ private static float clampFeatureFrequencyMultiplier(float multiplier) { private int thunderProlongationLength = WEATHER_PROLONGATION_LENGTH; private int rainMarker; // -1 - never rain, 1 - always rain, 0 - regular weather private int thunderMarker; // -1 - never thunder, 1 - always thunder, 0 - regular weather + private boolean acidicRain; // rain on this planet harms unprotected players under open sky IAtmosphere atmosphereType; StellarBody star; @@ -1690,6 +1691,8 @@ else if (nbt.hasKey("biomes", NBT.TAG_INT_ARRAY)) { setRainMarker(nbt.getInteger("rainMarker")); if (nbt.hasKey("thunderMarker", NBT.TAG_INT)) setThunderMarker(nbt.getInteger("thunderMarker")); + if (nbt.hasKey("acidicRain")) + setAcidicRain(nbt.getBoolean("acidicRain")); // Sanity clamp if (getRainStartLength() <= 0) setRainStartLength(WEATHER_START_LENGTH); @@ -2042,6 +2045,7 @@ public void writeToNBT(NBTTagCompound nbt) { nbt.setInteger("thunderProlongationLength", getThunderProlongationLength()); nbt.setInteger("rainMarker", getRainMarker()); nbt.setInteger("thunderMarker", getThunderMarker()); + nbt.setBoolean("acidicRain", isAcidicRain()); //Hierarchy if (!childPlanets.isEmpty()) { @@ -2382,6 +2386,14 @@ public void setThunderMarker(int marker) { this.thunderMarker = marker; updateCustomWorldInfo(); } + + public boolean isAcidicRain() { + return acidicRain; + } + + public void setAcidicRain(boolean acidicRain) { + this.acidicRain = acidicRain; + } // /** diff --git a/src/main/java/zmaster587/advancedRocketry/event/AcidRainHandler.java b/src/main/java/zmaster587/advancedRocketry/event/AcidRainHandler.java new file mode 100644 index 000000000..47f12741a --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/event/AcidRainHandler.java @@ -0,0 +1,85 @@ +package zmaster587.advancedRocketry.event; + +import net.minecraft.entity.player.EntityPlayer; +import net.minecraft.inventory.EntityEquipmentSlot; +import net.minecraft.item.ItemStack; +import net.minecraft.util.DamageSource; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import net.minecraftforge.event.entity.living.LivingEvent.LivingUpdateEvent; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import zmaster587.advancedRocketry.api.ARConfiguration; +import zmaster587.advancedRocketry.api.capability.CapabilitySpaceArmor; +import zmaster587.advancedRocketry.dimension.DimensionManager; +import zmaster587.advancedRocketry.dimension.DimensionProperties; +import zmaster587.advancedRocketry.world.provider.WorldProviderPlanet; + +/** + * Applies damage to players caught in acidic rain — planets flagged + * {@code acidicRain=true} in their definition — while standing under open sky + * without a full protective space suit. + * + *

Acid rain is independent of breathability: a breathable acidic planet still + * burns an unprotected player. Protection is the same {@code PROTECTIVEARMOR} + * capability the atmosphere system uses, required on all four armor slots so a + * mask-only loadout does not shield bare skin.

+ */ +public class AcidRainHandler { + + public static final DamageSource ACID_RAIN = + new DamageSource("acidRain").setDamageBypassesArmor(); + + @SubscribeEvent + public void playerTick(LivingUpdateEvent event) { + if (!(event.getEntity() instanceof EntityPlayer)) return; + EntityPlayer player = (EntityPlayer) event.getEntity(); + World world = player.world; + if (world.isRemote) return; + + int interval = ARConfiguration.getCurrentConfig().acidRainDamageInterval; + if (interval < 1) interval = 1; + if (world.getTotalWorldTime() % interval != 0) return; + + float damage = ARConfiguration.getCurrentConfig().acidRainDamage; + if (damage <= 0f) return; + + if (isExposedToAcidRain(player)) { + player.attackEntityFrom(ACID_RAIN, damage); + } + } + + /** + * True when {@code player} is currently being harmed by acid rain: on an AR + * planet whose rain is acidic, standing where rain actually falls (open sky, + * rain-capable biome), and not wearing a full protective suit. + */ + public static boolean isExposedToAcidRain(EntityPlayer player) { + World world = player.world; + if (!(world.provider instanceof WorldProviderPlanet)) return false; + + DimensionProperties props = DimensionManager.getInstance() + .getDimensionProperties(world.provider.getDimension()); + if (props == null || !props.isAcidicRain()) return false; + + BlockPos pos = player.getPosition(); + if (!world.isRainingAt(pos)) return false; + + return !isProtected(player); + } + + /** A full protective space suit (all four slots) shields from acid rain. */ + public static boolean isProtected(EntityPlayer player) { + if (player.capabilities.isCreativeMode || player.isSpectator()) return true; + return hasProtectiveArmor(player, EntityEquipmentSlot.HEAD) + && hasProtectiveArmor(player, EntityEquipmentSlot.CHEST) + && hasProtectiveArmor(player, EntityEquipmentSlot.LEGS) + && hasProtectiveArmor(player, EntityEquipmentSlot.FEET); + } + + private static boolean hasProtectiveArmor(EntityPlayer player, EntityEquipmentSlot slot) { + ItemStack stack = player.getItemStackFromSlot(slot); + return !stack.isEmpty() + && CapabilitySpaceArmor.PROTECTIVEARMOR != null + && stack.hasCapability(CapabilitySpaceArmor.PROTECTIVEARMOR, null); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/mixin/MixinAcidRainRender.java b/src/main/java/zmaster587/advancedRocketry/mixin/MixinAcidRainRender.java new file mode 100644 index 000000000..689b8d38d --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/mixin/MixinAcidRainRender.java @@ -0,0 +1,58 @@ +package zmaster587.advancedRocketry.mixin; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.EntityRenderer; +import net.minecraft.client.renderer.GlStateManager; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; +import zmaster587.advancedRocketry.dimension.DimensionManager; +import zmaster587.advancedRocketry.dimension.DimensionProperties; +import zmaster587.advancedRocketry.world.provider.WorldProviderPlanet; + +/** + * Tints falling precipitation a sickly green on AR planets whose rain is + * acidic ({@code acidicRain=true}). The visual cue matches the + * {@link zmaster587.advancedRocketry.event.AcidRainHandler} damage so players + * can see the danger before they feel it. + * + *

{@code EntityRenderer.renderRainSnow} sets the global GL colour to white + * once, before drawing every rain/snow quad (whose per-vertex colour is white + * too). Redirecting that single call multiplies the whole pass by the acid + * tint, so the rain renders green without touching the per-vertex loop. The + * {@code acidicRain} flag reaches the client through {@code PacketDimInfo}.

+ */ +@Mixin(EntityRenderer.class) +public abstract class MixinAcidRainRender { + + // Sickly yellow-green. Multiplied onto the white rain texture. + private static final float ACID_R = 0.45F; + private static final float ACID_G = 0.95F; + private static final float ACID_B = 0.30F; + + @Redirect( + method = "renderRainSnow", + at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/GlStateManager;color(FFFF)V"), + // OptiFine and other render mods rewrite renderRainSnow; if the target + // call isn't present we silently skip rather than aborting the whole + // (required) mixin config. + require = 0) + private void ar$tintAcidRain(float red, float green, float blue, float alpha) { + if (ar$isAcidicRainHere()) { + GlStateManager.color(red * ACID_R, green * ACID_G, blue * ACID_B, alpha); + } else { + GlStateManager.color(red, green, blue, alpha); + } + } + + private static boolean ar$isAcidicRainHere() { + Minecraft mc = Minecraft.getMinecraft(); + if (mc.world == null || !(mc.world.provider instanceof WorldProviderPlanet)) { + return false; + } + DimensionProperties props = DimensionManager.getInstance() + .getDimensionProperties(mc.world.provider.getDimension()); + return props != null && props.isAcidicRain(); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/satellite/SatelliteWeatherController.java b/src/main/java/zmaster587/advancedRocketry/satellite/SatelliteWeatherController.java index b9b8b8844..4cb06e31c 100644 --- a/src/main/java/zmaster587/advancedRocketry/satellite/SatelliteWeatherController.java +++ b/src/main/java/zmaster587/advancedRocketry/satellite/SatelliteWeatherController.java @@ -21,9 +21,12 @@ import zmaster587.advancedRocketry.api.AdvancedRocketryBiomes; import zmaster587.advancedRocketry.api.satellite.SatelliteBase; import zmaster587.advancedRocketry.api.satellite.SatelliteProperties; +import net.minecraft.world.WorldServer; import zmaster587.advancedRocketry.dimension.DimensionManager; +import zmaster587.advancedRocketry.dimension.DimensionProperties; import zmaster587.advancedRocketry.item.ItemBiomeChanger; import zmaster587.advancedRocketry.item.ItemWeatherController; +import zmaster587.advancedRocketry.world.weather.PlanetWeatherManager; import zmaster587.advancedRocketry.network.PacketAirParticle; import zmaster587.advancedRocketry.network.PacketFluidParticle; import zmaster587.advancedRocketry.util.BiomeHandler; @@ -100,6 +103,7 @@ public void tickEntity() { last_mode_id = mode_id; //this.timer = 0; viable_positions.clear(); + applyWeatherMode(); } //if (this.timer > 0) { @@ -157,6 +161,59 @@ public void tickEntity() { + /** + * Drive the planet's rain marker from the controller mode: + *
    + *
  • rain (0) → marker 1: the planet rains continuously,
  • + *
  • dry (1) → marker -1: the sky is forced clear,
  • + *
  • flood (2) → weather left untouched (purely hydrological).
  • + *
+ * The marker is re-read every tick by {@code WorldProviderPlanet#updateWeather}, + * which still keeps a thin-atmosphere planet clear even in rain mode. + */ + private void applyWeatherMode() { + DimensionProperties props = + DimensionManager.getInstance().getDimensionProperties(getDimensionId()); + if (props == null) return; + + if (mode_id == 0) { + props.setRainMarker(1); + } else if (mode_id == 1) { + props.setRainMarker(-1); + } else { + return; // flood: do not touch the weather + } + + WorldServer ws = net.minecraftforge.common.DimensionManager.getWorld(getDimensionId()); + if (ws != null) { + if (mode_id == 0) { + ws.getWorldInfo().setRaining(true); + } else { + ws.getWorldInfo().setRaining(false); + ws.getWorldInfo().setThundering(false); + } + PlanetWeatherManager.syncToPlayersInWorld(ws); + } + } + + @Override + public void setDead() { + super.setDead(); + // Hand weather control back when the controller is removed, but only if + // this controller was actively forcing it (rain/dry), so an XML-set + // marker on a flood-only planet is left alone. + if (mode_id != 0 && mode_id != 1) return; + DimensionProperties props = + DimensionManager.getInstance().getDimensionProperties(getDimensionId()); + if (props != null && props.getRainMarker() != 0) { + props.setRainMarker(0); + WorldServer ws = net.minecraftforge.common.DimensionManager.getWorld(getDimensionId()); + if (ws != null) { + PlanetWeatherManager.syncToPlayersInWorld(ws); + } + } + } + private boolean is_block_in_list(List l, BlockPos p) { for (BlockPos i : l) { if (i.getX() == p.getX() && i.getZ() == p.getZ() && i.getY() == p.getY()) { diff --git a/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java b/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java index ddc803023..fcf2a966a 100644 --- a/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java +++ b/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java @@ -110,6 +110,7 @@ public class XMLPlanetLoader { private static final String ELEMENT_THUNDER_PROLONGATION_LENGTH = "thunderProlongationLength"; private static final String ELEMENT_RAIN_MARKER = "rainMarker"; private static final String ELEMENT_THUNDER_MARKER = "thunderMarker"; + private static final String ELEMENT_ACIDIC_RAIN = "acidicRain"; NodeList currentList; private Document doc; @@ -274,6 +275,7 @@ private static Node writePlanet(Document doc, DimensionProperties properties) { nodePlanet.appendChild(createTextNode(doc, ELEMENT_THUNDER_PROLONGATION_LENGTH, properties.getThunderProlongationLength())); nodePlanet.appendChild(createTextNode(doc, ELEMENT_RAIN_MARKER, properties.getRainMarker())); nodePlanet.appendChild(createTextNode(doc, ELEMENT_THUNDER_MARKER, properties.getThunderMarker())); + nodePlanet.appendChild(createTextNode(doc, ELEMENT_ACIDIC_RAIN, properties.isAcidicRain())); nodePlanet.appendChild(createTextNode(doc, GENERATECRATERS, properties.canGenerateCraters())); nodePlanet.appendChild(createTextNode(doc, GENERATECAVES, properties.canGenerateCaves())); @@ -612,6 +614,8 @@ else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_RAIN_MARKER)) properties.setRainMarker(Integer.parseInt(planetPropertyNode.getTextContent())); else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_THUNDER_MARKER)) properties.setThunderMarker(Integer.parseInt(planetPropertyNode.getTextContent())); + else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_ACIDIC_RAIN)) + properties.setAcidicRain(Boolean.parseBoolean(planetPropertyNode.getTextContent())); else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_ATMDENSITY)) { try { diff --git a/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java b/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java index 57c5cb05b..c038dff8f 100644 --- a/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java +++ b/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java @@ -104,7 +104,8 @@ protected void init() { @Override public boolean canDoRainSnowIce(Chunk chunk) { - return getAtmosphereDensity(new BlockPos(0, 0, 0)) > 75 && super.canDoRainSnowIce(chunk); + return getDimensionProperties().getAtmosphereDensity() >= ARConfiguration.getCurrentConfig().minAtmosphereDensityForRain + && super.canDoRainSnowIce(chunk); } @Override @@ -142,7 +143,19 @@ public void updateWeather() { } boolean flag = world.getGameRules().getBoolean("doWeatherCycle"); - if (flag) { + // Compatibility gate: a planet whose atmosphere is too thin can + // neither rain nor thunder, no matter what its weather markers + // say. Below the threshold we force a clear sky and skip the + // whole cycle so markers can't re-enable precipitation. + final boolean canRain = props.getAtmosphereDensity() + >= ARConfiguration.getCurrentConfig().minAtmosphereDensityForRain; + + if (!canRain) { + world.getWorldInfo().setRaining(false); + world.getWorldInfo().setRainTime(0); + world.getWorldInfo().setThundering(false); + world.getWorldInfo().setThunderTime(0); + } else if (flag) { // No rain or thunder if (props.getRainMarker() == -1 && props.getThunderMarker() == -1) { world.getWorldInfo().setRaining(false); @@ -168,6 +181,17 @@ public void updateWeather() { world.getWorldInfo().setCleanWeatherTime(0); world.getWorldInfo().setRaining(true); } + // Marker -1 means "never" — force the state off independently + // so a "dry" planet (or a Weather Controller in dry mode) stays + // clear even when only one of the two markers is -1. + if (props.getThunderMarker() == -1) { + world.getWorldInfo().setThundering(false); + world.getWorldInfo().setThunderTime(0); + } + if (props.getRainMarker() == -1) { + world.getWorldInfo().setRaining(false); + world.getWorldInfo().setRainTime(0); + } // Clamp to avoid IllegalArgumentException in Random#nextInt(0 or negative) final int thunderProlong = props.getThunderProlongationLength() > 0 ? props.getThunderProlongationLength() : 12000; @@ -212,6 +236,15 @@ public void updateWeather() { } + // Thunder cannot exist without rain. Vanilla couples the two + // (lightning only strikes where it is raining, and thunder + // strength is scaled by rain strength); keep AR consistent so a + // thunderMarker without rain can't leave a dead "storm" flag. + if (!world.getWorldInfo().isRaining()) { + world.getWorldInfo().setThundering(false); + world.getWorldInfo().setThunderTime(0); + } + world.prevThunderingStrength = world.thunderingStrength; if (world.getWorldInfo().isThundering()) { diff --git a/src/main/resources/assets/advancedrocketry/lang/en_US.lang b/src/main/resources/assets/advancedrocketry/lang/en_US.lang index de2b13b0c..08f20fb74 100644 --- a/src/main/resources/assets/advancedrocketry/lang/en_US.lang +++ b/src/main/resources/assets/advancedrocketry/lang/en_US.lang @@ -317,6 +317,7 @@ commands.advancedrocketry.goto.dimension.usage=goto dimension (alias: d, commands.advancedrocketry.goto.station.usage=goto station (alias: s) - teleports the player to the supplied station commands.advancedrocketry.planet.usage=/advancedrocketry planet help - lists subcommands. +commands.advancedrocketry.planet.weather.usage=planet weather [dimId] commands.advancedrocketry.planet.reset.usage=planet reset [dimId] commands.advancedrocketry.planet.list.usage=planet list commands.advancedrocketry.planet.list.dimensions=Dimensions: @@ -779,6 +780,8 @@ msg.orbitalregistry.writechip.hint.station.unlaunched=This station is not in orb commands.weather.always_not_clear=This planet is always not clear... commands.weather.cannot_rain=Cannot start a rain here commands.weather.cannot_thunder=Cannot start a thunder here +commands.weather.cannot_rain_atmosphere=This planet's atmosphere is too thin to hold rain +commands.weather.cannot_thunder_norain=This planet cannot thunder - rain is impossible here # Jeistuff jei.machinerecipe.power=Power: @@ -1264,8 +1267,8 @@ tooltip.advancedrocketry.satfunc.weather.alt.1=Combine with Weather Remote when tooltip.advancedrocketry.weathercontrollerremote=§bShift-Right Click to open GUI tooltip.advancedrocketry.weathercontrollerremote.shift.1=§fRight-click in hand to use tooltip.advancedrocketry.weathercontrollerremote.alt.1=Combine with Weather Controller when assembling -tooltip.advancedrocketry.weathercontrollerremote.mode.rain=§eMode: Rain - Fills small basins in the terrain with water -tooltip.advancedrocketry.weathercontrollerremote.mode.dry=§eMode: Dry - Dries all water in a radius of 16 +tooltip.advancedrocketry.weathercontrollerremote.mode.rain=§eMode: Rain - Makes it rain on the planet and fills small basins in the terrain with water +tooltip.advancedrocketry.weathercontrollerremote.mode.dry=§eMode: Dry - Keeps the sky clear and dries all water in a radius of 16 tooltip.advancedrocketry.weathercontrollerremote.mode.flood=§eMode: Flood - Floods area with a radius of 16 with water diff --git a/src/main/resources/mixins.advancedrocketry.json b/src/main/resources/mixins.advancedrocketry.json index 2ebf6a5f3..44bd254ac 100644 --- a/src/main/resources/mixins.advancedrocketry.json +++ b/src/main/resources/mixins.advancedrocketry.json @@ -14,6 +14,8 @@ "MixinWorldServerMulti", "MixinWorldSetBlockState" ], - "client": [], + "client": [ + "MixinAcidRainRender" + ], "server": [] } diff --git a/src/test/java/zmaster587/advancedRocketry/test/integration/DimensionPropertiesTest.java b/src/test/java/zmaster587/advancedRocketry/test/integration/DimensionPropertiesTest.java index f789a6769..3388d5513 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/integration/DimensionPropertiesTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/integration/DimensionPropertiesTest.java @@ -118,6 +118,7 @@ public void nbtRoundTripPreservesWeatherConfig() { original.setThunderProlongationLength(4_000); original.setRainMarker(1); original.setThunderMarker(-1); + original.setAcidicRain(true); NBTTagCompound nbt = new NBTTagCompound(); original.writeToNBT(nbt); @@ -130,6 +131,7 @@ public void nbtRoundTripPreservesWeatherConfig() { assertEquals(4_000, restored.getThunderProlongationLength()); assertEquals(1, restored.getRainMarker()); assertEquals(-1, restored.getThunderMarker()); + assertTrue("acidicRain must survive the save round-trip", restored.isAcidicRain()); } @Test diff --git a/src/test/java/zmaster587/advancedRocketry/test/integration/XMLPlanetLoaderTest.java b/src/test/java/zmaster587/advancedRocketry/test/integration/XMLPlanetLoaderTest.java index 8f91e1ab7..0bceeb430 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/integration/XMLPlanetLoaderTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/integration/XMLPlanetLoaderTest.java @@ -23,6 +23,7 @@ import java.util.List; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -147,6 +148,7 @@ public void weatherFieldsAreParsed() throws Exception { + " 6000\n" + " 1\n" + " -1\n" + + " true\n" + "\n"))); DimensionProperties props = coupling.dims.get(0); assertEquals(3000, props.getRainStartLength()); @@ -155,6 +157,7 @@ public void weatherFieldsAreParsed() throws Exception { assertEquals(6000, props.getThunderProlongationLength()); assertEquals("rainMarker=1 → always rain", 1, props.getRainMarker()); assertEquals("thunderMarker=-1 → never thunder", -1, props.getThunderMarker()); + assertTrue("acidicRain=true must be parsed from the planet XML", props.isAcidicRain()); } @Test @@ -169,6 +172,7 @@ public void weatherFieldsDefaultWhenMissing() throws Exception { assertEquals(168000, props.getThunderStartLength()); assertEquals("default rainMarker=0 (regular weather)", 0, props.getRainMarker()); assertEquals("default thunderMarker=0 (regular weather)", 0, props.getThunderMarker()); + assertFalse("acidicRain must default to false when the tag is absent", props.isAcidicRain()); } @Test diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/PlanetWeatherGateTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/PlanetWeatherGateTest.java new file mode 100644 index 000000000..58198a343 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/PlanetWeatherGateTest.java @@ -0,0 +1,138 @@ +package zmaster587.advancedRocketry.test.server; + +import com.github.stannismod.forge.testing.junit.AbstractHeadlessServerTest; +import com.github.stannismod.forge.testing.server.RealDedicatedServerHarness; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertTrue; + +/** + * SMART §7.5 — planet weather compatibility gates (feature/better_weather). + * + *

Pins two player-visible contracts of {@code WorldProviderPlanet.updateWeather}:

+ *
    + *
  1. Atmosphere gate. A planet whose atmosphere is thinner than + * {@code minAtmosphereDensityForRain} (default 75) must NOT rain, even with + * {@code rainMarker=1} ("always rain"). The same marker on a thick-atmosphere + * planet DOES rain — the contrast proves the gate, not a dead tick.
  2. + *
  3. Thunder requires rain. {@code thunderMarker=1} combined with + * {@code rainMarker=-1} must leave the planet not thundering — vanilla + * couples thunder to rain and AR must not create a dry storm.
  4. + *
+ * + *

All three fixture planets keep a non-default marker so + * {@code usesCustomWorldInfo()} engages the custom cycle; the live state is read + * back through {@code artest weather get}.

+ */ +public class PlanetWeatherGateTest { + + private static final int DIM_THIN_RAIN = 9111; // density 10, rainMarker 1 → must stay clear + private static final int DIM_THICK_RAIN = 9112; // density 100, rainMarker 1 → must rain + private static final int DIM_DRY_THUNDER = 9113; // density 100, thunder 1 / rain -1 → no thunder + + private Path workDir; + private RealDedicatedServerHarness harness; + + @Before + public void writeFixture() throws Exception { + Assume.assumeTrue( + "Server harness disabled — set -Dforge.test.harness.enabled=true", + Boolean.parseBoolean(System.getProperty( + AbstractHeadlessServerTest.PROP_HARNESS_ENABLED, "false"))); + + workDir = Files.createTempDirectory("forge-server-weather-gate-"); + Path arConfigDir = workDir.resolve("config").resolve("advRocketry"); + Files.createDirectories(arConfigDir); + + String xml = "\n" + + "\n" + + " \n" + + planetXml("ThinRainPlanet", DIM_THIN_RAIN, /*density*/ 10, /*rainMarker*/ 1, /*thunderMarker*/ 0) + + planetXml("ThickRainPlanet", DIM_THICK_RAIN, /*density*/ 100, /*rainMarker*/ 1, /*thunderMarker*/ 0) + + planetXml("DryThunderPlanet", DIM_DRY_THUNDER, /*density*/ 100, /*rainMarker*/ -1, /*thunderMarker*/ 1) + + " \n" + + "\n"; + Files.write(arConfigDir.resolve("planetDefs.xml"), xml.getBytes(StandardCharsets.UTF_8)); + } + + private static String planetXml(String name, int dim, int density, int rainMarker, int thunderMarker) { + return " \n" + + " true\n" + + " 0.5,0.5,0.5\n" + + " 0.4,0.6,0.9\n" + + " 100\n" + + " 100\n" + + " 0\n" + + " 0\n" + + " false\n" + + " 250\n" + + " 24000\n" + + " " + density + "\n" + + " " + rainMarker + "\n" + + " " + thunderMarker + "\n" + + " false\n" + + " true\n" + + " false\n" + + " \n"; + } + + @After + public void stopHarness() throws Exception { + if (harness != null) harness.close(); + } + + @Test + public void atmosphereGatesRainAndThunderRequiresRain() throws Exception { + harness = RealDedicatedServerHarness.startWith(workDir, /*cleanupOnClose=*/true); + + String dimList = String.join("\n", harness.client().execute("artest dim list")); + for (int dim : new int[]{DIM_THIN_RAIN, DIM_THICK_RAIN, DIM_DRY_THUNDER}) { + assertTrue("fixture dim " + dim + " not registered: " + dimList, + dimList.contains(String.valueOf(dim))); + } + + // Force a fresh weather evaluation on each planet, then read the live + // state. A few ticks let updateWeather run its marker + gate logic. + String thin = weatherAfterSettle(DIM_THIN_RAIN); + String thick = weatherAfterSettle(DIM_THICK_RAIN); + String dry = weatherAfterSettle(DIM_DRY_THUNDER); + + // Contrast: same rainMarker=1, opposite atmosphere → opposite rain state. + assertTrue("thick-atmosphere planet with rainMarker=1 must rain (gate baseline): " + thick, + thick.contains("\"isRaining\":true")); + assertTrue("thin-atmosphere planet must stay clear despite rainMarker=1 " + + "(atmosphere gate): " + thin, + thin.contains("\"isRaining\":false")); + + // Thunder cannot exist without rain: dry planet (rainMarker=-1) must not thunder. + assertTrue("dry planet (rainMarker=-1) must not rain: " + dry, + dry.contains("\"isRaining\":false")); + assertTrue("thunderMarker=1 with no rain must NOT thunder (vanilla couples them): " + dry, + dry.contains("\"isThundering\":false")); + } + + /** + * Reads {@code artest weather get } a few times so the dedicated server + * has ticked {@code updateWeather} at least once after the dims came online. + */ + private String weatherAfterSettle(int dim) throws Exception { + String last = ""; + for (int i = 0; i < 5; i++) { + last = String.join("\n", harness.client().execute("artest weather get " + dim)); + if (!last.contains("\"error\"")) { + // brief settle to let the weather cycle apply the marker/gate + try { Thread.sleep(250); } catch (InterruptedException ignored) { } + } + } + return last; + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/WeatherCommandRefusalTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/WeatherCommandRefusalTest.java new file mode 100644 index 000000000..476d3e776 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/WeatherCommandRefusalTest.java @@ -0,0 +1,94 @@ +package zmaster587.advancedRocketry.test.unit; + +import org.junit.Test; +import zmaster587.advancedRocketry.command.sub.redirect.WeatherCommand; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** + * Decision table for {@code /advancedrocketry weather} refusals — the + * better_weather ↔ 1.12 integration that makes the manual weather command aware + * of the marker / atmosphere policy it would otherwise lose to. + * + *

{@code WorldProviderPlanet.updateWeather()} re-applies the planet's weather + * markers and atmosphere gate every tick, so a manual command that fights them + * is reverted on the next tick. {@link WeatherCommand#weatherRefusalKey} refuses + * such commands up front; this pins that decision (a pure function, so no + * harness needed).

+ * + *

Marker convention: {@code -1} = never, {@code 0} = dynamic, {@code +1} = + * always. {@code canRain} = atmosphere density ≥ {@code minAtmosphereDensityForRain}.

+ */ +public class WeatherCommandRefusalTest { + + // ── rain ────────────────────────────────────────────────────────────── + @Test + public void rainAllowedWhenDynamicMarkerAndAtmosphereOk() { + assertNull(WeatherCommand.weatherRefusalKey("rain", 0, 0, true)); + } + + @Test + public void rainRefusedByNeverMarker() { + assertEquals("commands.weather.cannot_rain", + WeatherCommand.weatherRefusalKey("rain", -1, 0, true)); + } + + @Test + public void rainRefusedByThinAtmosphere() { + assertEquals("commands.weather.cannot_rain_atmosphere", + WeatherCommand.weatherRefusalKey("rain", 0, 0, false)); + } + + @Test + public void neverMarkerTakesPriorityOverAtmosphereMessageForRain() { + // The marker is checked first, so its message wins even when the + // atmosphere is ALSO too thin — both would refuse, the marker explains it. + assertEquals("commands.weather.cannot_rain", + WeatherCommand.weatherRefusalKey("rain", -1, 0, false)); + } + + // ── thunder (coupled to rain) ───────────────────────────────────────── + @Test + public void thunderAllowedWhenDynamicAndAtmosphereOk() { + assertNull(WeatherCommand.weatherRefusalKey("thunder", 0, 0, true)); + } + + @Test + public void thunderRefusedByNeverThunderMarker() { + assertEquals("commands.weather.cannot_thunder", + WeatherCommand.weatherRefusalKey("thunder", 0, -1, true)); + } + + @Test + public void thunderRefusedWhenRainImpossibleByMarker() { + assertEquals("commands.weather.cannot_thunder_norain", + WeatherCommand.weatherRefusalKey("thunder", -1, 0, true)); + } + + @Test + public void thunderRefusedWhenRainImpossibleByAtmosphere() { + assertEquals("commands.weather.cannot_thunder_norain", + WeatherCommand.weatherRefusalKey("thunder", 0, 0, false)); + } + + // ── clear ───────────────────────────────────────────────────────────── + @Test + public void clearAllowedWhenNoForcingMarker() { + assertNull(WeatherCommand.weatherRefusalKey("clear", 0, 0, true)); + // A "never rain/thunder" planet is already clear — clear is allowed. + assertNull(WeatherCommand.weatherRefusalKey("clear", -1, -1, false)); + } + + @Test + public void clearRefusedByAlwaysRainMarker() { + assertEquals("commands.weather.always_not_clear", + WeatherCommand.weatherRefusalKey("clear", 1, 0, true)); + } + + @Test + public void clearRefusedByAlwaysThunderMarker() { + assertEquals("commands.weather.always_not_clear", + WeatherCommand.weatherRefusalKey("clear", 0, 1, true)); + } +}