diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java index 2f1ea2aa13..5c6e459a6d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java @@ -104,6 +104,7 @@ public enum SVG { REFRESH("M12 20Q8.65 20 6.325 17.675T4 12Q4 8.65 6.325 6.325T12 4Q13.725 4 15.3 4.7125T18 6.75V4H20V11H13V9H17.2Q16.4 7.6 15.0125 6.8T12 6Q9.5 6 7.75 7.75T6 12Q6 14.5 7.75 16.25T12 18Q13.925 18 15.475 16.9T17.65 14H19.75Q19.05 16.65 16.9 18.325T12 20Z"), RELEASE_CIRCLE("M9,7H13A2,2 0 0,1 15,9V11C15,11.84 14.5,12.55 13.76,12.85L15,17H13L11.8,13H11V17H9V7M11,9V11H13V9H11M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12C4,16.41 7.58,20 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z"), // Not Material RESTORE("M12 21Q8.55 21 5.9875 18.7125T3.05 13H5.1Q5.45 15.6 7.4125 17.3T12 19Q14.925 19 16.9625 16.9625T19 12Q19 9.075 16.9625 7.0375T12 5Q10.275 5 8.775 5.8T6.25 8H9V10H3V4H5V6.35Q6.275 4.75 8.1125 3.875T12 3Q13.875 3 15.5125 3.7125T18.3625 5.6375Q19.575 6.85 20.2875 8.4875T21 12Q21 13.875 20.2875 15.5125T18.3625 18.3625Q17.15 19.575 15.5125 20.2875T12 21Z"), // Not Material + RULE("m14.4 20-1.4-1.4 2.6-2.6-2.6-2.6 1.4-1.4 2.6 2.6 2.6-2.6 1.4 1.4-2.6 2.6 2.6 2.6-1.4 1.4-2.6-2.6-2.6 2.6ZM16.375 11L12.825 7.45l1.4-1.4 2.125 2.125 4.25-4.25 1.4 1.425-5.625 5.65ZM2 17v-2h9v2H2ZM2 9v-2h9v2H2Z"), ROCKET_LAUNCH("M5.65 10.025 7.6 10.85Q7.95 10.15 8.325 9.5T9.15 8.2L7.75 7.925 5.65 10.025ZM9.2 12.1 12.05 14.925Q13.1 14.525 14.3 13.7T16.55 11.825Q18.3 10.075 19.2875 7.9375T20.15 4Q18.35 3.875 16.2 4.8625T12.3 7.6Q11.25 8.65 10.425 9.85T9.2 12.1ZM13.65 10.475Q13.075 9.9 13.075 9.0625T13.65 7.65Q14.225 7.075 15.075 7.075T16.5 7.65Q17.075 8.225 17.075 9.0625T16.5 10.475Q15.925 11.05 15.075 11.05T13.65 10.475ZM14.125 18.5 16.225 16.4 15.95 15Q15.3 15.45 14.65 15.8125T13.3 16.525L14.125 18.5ZM21.95 2.175Q22.425 5.2 21.3625 8.0625T17.7 13.525L18.2 16Q18.3 16.5 18.15 16.975T17.65 17.8L13.45 22 11.35 17.075 7.075 12.8 2.15 10.7 6.325 6.5Q6.675 6.15 7.1625 6T8.15 5.95L10.625 6.45Q13.225 3.85 16.075 2.775T21.95 2.175ZM3.925 15.975Q4.8 15.1 6.0625 15.0875T8.2 15.95Q9.075 16.825 9.0625 18.0875T8.175 20.225Q7.55 20.85 6.0875 21.3T2.05 22.1Q2.4 19.525 2.85 18.0625T3.925 15.975ZM5.35 17.375Q5.1 17.625 4.85 18.2875T4.5 19.625Q5.175 19.525 5.8375 19.2875T6.75 18.8Q7.05 18.5 7.075 18.075T6.8 17.35Q6.5 17.05 6.075 17.0625T5.35 17.375Z"), SCHEMA("M4 23V17H6.5V15H4V9H6.5V7H4V1h7V7H8.5V9H11v2h3V9h7v6H14V13H11v2H8.5v2H11v6H4Zm2-2H9V19H6v2Zm0-8H9V11H6v2Zm10 0h3V11H16v2ZM6 5H9V3H6V5ZM7.5 4Zm0 8Zm10 0Zm-10 8Z"), SCHEMA_FILL("M4 23V17H6.5V15H4V9H6.5V7H4V1h7V7H8.5V9H11v2h3V9h7v6H14V13H11v2H8.5v2H11v6H4Z"), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/NumberRangeValidator.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/NumberRangeValidator.java new file mode 100644 index 0000000000..47be61a013 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/NumberRangeValidator.java @@ -0,0 +1,54 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.construct; + +import com.jfoenix.validation.base.ValidatorBase; +import javafx.beans.NamedArg; +import javafx.scene.control.TextInputControl; +import org.jackhuang.hmcl.util.Lang; + +/// NumberRangeValidator only check whether inputted number is in range, but not if it is a number, +/// if the input is not a number, NumberRangeValidator will not show error message +public class NumberRangeValidator extends ValidatorBase { + private final int minValue; + private final int maxValue; + + public NumberRangeValidator(@NamedArg("outOfLimitMessage") String outOfLimitMessage, @NamedArg("minValue") int minValue, @NamedArg("maxValue") int maxValue) { + super(outOfLimitMessage); + this.minValue = minValue; + this.maxValue = maxValue; + } + + @Override + protected void eval() { + if (srcControl.get() instanceof TextInputControl) { + evalTextInputField(); + } + } + + private void evalTextInputField() { + TextInputControl textField = ((TextInputControl) srcControl.get()); + Double intOrNull = Lang.toDoubleOrNull(textField.getText()); + + if (intOrNull == null) { + hasErrors.set(false); + } else { + hasErrors.set(intOrNull > maxValue || intOrNull < minValue); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameRuleInfo.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameRuleInfo.java new file mode 100644 index 0000000000..3240ed15fb --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameRuleInfo.java @@ -0,0 +1,202 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.versions; + +import com.github.steveice10.opennbt.tag.builtin.Tag; +import javafx.beans.binding.Bindings; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import org.jackhuang.hmcl.gamerule.GameRule; +import org.jackhuang.hmcl.gamerule.GameRuleNBT; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; + +import java.util.Objects; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public sealed abstract class GameRuleInfo permits GameRuleInfo.BooleanGameRuleInfo, GameRuleInfo.IntGameRuleInfo { + private final String ruleKey; + private final String displayName; + private final GameRuleNBT gameRuleNBT; + private final BooleanProperty modified = new SimpleBooleanProperty(this, "modified", false); + + private final Runnable onSave; + private Runnable resetValue = () -> { + }; + + private GameRuleInfo(GameRule gameRule, GameRuleNBT gameRuleNBT, Runnable onSave) { + ruleKey = gameRule.getRuleKey().get(0); + String displayName = ""; + try { + if (StringUtils.isNotBlank(gameRule.getDisplayI18nKey())) { + displayName = i18n(gameRule.getDisplayI18nKey()); + } + } catch (Exception e) { + LOG.warning("Failed to get i18n text for key: " + gameRule.getDisplayI18nKey(), e); + } + this.displayName = displayName; + this.gameRuleNBT = gameRuleNBT; + this.onSave = onSave; + } + + public static GameRuleInfo createGameRuleInfo(GameRule gameRule, GameRuleNBT gameRuleNBT, Runnable onSave, GameVersionNumber gameVersion) { + if (gameRule instanceof GameRule.IntGameRule intGameRule) { + @SuppressWarnings("unchecked") var typedGameRuleNBT = (GameRuleNBT) gameRuleNBT; + return new GameRuleInfo.IntGameRuleInfo(intGameRule, typedGameRuleNBT, onSave, gameVersion); + } else if (gameRule instanceof GameRule.BooleanGameRule booleanGameRule) { + @SuppressWarnings("unchecked") var typedGameRuleNBT = (GameRuleNBT) gameRuleNBT; + return new GameRuleInfo.BooleanGameRuleInfo(booleanGameRule, typedGameRuleNBT, onSave, gameVersion); + } + return null; + } + + public abstract String getCurrentValueText(); + + public abstract String getDefaultValueText(); + + public void resetValue() { + resetValue.run(); + } + + public void save() { + onSave.run(); + } + + public String getRuleKey() { + return ruleKey; + } + + public String getDisplayName() { + return displayName; + } + + public GameRuleNBT getGameRuleNBT() { + return gameRuleNBT; + } + + public Runnable getOnSave() { + return onSave; + } + + public Runnable getResetValue() { + return resetValue; + } + + public void setResetValue(Runnable resetValue) { + this.resetValue = resetValue; + } + + public BooleanProperty modifiedProperty() { + return modified; + } + + public boolean getModified() { + return modified.get(); + } + + static final class BooleanGameRuleInfo extends GameRuleInfo { + private final BooleanProperty currentValue; + private final Boolean defaultValue; + + public BooleanGameRuleInfo(GameRule.BooleanGameRule booleanGameRule, GameRuleNBT gameRuleNBT, Runnable onSave, GameVersionNumber gameVersionNumber) { + super(booleanGameRule, gameRuleNBT, onSave); + this.currentValue = new SimpleBooleanProperty(booleanGameRule.getValue()); + this.defaultValue = booleanGameRule.getDefaultValue(gameVersionNumber).orElse(null); + + currentValue.addListener((observable, oldValue, newValue) -> { + getGameRuleNBT().changeValue(newValue); + save(); + }); + + if (defaultValue != null) { + setResetValue(() -> currentValue.set(defaultValue)); + modifiedProperty().bind(Bindings.createBooleanBinding(() -> currentValue.getValue() != defaultValue, currentValue)); + } + + } + + @Override + public String getCurrentValueText() { + return currentValue.getValue().toString(); + } + + @Override + public String getDefaultValueText() { + return defaultValue == null ? "" : defaultValue.toString(); + } + + public BooleanProperty currentValueProperty() { + return currentValue; + } + } + + static final class IntGameRuleInfo extends GameRuleInfo { + private final StringProperty currentValue; + private final Integer defaultValue; + private final int minValue; + private final int maxValue; + + public IntGameRuleInfo(GameRule.IntGameRule intGameRule, GameRuleNBT gameRuleNBT, Runnable onSave, GameVersionNumber gameVersionNumber) { + super(intGameRule, gameRuleNBT, onSave); + currentValue = new SimpleStringProperty(String.valueOf(intGameRule.getValue())); + defaultValue = intGameRule.getDefaultValue(gameVersionNumber).orElse(null); + minValue = intGameRule.getMinValue(gameVersionNumber); + maxValue = intGameRule.getMaxValue(gameVersionNumber); + + currentValue.addListener((observable, oldValue, newValue) -> { + Integer value = Lang.toIntOrNull(newValue); + if (value != null && value >= minValue && value <= maxValue) { + getGameRuleNBT().changeValue(newValue); + save(); + } + }); + + if (defaultValue != null) { + setResetValue(() -> currentValue.set(String.valueOf(defaultValue))); + modifiedProperty().bind(Bindings.createBooleanBinding(() -> !Objects.equals(Lang.toIntOrNull(currentValue.getValue()), defaultValue), currentValue)); + } + } + + @Override + public String getCurrentValueText() { + return currentValue.getValue(); + } + + @Override + public String getDefaultValueText() { + return defaultValue == null ? null : defaultValue.toString(); + } + + public StringProperty currentValueProperty() { + return currentValue; + } + + public int getMinValue() { + return minValue; + } + + public int getMaxValue() { + return maxValue; + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameRulePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameRulePage.java new file mode 100644 index 0000000000..9253929329 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameRulePage.java @@ -0,0 +1,219 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.versions; + +import com.github.steveice10.opennbt.tag.builtin.CompoundTag; +import javafx.animation.PauseTransition; +import javafx.beans.Observable; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.collections.transformation.FilteredList; +import javafx.scene.control.Skin; +import javafx.util.Duration; +import org.jackhuang.hmcl.game.World; +import org.jackhuang.hmcl.gamerule.GameRule; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.ListPageBase; +import org.jackhuang.hmcl.util.StringUtils; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Locale; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public class GameRulePage extends ListPageBase> { + + private final WorldManagePage worldManagePage; + private final World world; + private CompoundTag levelDat; + + private final ObservableList> gameRuleList; + private final FilteredList> modifiedItems = new FilteredList<>(getItems(), GameRuleInfo::getModified); + private final ObservableList> modifiedList = FXCollections.observableArrayList(); + private final FilteredList> displayedItems = new FilteredList<>(modifiedList); + + private boolean batchUpdating = false; + private final PauseTransition saveLevelDatPause; + + public GameRulePage(WorldManagePage worldManagePage) { + this.worldManagePage = worldManagePage; + this.world = worldManagePage.getWorld(); + + gameRuleList = FXCollections.observableArrayList(gameRule -> { + if (gameRule instanceof GameRuleInfo.BooleanGameRuleInfo booleanGameRuleInfo) { + return new Observable[]{booleanGameRuleInfo.currentValueProperty()}; + } else if (gameRule instanceof GameRuleInfo.IntGameRuleInfo intGameRuleInfo) { + return new Observable[]{intGameRuleInfo.currentValueProperty()}; + } + return new Observable[]{}; + }); + setItems(gameRuleList); + + this.setLoading(true); + Task.supplyAsync(this::loadWorldInfo) + .whenComplete(Schedulers.javafx(), ((result, exception) -> { + if (exception == null) { + this.levelDat = result; + updateControls(); + setLoading(false); + } else { + LOG.warning("Failed to load level.dat", exception); + setFailedReason(i18n("world.info.failed")); + } + })).start(); + + saveLevelDatPause = new PauseTransition(Duration.millis(300)); + saveLevelDatPause.setOnFinished(event -> saveLevelDat()); + } + + public void updateControls() { + CompoundTag dataTag = levelDat.get("Data"); + CompoundTag gameRuleCompoundTag = dataTag.get("game_rules"); + if (gameRuleCompoundTag == null) { + gameRuleCompoundTag = dataTag.get("GameRules"); + } + + if (gameRuleCompoundTag == null) { + LOG.warning("Neither 'game_rules' nor 'GameRules' tag found in level.dat"); + return; + } + + gameRuleCompoundTag.iterator().forEachRemaining(gameRuleTag -> { + GameRule.createGameRuleNBT(gameRuleTag).ifPresent(gameRuleNBT -> { + GameRule.getFullGameRule(gameRuleTag).ifPresent(gameRule -> { + gameRuleList.add(GameRuleInfo.createGameRuleInfo(gameRule, gameRuleNBT, this::saveLevelDatIfNotInBatchUpdating, world.getGameVersion())); + }); + }); + }); + applyModifiedFilter(RuleModifiedType.ALL); + } + + @Override + protected Skin createDefaultSkin() { + return new GameRulePageSkin(this); + } + + public ObservableList> getModifiedList() { + return modifiedList; + } + + public void applyModifiedFilter(RuleModifiedType type) { + switch (type) { + case ALL -> modifiedList.setAll(getItems()); + case MODIFIED -> modifiedList.setAll(modifiedItems); + case UNMODIFIED -> { + modifiedList.setAll(getItems().stream() + .filter(gameRuleInfo -> !modifiedItems.contains(gameRuleInfo)) + .collect(Collectors.toSet())); + } + } + } + + public FilteredList> getModifiedItems() { + return modifiedItems; + } + + public FilteredList> getDisplayedItems() { + return displayedItems; + } + + public boolean isBatchUpdating() { + return batchUpdating; + } + + public void setBatchUpdating(boolean isResettingAll) { + this.batchUpdating = isResettingAll; + } + + private CompoundTag loadWorldInfo() throws IOException { + if (!Files.isDirectory(world.getFile())) + throw new IOException("Not a valid world directory"); + + return world.getLevelData(); + } + + void saveLevelDat() { + LOG.info("Saving level.dat of world " + world.getWorldName()); + world.writeLevelDatAsync(); + } + + void requestSaveLevelDat() { + saveLevelDatPause.playFromStart(); + } + + void saveLevelDatIfNotInBatchUpdating() { + if (!batchUpdating) { + requestSaveLevelDat(); + } + } + + void resettingAllGameRule() { + batchUpdating = true; + for (GameRuleInfo gameRuleInfo : getItems()) { + gameRuleInfo.resetValue(); + } + saveLevelDat(); + batchUpdating = false; + Controllers.showToast(i18n("gamerule.restore_default_values_all.finish.toast")); + } + + void updateSearchPredicate(String queryString) { + displayedItems.setPredicate(updatePredicate(queryString)); + } + + @NotNull private Predicate> updatePredicate(String queryString) { + if (StringUtils.isBlank(queryString)) { + return gameRuleInfo -> true; + } + + final Predicate stringPredicate; + if (queryString.startsWith("regex:")) { + try { + Pattern pattern = Pattern.compile(StringUtils.substringAfter(queryString, "regex:")); + stringPredicate = s -> s != null && pattern.matcher(s).find(); + } catch (Exception e) { + return gameRuleInfo -> false; + } + } else { + String lowerCaseFilter = queryString.toLowerCase(Locale.ROOT); + stringPredicate = s -> s != null && s.toLowerCase(Locale.ROOT).contains(lowerCaseFilter); + } + + return gameRuleInfo -> stringPredicate.test(gameRuleInfo.getDisplayName()) || stringPredicate.test(gameRuleInfo.getRuleKey()); + } + + enum RuleModifiedType { + ALL, MODIFIED, UNMODIFIED; + + static final ObservableList items = FXCollections.observableList(Arrays.asList(values())); + + @Override + public String toString() { + return i18n("gamerule.filter." + name().toLowerCase(Locale.ROOT)); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameRulePageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameRulePageSkin.java new file mode 100644 index 0000000000..a21f8e033a --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameRulePageSkin.java @@ -0,0 +1,315 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.versions; + +import com.jfoenix.controls.*; +import javafx.animation.PauseTransition; +import javafx.collections.transformation.FilteredList; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.SkinBase; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.*; +import javafx.stage.Stage; +import javafx.util.Duration; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.construct.*; +import org.jackhuang.hmcl.util.StringUtils; + +import java.util.HashMap; +import java.util.Map; + +import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +class GameRulePageSkin extends SkinBase { + + private final HBox toolBar; + private final JFXTextField searchField; + private final JFXListView> listView = new JFXListView<>(); + private final Map cellMap = new HashMap<>(); + + GameRulePageSkin(GameRulePage skinnable) { + super(skinnable); + ComponentList root = new ComponentList(); + StackPane pane = new StackPane(root); + { + pane.setPadding(new Insets(10)); + pane.getStyleClass().add("notice-pane"); + root.getStyleClass().add("no-padding"); + + getChildren().add(pane); + } + + { + toolBar = new HBox(); + toolBar.setAlignment(Pos.CENTER); + toolBar.setPadding(new Insets(0, 5, 0, 5)); + toolBar.setSpacing(5); + + JFXComboBox viewFilterComboBox = new JFXComboBox<>(GameRulePage.RuleModifiedType.items); + viewFilterComboBox.setValue(GameRulePage.RuleModifiedType.ALL); + // Changes to the modifiedList are only applied at the time a type is manually selected; this is by design. + viewFilterComboBox.valueProperty().addListener((observable, oldValue, newValue) -> { + getSkinnable().applyModifiedFilter(newValue); + }); + + searchField = new JFXTextField(); + { + searchField.setPromptText(i18n("search")); + PauseTransition searchPause = new PauseTransition(Duration.millis(1000)); + searchPause.setOnFinished(event -> getSkinnable().updateSearchPredicate(searchField.getText())); + searchField.textProperty().addListener((observable) -> searchPause.playFromStart()); + HBox.setHgrow(searchField, Priority.ALWAYS); + } + + JFXButton resetAllButton = createToolbarButton2(i18n("gamerule.restore_default_values_all"), SVG.RESTORE, + () -> Controllers.dialog(new ResetDefaultValuesLayout(skinnable::resettingAllGameRule, getSkinnable().getModifiedItems(), () -> getSkinnable().applyModifiedFilter(viewFilterComboBox.getSelectionModel().getSelectedItem())))); + + toolBar.getChildren().addAll(searchField, new Label(i18n("gamerule.filter")), viewFilterComboBox, resetAllButton); + root.getContent().add(toolBar); + } + + SpinnerPane center = new SpinnerPane(); + { + ComponentList.setVgrow(center, Priority.ALWAYS); + center.getStyleClass().add("large-spinner-pane"); + center.setContent(listView); + + listView.setItems(getSkinnable().getDisplayedItems()); + listView.setCellFactory(x -> new GameRuleListCell(listView, cellMap)); + FXUtils.ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE); + + root.getContent().add(center); + } + } + + static class GameRuleListCell extends MDListCell> { + private final Map cellMap; + + public GameRuleListCell(JFXListView> listView, Map cellMap) { + super(listView); + this.cellMap = cellMap; + } + + @Override + protected void updateControl(GameRuleInfo item, boolean empty) { + if (empty) return; + + HBox hBox = cellMap.computeIfAbsent(item.getRuleKey(), key -> { + if (item instanceof GameRuleInfo.IntGameRuleInfo intInfo) { + return buildNodeForIntGameRule(intInfo); + } else if (item instanceof GameRuleInfo.BooleanGameRuleInfo booleanInfo) { + return buildNodeForBooleanGameRule(booleanInfo); + } + return null; + }); + if (hBox != null) { + getContainer().getChildren().setAll(hBox); + } + } + + private HBox buildNodeForIntGameRule(GameRuleInfo.IntGameRuleInfo gameRule) { + HBox cellBox = new HBox(); + { + cellBox.setPadding(new Insets(8, 8, 8, 16)); + HBox.setHgrow(cellBox, Priority.ALWAYS); + cellBox.setAlignment(Pos.CENTER_LEFT); + } + + VBox displayInfoVBox = new VBox(); + { + if (StringUtils.isNotBlank(gameRule.getDisplayName())) { + Label displayNameLabel = new Label(gameRule.getDisplayName()); + Label ruleKeyLabel = new Label(gameRule.getRuleKey()); + ruleKeyLabel.getStyleClass().add("subtitle"); + + displayInfoVBox.getChildren().addAll(displayNameLabel, ruleKeyLabel); + } else { + displayInfoVBox.getChildren().addAll(new Label(gameRule.getRuleKey())); + } + displayInfoVBox.setAlignment(Pos.CENTER_LEFT); + HBox.setHgrow(displayInfoVBox, Priority.ALWAYS); + } + + HBox rightHBox = new HBox(); + { + rightHBox.setSpacing(12); + rightHBox.setAlignment(Pos.CENTER_LEFT); + } + + JFXTextField textField = new JFXTextField(); + { + textField.textProperty().bindBidirectional(gameRule.currentValueProperty()); + FXUtils.setValidateWhileTextChanged(textField, true); + textField.setValidators( + new NumberValidator(i18n("input.integer"), false), + new NumberRangeValidator(i18n("input.number_range", gameRule.getMinValue(), gameRule.getMaxValue()), gameRule.getMinValue(), gameRule.getMaxValue())); + + textField.setPrefWidth(150); + } + + rightHBox.getChildren().addAll(textField, buildResetButton(gameRule)); + cellBox.getChildren().addAll(displayInfoVBox, rightHBox); + + return cellBox; + } + + private HBox buildNodeForBooleanGameRule(GameRuleInfo.BooleanGameRuleInfo gameRule) { + HBox cellBox = new HBox(); + { + HBox.setHgrow(cellBox, Priority.ALWAYS); + cellBox.setAlignment(Pos.CENTER_LEFT); + cellBox.setPadding(new Insets(0, 8, 0, 0)); + } + + OptionToggleButton toggleButton = new OptionToggleButton(); + { + if (StringUtils.isNotBlank(gameRule.getDisplayName())) { + toggleButton.setTitle(gameRule.getDisplayName()); + toggleButton.setSubtitle(gameRule.getRuleKey()); + } else { + toggleButton.setTitle(gameRule.getRuleKey()); + } + HBox.setHgrow(toggleButton, Priority.ALWAYS); + toggleButton.selectedProperty().bindBidirectional(gameRule.currentValueProperty()); + } + + cellBox.getChildren().addAll(toggleButton, buildResetButton(gameRule)); + + return cellBox; + } + + private StackPane buildResetButton(GameRuleInfo gameRule) { + JFXButton resetButton = new JFXButton(); + StackPane wrapperPane = new StackPane(resetButton); + { + wrapperPane.setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); + resetButton.setFocusTraversable(false); + resetButton.setGraphic(SVG.RESTORE.createIcon(24)); + resetButton.getStyleClass().add("toggle-icon4"); + if (StringUtils.isNotBlank(gameRule.getDefaultValueText())) { + resetButton.setOnAction(event -> gameRule.resetValue()); + resetButton.disableProperty().bind(gameRule.modifiedProperty().not()); + FXUtils.installFastTooltip(resetButton, i18n("gamerule.restore_default_values.tooltip", gameRule.getDefaultValueText())); + FXUtils.installFastTooltip(wrapperPane, i18n("gamerule.now_is_default_values.tooltip")); + } else { + resetButton.setDisable(true); + FXUtils.installFastTooltip(wrapperPane, i18n("gamerule.not_have_default_values.tooltip")); + } + } + return wrapperPane; + } + } + + static class ResetDefaultValuesLayout extends JFXDialogLayout { + public ResetDefaultValuesLayout(Runnable resettingAllGameRule, FilteredList> modifiedItems, Runnable callBack) { + + { + Stage stage = Controllers.getStage(); + maxWidthProperty().bind(stage.widthProperty().multiply(0.7)); + maxHeightProperty().bind(stage.heightProperty().multiply(0.7)); + } + + //heading area + setHeading(new Label(i18n("gamerule.restore_default_values_all"))); + + //body area + VBox vBox = new VBox(); + { + vBox.setSpacing(10); + setBody(vBox); + } + { + Label warnLabel = modifiedItems.isEmpty() ? new Label(i18n("gamerule.all_is_default")) : new Label(i18n("gamerule.restore_default_values_all.confirm")); + vBox.getChildren().add(warnLabel); + + if (!modifiedItems.isEmpty()) { + MenuUpDownButton showDetailButton = new MenuUpDownButton(); + { + showDetailButton.setText(i18n("gamerule.show_modified_details.button")); + showDetailButton.setMaxWidth(USE_PREF_SIZE); + + vBox.getChildren().add(showDetailButton); + } + + GridPane gridPane = new GridPane(); + { + gridPane.addRow(0, + new Label(i18n("gamerule.column.name")), + new Label(i18n("gamerule.column.current")), + new Label("", SVG.ARROW_FORWARD.createIcon(12)), + new Label(i18n("gamerule.column.default"))); + + for (int i = 0; i < modifiedItems.size(); i++) { + GameRuleInfo gameRuleInfo = modifiedItems.get(i); + String displayName = StringUtils.isNotBlank(gameRuleInfo.getDisplayName()) ? gameRuleInfo.getDisplayName() : gameRuleInfo.getRuleKey(); + gridPane.addRow(i + 1, + new Label(displayName), + new Label(gameRuleInfo.getCurrentValueText()), + new Label("", SVG.ARROW_FORWARD.createIcon(12)), + new Label(gameRuleInfo.getDefaultValueText())); + } + } + + ScrollPane scrollPane = new ScrollPane(gridPane); + { + gridPane.setHgap(10); + gridPane.setVgap(10); + gridPane.setPadding(new Insets(0, 5, 10, 0)); + + scrollPane.visibleProperty().bind(showDetailButton.selectedProperty()); + scrollPane.managedProperty().bind(showDetailButton.selectedProperty()); + VBox.setMargin(scrollPane, new Insets(5, 8, 5, 8)); + FXUtils.smoothScrolling(scrollPane); + + vBox.getChildren().add(scrollPane); + } + } + } + + //action area + JFXButton accept = new JFXButton(i18n("button.ok")); + { + accept.getStyleClass().add("dialog-accept"); + if (!modifiedItems.isEmpty()) { + accept.setOnAction(event -> { + resettingAllGameRule.run(); + callBack.run(); + fireEvent(new DialogCloseEvent()); + }); + } else { + accept.setOnAction(event -> fireEvent(new DialogCloseEvent())); + } + } + JFXButton reject = new JFXButton(i18n("button.cancel")); + { + reject.getStyleClass().add("dialog-cancel"); + reject.setOnAction(event -> fireEvent(new DialogCloseEvent())); + } + setActions(accept, reject); + + FXUtils.onEscPressed(this, reject::fire); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java index 30c06ee7fa..767f70b704 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldInfoPage.java @@ -549,11 +549,7 @@ private void setTagAndTextField(FloatTag floatTag, JFXTextField jfxTextField) { private void saveLevelDat() { LOG.info("Saving level.dat of world " + world.getWorldName()); - try { - this.world.writeLevelDat(levelDat); - } catch (IOException e) { - LOG.warning("Failed to save level.dat of world " + world.getWorldName(), e); - } + this.world.writeLevelDatAsync(); } private record Dimension(String name) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java index 848beaeed3..b6f2932509 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/WorldManagePage.java @@ -60,6 +60,7 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco private final TabHeader header; private final TabHeader.Tab worldInfoTab = new TabHeader.Tab<>("worldInfoPage"); + private final TabHeader.Tab gameRuleTab = new TabHeader.Tab<>("gameRulePage"); private final TabHeader.Tab worldBackupsTab = new TabHeader.Tab<>("worldBackupsPage"); private final TabHeader.Tab datapackTab = new TabHeader.Tab<>("datapackListPage"); @@ -82,11 +83,12 @@ public WorldManagePage(World world, Path backupsDir, Profile profile, String id) } this.worldInfoTab.setNodeSupplier(() -> new WorldInfoPage(this)); + gameRuleTab.setNodeSupplier(() -> new GameRulePage(this)); this.worldBackupsTab.setNodeSupplier(() -> new WorldBackupsPage(this)); this.datapackTab.setNodeSupplier(() -> new DatapackListPage(this)); this.state = new SimpleObjectProperty<>(State.fromTitle(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())))); - this.header = new TabHeader(transitionPane, worldInfoTab, worldBackupsTab); + this.header = new TabHeader(transitionPane, worldInfoTab, gameRuleTab, worldBackupsTab); header.select(worldInfoTab); setCenter(transitionPane); @@ -98,6 +100,7 @@ public WorldManagePage(World world, Path backupsDir, Profile profile, String id) AdvancedListBox sideBar = new AdvancedListBox() .addNavigationDrawerTab(header, worldInfoTab, i18n("world.info"), SVG.INFO, SVG.INFO_FILL) + .addNavigationDrawerTab(header, gameRuleTab, i18n("gamerule"), SVG.RULE) .addNavigationDrawerTab(header, worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL); if (world.getGameVersion() != null && // old game will not write game version to level.dat diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index d089bdc2b4..f36dff2734 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -687,12 +687,97 @@ game.crash.title=Game Crashed game.directory=Game Path game.version=Game Instance +gamerule=Game Rule +gamerule.all_is_default=All game rules are currently at default values +gamerule.column.current=Current Value +gamerule.column.default=Default Value +gamerule.column.name=Rule Name +gamerule.filter=Filter +gamerule.filter.all=All +gamerule.filter.modified=Modified +gamerule.filter.unmodified=Unmodified +gamerule.restore_default_values_all=Reset all to game defaults +gamerule.restore_default_values_all.confirm=Are you sure you want to reset all rules to the game's default? This action cannot be undone! +gamerule.restore_default_values_all.finish.toast=All game rules have been reset to default values +gamerule.restore_default_values.tooltip=Reset this rule to game default.\nDefault: %s +gamerule.now_is_default_values.tooltip=Already at default. +gamerule.not_have_default_values.tooltip=Default value not found. +gamerule.show_modified_details.button=Display Change List + +gamerule.rule.advance_time=Advance Time +gamerule.rule.advance_weather=Advance Weather +gamerule.rule.allow_fire_ticks_away_from_player=Allow Fire Ticks Away From Player +gamerule.rule.allow_entering_nether_using_portals=Allow Entering Nether Using Portals +gamerule.rule.block_drops=Block Drops +gamerule.rule.block_explosion_drop_decay=Block Explosion Drop Decay +gamerule.rule.command_block_output=Command Block Output +gamerule.rule.command_blocks_work=Enable Command Blocks +gamerule.rule.disable_elytra_movement_check=Disable Elytra Movement Check +gamerule.rule.disable_raids=Disable Raids +gamerule.rule.do_fire_tick=Fire Tick +gamerule.rule.drowning_damage=Drowning Damage +gamerule.rule.elytra_movement_check=Elytra Movement Check +gamerule.rule.ender_pearls_vanish_on_death=Ender Pearls Vanish on Death +gamerule.rule.entity_drops=Entity Drops +gamerule.rule.entities_with_passengers_can_use_portals=Allow ridden entities to use portals +gamerule.rule.fall_damage=Fall Damage +gamerule.rule.fire_damage=Fire Damage +gamerule.rule.fire_spread_radius_around_player=Fire Spread Radius Around Player +gamerule.rule.forgive_dead_players=Forgive Dead Players +gamerule.rule.freeze_damage=Freeze Damage +gamerule.rule.global_sound_events=Global Sound Events +gamerule.rule.immediate_respawn=Immediate Respawn +gamerule.rule.keep_inventory=Keep Inventory +gamerule.rule.lava_source_conversion=Lava Source Conversion +gamerule.rule.limited_crafting=Limited Crafting +gamerule.rule.locator_bar=Enable Locator Bar +gamerule.rule.log_admin_commands=Log Admin Commands +gamerule.rule.max_block_modifications=Command Modification Block Limit +gamerule.rule.max_command_forks=Max Command Fork Count +gamerule.rule.max_command_sequence_length=Max Command Chain Length +gamerule.rule.max_entity_cramming=Max Entity Cramming +gamerule.rule.max_minecart_speed=Minecart Max Speed +gamerule.rule.max_snow_accumulation_height=Snow Accumulation Height +gamerule.rule.mob_drops=Mob Drops +gamerule.rule.mob_explosion_drop_decay=Mob Explosion Drop Decay +gamerule.rule.mob_griefing=Mob Griefing +gamerule.rule.natural_health_regeneration=Natural Health Regeneration +gamerule.rule.player_movement_check=Player Movement Check +gamerule.rule.players_nether_portal_creative_delay=Players Nether Portal Creative Delay +gamerule.rule.players_nether_portal_default_delay=Players Nether Portal Default Delay +gamerule.rule.players_sleeping_percentage=Players Sleeping Percentage +gamerule.rule.projectiles_can_break_blocks=Projectiles Can Break Blocks +gamerule.rule.pvp=Enable PvP +gamerule.rule.raids=Enable Raids +gamerule.rule.random_tick_speed=Random Tick Speed +gamerule.rule.reduced_debug_info=Reduced Debug Info +gamerule.rule.respawn_radius=Respawn Radius +gamerule.rule.send_command_feedback=Send Command Feedback +gamerule.rule.show_advancement_messages=Show Advancement Messages +gamerule.rule.show_death_messages=Show Death Messages +gamerule.rule.spawn_chunk_radius=Spawn Chunk Radius +gamerule.rule.spawn_mobs=Spawn Mobs +gamerule.rule.spawn_monsters=Spawn Monsters +gamerule.rule.spawn_patrols=Spawn Patrols +gamerule.rule.spawn_phantoms=Spawn Phantoms +gamerule.rule.spawn_wandering_traders=Spawn Wandering Traders +gamerule.rule.spawn_wardens=Spawn Wardens +gamerule.rule.spawner_blocks_work=Enable Spawner Blocks +gamerule.rule.spectators_generate_chunks=Spectators Generate Chunks +gamerule.rule.spread_vines=Spread Vines +gamerule.rule.tnt_explodes=TNT Explodes +gamerule.rule.tnt_explosion_drop_decay=TNT Explosion Drop Decay +gamerule.rule.universal_anger=Universal Anger +gamerule.rule.water_source_conversion=Water Source Conversion + help=Help help.doc=Hello Minecraft! Launcher Documentation help.detail=For datapack and modpack makers. input.email=The username must be an email address. input.number=The input must be numbers. +input.integer=Must be an integer. +input.number_range=Range: %d to %d. input.not_empty=This is a required field. input.url=The input must be a valid URL. diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index c49e578014..66f06c842e 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -486,12 +486,97 @@ game.crash.title=遊戲意外退出 game.directory=遊戲目錄路徑 game.version=遊戲實例 +gamerule=遊戲規則 +gamerule.all_is_default=目前所有遊戲規則已處於預設狀態 +gamerule.column.current=目前值 +gamerule.column.default=預設值 +gamerule.column.name=規則名稱 +gamerule.filter=篩選 +gamerule.filter.all=全部 +gamerule.filter.modified=已變更 +gamerule.filter.unmodified=未變更 +gamerule.restore_default_values_all=復原遊戲預設規則 +gamerule.restore_default_values_all.confirm=你確定要復原所有規則至遊戲預設嗎?該操作無法復原! +gamerule.restore_default_values_all.finish.toast=已將所有遊戲規則復原至預設值 +gamerule.restore_default_values.tooltip=復原此項預設規則\n預設值: %s +gamerule.now_is_default_values.tooltip=目前已是預設值 +gamerule.not_have_default_values.tooltip=未找到預設值 +gamerule.show_modified_details.button=顯示變更清單 + +gamerule.rule.advance_time=日夜交替 +gamerule.rule.advance_weather=更新天氣 +gamerule.rule.allow_fire_ticks_away_from_player=允許火在遠離玩家處蔓延 +gamerule.rule.allow_entering_nether_using_portals=允許進入地獄 +gamerule.rule.block_drops=掉落方塊 +gamerule.rule.block_explosion_drop_decay=在與方塊互動的爆炸中,部分方塊不會掉落成戰利品 +gamerule.rule.command_block_output=記錄指令方塊輸出 +gamerule.rule.command_blocks_work=啟用指令方塊 +gamerule.rule.disable_elytra_movement_check=停用鞘翅移動檢測 +gamerule.rule.disable_raids=停用突襲 +gamerule.rule.do_fire_tick=更新火焰 +gamerule.rule.drowning_damage=造成溺水傷害 +gamerule.rule.elytra_movement_check=啟用鞘翅移動檢測 +gamerule.rule.ender_pearls_vanish_on_death=拋出的終界珍珠在死亡時消失 +gamerule.rule.entity_drops=掉落實體裝備 +gamerule.rule.entities_with_passengers_can_use_portals=被騎乘的實體能否使用傳送門 +gamerule.rule.fall_damage=造成摔落傷害 +gamerule.rule.fire_damage=造成火焰傷害 +gamerule.rule.fire_spread_radius_around_player=火焰蔓延半徑 +gamerule.rule.forgive_dead_players=原諒死者 +gamerule.rule.freeze_damage=造成冰凍傷害 +gamerule.rule.global_sound_events=全域聲音事件 +gamerule.rule.immediate_respawn=立即重生 +gamerule.rule.keep_inventory=死亡後保留物品欄 +gamerule.rule.lava_source_conversion=流動熔岩轉化成熔岩源 +gamerule.rule.limited_crafting=需要配方才能合成 +gamerule.rule.locator_bar=啟用玩家定位條 +gamerule.rule.log_admin_commands=記錄管理員指令 +gamerule.rule.max_block_modifications=指令修改方塊數量限制 +gamerule.rule.max_command_forks=指令的上下文上限 +gamerule.rule.max_command_sequence_length=指令連鎖大小限制 +gamerule.rule.max_entity_cramming=實體擠壓上限 +gamerule.rule.max_minecart_speed=礦車最大速度 +gamerule.rule.max_snow_accumulation_height=積雪厚度 +gamerule.rule.mob_drops=掉落生物戰利品 +gamerule.rule.mob_explosion_drop_decay=在生物的爆炸中,部分方塊不會掉落成戰利品 +gamerule.rule.mob_griefing=允許生物的破壞行為 +gamerule.rule.natural_health_regeneration=自然回血 +gamerule.rule.player_movement_check=啟用玩家移動檢測 +gamerule.rule.players_nether_portal_creative_delay=創造模式玩家使用地獄傳送門的等待時間 +gamerule.rule.players_nether_portal_default_delay=非創造模式玩家使用地獄傳送門的等待時間 +gamerule.rule.players_sleeping_percentage=睡眠比例 +gamerule.rule.projectiles_can_break_blocks=投射物是否能破壞方塊 +gamerule.rule.pvp=啟用 PvP +gamerule.rule.raids=啟用突襲 +gamerule.rule.random_tick_speed=隨機刻速率 +gamerule.rule.reduced_debug_info=簡化除錯資訊 +gamerule.rule.respawn_radius=重生點半徑 +gamerule.rule.send_command_feedback=回傳指令回饋 +gamerule.rule.show_advancement_messages=進度通知 +gamerule.rule.show_death_messages=顯示死亡訊息 +gamerule.rule.spawn_chunk_radius=出生區塊半徑 +gamerule.rule.spawn_mobs=生成生物 +gamerule.rule.spawn_monsters=生成怪物 +gamerule.rule.spawn_patrols=生成掠奪者巡邏隊 +gamerule.rule.spawn_phantoms=生成夜魅 +gamerule.rule.spawn_wandering_traders=生成流浪商人 +gamerule.rule.spawn_wardens=生成伏守者 +gamerule.rule.spawner_blocks_work=啟用生怪磚 +gamerule.rule.spectators_generate_chunks=允許旁觀者生成地形 +gamerule.rule.spread_vines=藤蔓蔓延 +gamerule.rule.tnt_explodes=允許 TNT 被點燃並爆炸 +gamerule.rule.tnt_explosion_drop_decay=在 TNT 的爆炸中,部分方塊不會掉落成戰利品 +gamerule.rule.universal_anger=無差別憤怒 +gamerule.rule.water_source_conversion=流動水轉化成水源 + help=說明 help.doc=Hello Minecraft! Launcher 說明文件 help.detail=可查閱資料包、模組包製作教學等內容 input.email=[使用者名稱] 必須是電子信箱格式 input.number=必須是數字 +input.integer=必須是整數 +input.number_range=有效範圍: %d~%d input.not_empty=必填 input.url=必須是有效連結 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 76e838140a..b41d1a5e76 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -490,12 +490,97 @@ game.crash.title=游戏意外退出 game.directory=游戏文件夹路径 game.version=游戏实例 +gamerule=游戏规则 +gamerule.all_is_default=当前所有游戏规则已处于默认状态 +gamerule.column.current=当前值 +gamerule.column.default=默认值 +gamerule.column.name=规则名称 +gamerule.filter=过滤 +gamerule.filter.all=全部 +gamerule.filter.modified=已修改 +gamerule.filter.unmodified=未修改 +gamerule.restore_default_values_all=恢复游戏默认规则 +gamerule.restore_default_values_all.confirm=你确定要恢复所有规则为游戏默认吗?此操作无法撤销! +gamerule.restore_default_values_all.finish.toast=已将所有游戏规则恢复至默认值 +gamerule.restore_default_values.tooltip=恢复此项默认规则\n默认值: %s +gamerule.now_is_default_values.tooltip=现在已是默认值 +gamerule.not_have_default_values.tooltip=未找到默认值 +gamerule.show_modified_details.button=显示变动列表 + +gamerule.rule.advance_time=游戏内时间流逝 +gamerule.rule.advance_weather=天气更替 +gamerule.rule.allow_fire_ticks_away_from_player=允许火在远离玩家处蔓延 +gamerule.rule.allow_entering_nether_using_portals=允许进入下界 +gamerule.rule.block_drops=方块掉落 +gamerule.rule.block_explosion_drop_decay=在方块交互爆炸中,一些方块不会掉落战利品 +gamerule.rule.command_block_output=广播命令方块输出 +gamerule.rule.command_blocks_work=启用命令方块 +gamerule.rule.disable_elytra_movement_check=禁用鞘翅移动检测 +gamerule.rule.disable_raids=禁用袭击 +gamerule.rule.do_fire_tick=火焰蔓延 +gamerule.rule.drowning_damage=溺水伤害 +gamerule.rule.elytra_movement_check=启用鞘翅移动检测 +gamerule.rule.ender_pearls_vanish_on_death=掷出的末影珍珠在死亡时消失 +gamerule.rule.entity_drops=非生物实体掉落 +gamerule.rule.entities_with_passengers_can_use_portals=被骑乘的实体能否使用传送门 +gamerule.rule.fall_damage=摔落伤害 +gamerule.rule.fire_damage=火焰伤害 +gamerule.rule.fire_spread_radius_around_player=火焰蔓延半径 +gamerule.rule.forgive_dead_players=宽恕死亡玩家 +gamerule.rule.freeze_damage=冰冻伤害 +gamerule.rule.global_sound_events=全局声音事件 +gamerule.rule.immediate_respawn=立即重生 +gamerule.rule.keep_inventory=死亡后保留物品栏 +gamerule.rule.lava_source_conversion=允许流动熔岩转化为熔岩源 +gamerule.rule.limited_crafting=合成需要配方 +gamerule.rule.locator_bar=启用玩家定位栏 +gamerule.rule.log_admin_commands=通告管理员命令 +gamerule.rule.max_block_modifications=命令修改方块数量限制 +gamerule.rule.max_command_forks=命令上下文数量限制 +gamerule.rule.max_command_sequence_length=命令连锁执行数量限制 +gamerule.rule.max_entity_cramming=实体挤压上限 +gamerule.rule.max_minecart_speed=矿车最大速度 +gamerule.rule.max_snow_accumulation_height=积雪厚度 +gamerule.rule.mob_drops=生物战利品掉落 +gamerule.rule.mob_explosion_drop_decay=在生物爆炸中,一些方块不会掉落战利品 +gamerule.rule.mob_griefing=允许破坏性生物行为 +gamerule.rule.natural_health_regeneration=生命值自然恢复 +gamerule.rule.player_movement_check=启用玩家移动检测 +gamerule.rule.players_nether_portal_creative_delay=创造模式下玩家在下界传送门中等待的时间 +gamerule.rule.players_nether_portal_default_delay=非创造模式下玩家在下界传送门中等待的时间 +gamerule.rule.players_sleeping_percentage=入睡占比 +gamerule.rule.projectiles_can_break_blocks=弹射物能否破坏方块 +gamerule.rule.pvp=启用PvP +gamerule.rule.raids=启用袭击 +gamerule.rule.random_tick_speed=随机刻速率 +gamerule.rule.reduced_debug_info=简化调试信息 +gamerule.rule.respawn_radius=重生点半径 +gamerule.rule.send_command_feedback=发送命令反馈 +gamerule.rule.show_advancement_messages=进度通知 +gamerule.rule.show_death_messages=显示死亡消息 +gamerule.rule.spawn_chunk_radius=出生区块半径 +gamerule.rule.spawn_mobs=生成生物 +gamerule.rule.spawn_monsters=生成怪物 +gamerule.rule.spawn_patrols=生成灾厄巡逻队 +gamerule.rule.spawn_phantoms=生成幻翼 +gamerule.rule.spawn_wandering_traders=生成流浪商人 +gamerule.rule.spawn_wardens=生成监守者 +gamerule.rule.spawner_blocks_work=启用刷怪笼方块 +gamerule.rule.spectators_generate_chunks=允许旁观者生成地形 +gamerule.rule.spread_vines=藤蔓蔓延 +gamerule.rule.tnt_explodes=允许TNT被点燃并爆炸 +gamerule.rule.tnt_explosion_drop_decay=在TNT爆炸中,一些方块不会掉落战利品 +gamerule.rule.universal_anger=无差别愤怒 +gamerule.rule.water_source_conversion=允许流动水转化为水源 + help=帮助 help.doc=Hello Minecraft! Launcher 帮助文档 help.detail=可查阅数据包、整合包制作指南等内容 input.email=用户名必须是邮箱 input.number=必须是数字 +input.integer=必须是整数 +input.number_range=有效范围: %d~%d input.not_empty=必填项 input.url=必须是合法的链接 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 24943caf07..f19bea7d20 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -20,6 +20,8 @@ import com.github.steveice10.opennbt.NBTIO; import com.github.steveice10.opennbt.tag.builtin.*; import javafx.scene.image.Image; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.io.*; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.Nullable; @@ -78,7 +80,7 @@ public String getWorldName() { public void setWorldName(String worldName) throws IOException { if (levelData.get("Data") instanceof CompoundTag data && data.get("LevelName") instanceof StringTag levelNameTag) { levelNameTag.setValue(worldName); - writeLevelDat(levelData); + writeLevelDat(); } } @@ -237,7 +239,7 @@ public void rename(String newName) throws IOException { // Change the name recorded in level.dat CompoundTag data = levelData.get("Data"); data.put(new StringTag("LevelName", newName)); - writeLevelDat(levelData); + writeLevelDat(); // then change the folder's name Files.move(file, file.resolveSibling(newName)); @@ -333,17 +335,26 @@ public FileChannel lock() throws WorldLockedException { } } - public void writeLevelDat(CompoundTag nbt) throws IOException { + public void writeLevelDat() throws IOException { if (!Files.isDirectory(file)) throw new IOException("Not a valid world directory"); FileUtils.saveSafely(getLevelDatFile(), os -> { try (OutputStream gos = new GZIPOutputStream(os)) { - NBTIO.writeTag(gos, nbt); + NBTIO.writeTag(gos, getLevelData()); } }); } + public void writeLevelDatAsync() { + Task.runAsync(Schedulers.io(), this::writeLevelDat) + .whenComplete(Schedulers.defaultScheduler(), ((result, exception) -> { + if (exception != null) { + LOG.warning("Failed to save level.dat of world " + getWorldName(), exception); + } + })).start(); + } + private static CompoundTag parseLevelDat(Path path) throws IOException { try (InputStream is = new GZIPInputStream(Files.newInputStream(path))) { Tag nbt = NBTIO.readTag(is); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/gamerule/GameRule.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/gamerule/GameRule.java new file mode 100644 index 0000000000..8b6db8fe0f --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/gamerule/GameRule.java @@ -0,0 +1,350 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.gamerule; + +import com.github.steveice10.opennbt.tag.builtin.ByteTag; +import com.github.steveice10.opennbt.tag.builtin.IntTag; +import com.github.steveice10.opennbt.tag.builtin.StringTag; +import com.github.steveice10.opennbt.tag.builtin.Tag; +import com.google.gson.*; +import com.google.gson.annotations.JsonAdapter; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.gson.JsonSerializable; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.IOUtils; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jackhuang.hmcl.util.versioning.VersionedValue; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Type; +import java.util.*; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Represents an abstract game rule in Minecraft (e.g., `doDaylightCycle`, `randomTickSpeed`). +/// +/// This class handles the logic for: +/// * Defining rule types (Boolean or Integer). +/// * Parsing rules from NBT tags (read from `level.dat`). +/// * Deserializing rules form `resources/assets/gamerule/gamerule.json` +/// +/// It is a sealed class permitting only [BooleanGameRule] and [IntGameRule]. +@JsonSerializable +@JsonAdapter(GameRule.GameRuleDeserializer.class) +public sealed abstract class GameRule permits GameRule.BooleanGameRule, GameRule.IntGameRule { + + private List ruleKey; + private String displayI18nKey = ""; + + protected GameRule() { + } + + protected GameRule(List ruleKey, String displayI18nKey) { + this.ruleKey = ruleKey; + this.displayI18nKey = displayI18nKey; + } + + /// Retrieves a fully populated GameRule based on an NBT tag. + /// + /// This combines parsing the tag [#createGameRuleNBT(Tag)] and applying known metadata + /// from the provided `gameRuleMap`. + public static Optional getFullGameRule(Tag tag) { + return createSimpleRuleFromTag(tag).map(simpleGameRule -> { + Optional.ofNullable(GameRuleHolder.metaDataGameRuleMap.get(tag.getName())) + .ifPresent(simpleGameRule::applyMetadata); + return simpleGameRule; + }); + } + + /// Parses an NBT Tag to create a corresponding [GameRule]. + /// + /// This method handles type coercion: + /// * [IntTag] -> [IntGameRule] + /// * [ByteTag] -> [BooleanGameRule] + /// * [StringTag] -> Tries to parse as [BooleanGameRule] ("true"/"false") or [IntGameRule]. + /// + /// @param tag The NBT tag to parse. + /// @return An Optional containing the GameRule if parsing was successful. + private static Optional createSimpleRuleFromTag(Tag tag) { + String name = tag.getName(); + + if (tag instanceof IntTag intTag) { + return Optional.of(new IntGameRule(name, intTag.getValue())); + } else if (tag instanceof ByteTag byteTag) { + return Optional.of(new BooleanGameRule(name, byteTag.getValue() == 1)); + } else if (tag instanceof StringTag stringTag) { + String value = stringTag.getValue(); + if ("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value)) { + return Optional.of(new BooleanGameRule(name, Boolean.parseBoolean(value))); + } + Integer intValue = Lang.toIntOrNull(value); + if (intValue != null) { + return Optional.of(new IntGameRule(name, intValue)); + } + } + + return Optional.empty(); + } + + /// Creates a [GameRuleNBT] wrapper around an NBT Tag. + /// Used for unified changing operations back to NBT format. + /// + /// @see GameRuleNBT + public static Optional> createGameRuleNBT(Tag tag) { + if (tag instanceof IntTag intTag) { + return Optional.of(new GameRuleNBT.IntGameRuleNBT(intTag)); + } else if (tag instanceof ByteTag byteTag) { + return Optional.of(new GameRuleNBT.ByteGameRuleNBT(byteTag)); + } else if (tag instanceof StringTag stringTag && (stringTag.getValue().equals("true") || stringTag.getValue().equals("false"))) { + return Optional.of(new GameRuleNBT.StringByteGameRuleNBT(stringTag)); + } else if (tag instanceof StringTag stringTag && Lang.toIntOrNull(stringTag.getValue()) != null) { + return Optional.of(new GameRuleNBT.StringIntGameRuleNBT(stringTag)); + } + return Optional.empty(); + } + + /// Copies metadata (descriptions, default value and ranges) from the source rule to this instance. + public abstract void applyMetadata(GameRule metadataSource); + + public abstract GameRule deserialize(JsonObject jsonObject, Type type, JsonDeserializationContext context); + + public void setRuleKey(List ruleKey) { + this.ruleKey = ruleKey; + } + + public List getRuleKey() { + return ruleKey; + } + + public void setDisplayI18nKey(String displayI18nKey) { + this.displayI18nKey = displayI18nKey; + } + + public String getDisplayI18nKey() { + return displayI18nKey; + } + + /// Implementation of a boolean-based GameRule. + public static final class BooleanGameRule extends GameRule { + private boolean value = false; + private final VersionedValue defaultValue = new VersionedValue<>(); + + private BooleanGameRule() { + } + + private BooleanGameRule(String ruleKey, boolean value) { + super(Collections.singletonList(ruleKey), ""); + this.value = value; + } + + @Override + public void applyMetadata(GameRule metadataSource) { + if (metadataSource instanceof BooleanGameRule source) { + this.setDisplayI18nKey(source.getDisplayI18nKey()); + this.defaultValue.putAll(source.defaultValue); + } + } + + @Override + public GameRule deserialize(JsonObject jsonObject, Type type, JsonDeserializationContext context) { + this.setRuleKey(JsonUtils.fromNonNullJson(jsonObject.get("ruleKey").toString(), JsonUtils.listTypeOf(String.class))); + this.setDisplayI18nKey(jsonObject.get("displayI18nKey").getAsString()); + JsonElement defaultValue = jsonObject.get("defaultValue"); + if (defaultValue instanceof JsonPrimitive p && p.isBoolean()) { + this.addDefaultValue(p.getAsBoolean()); + } else if (defaultValue instanceof JsonObject o) { + o.asMap().forEach((key, value) -> this.addDefaultValue(key, value.getAsBoolean())); + } + + return this; + } + + public Optional getDefaultValue(GameVersionNumber gameVersionNumber) { + return defaultValue.getValue(gameVersionNumber); + } + + private void addDefaultValue(boolean value) { + defaultValue.putMinVersion("1.4.2", value); + } + + private void addDefaultValue(String versionName, boolean value) { + defaultValue.putMinVersion(versionName, value); + } + + public boolean getValue() { + return value; + } + + public void setValue(boolean value) { + this.value = value; + } + } + + /// Implementation of an integer-based GameRule. + /// supports min/max value validation. + public static final class IntGameRule extends GameRule { + private int value = 0; + private final VersionedValue defaultValue = new VersionedValue<>(); + private final VersionedValue minValue = new VersionedValue<>(); + private final VersionedValue maxValue = new VersionedValue<>(); + + private IntGameRule() { + } + + private IntGameRule(String ruleKey, int value) { + super(Collections.singletonList(ruleKey), ""); + this.value = value; + addMaxValue(Integer.MAX_VALUE); + addMinValue(Integer.MIN_VALUE); + } + + @Override + public void applyMetadata(GameRule metadataSource) { + if (metadataSource instanceof IntGameRule source) { + this.setDisplayI18nKey(source.getDisplayI18nKey()); + this.defaultValue.putAll(source.defaultValue); + this.maxValue.putAll(source.maxValue); + this.minValue.putAll(source.minValue); + } + } + + @Override + public GameRule deserialize(JsonObject jsonObject, Type type, JsonDeserializationContext context) { + this.setRuleKey(JsonUtils.fromNonNullJson(jsonObject.get("ruleKey").toString(), JsonUtils.listTypeOf(String.class))); + this.setDisplayI18nKey(jsonObject.get("displayI18nKey").getAsString()); + + if (jsonObject.get("defaultValue") instanceof JsonPrimitive p && p.isNumber()) { + this.addDefaultValue(p.getAsInt()); + } else if (jsonObject.get("defaultValue") instanceof JsonObject o) { + o.asMap().forEach((key, value) -> this.addDefaultValue(key, value.getAsInt())); + } + + if (jsonObject.get("maxValue") instanceof JsonPrimitive jsonPrimitive) { + this.addMaxValue(jsonPrimitive.getAsInt()); + } else if (jsonObject.get("maxValue") instanceof JsonObject o) { + o.asMap().forEach((key, value) -> this.addMaxValue(key, parseValue(value))); + } else { + this.addMaxValue(Integer.MAX_VALUE); + } + + if (jsonObject.get("minValue") instanceof JsonPrimitive jsonPrimitive) { + this.addMinValue(jsonPrimitive.getAsInt()); + } else if (jsonObject.get("minValue") instanceof JsonObject o) { + o.asMap().forEach((key, value) -> this.addMinValue(key, parseValue(value))); + } else { + this.addMinValue(Integer.MIN_VALUE); + } + return this; + } + + private int parseValue(JsonElement jsonElement) { + JsonPrimitive primitive = jsonElement.getAsJsonPrimitive(); + if (primitive.isNumber()) { + return primitive.getAsInt(); + } else { + String str = primitive.getAsString(); + return "INT_MAX".equals(str) ? Integer.MAX_VALUE : Integer.MIN_VALUE; + } + } + + public Optional getDefaultValue(GameVersionNumber gameVersionNumber) { + return defaultValue.getValue(gameVersionNumber); + } + + private void addDefaultValue(int value) { + this.defaultValue.putMinVersion("1.4.2", value); + } + + private void addDefaultValue(String versionName, int value) { + this.defaultValue.putMinVersion(versionName, value); + } + + public int getValue() { + return value; + } + + public void setValue(int value) { + this.value = value; + } + + public int getMaxValue(GameVersionNumber gameVersionNumber) { + return this.maxValue.getValue(gameVersionNumber, Integer.MAX_VALUE); + } + + public void addMaxValue(int maxValue) { + this.maxValue.putMinVersion("1.4.2", maxValue); + } + + public void addMaxValue(String versionName, int maxValue) { + this.maxValue.putMinVersion(versionName, maxValue); + } + + public int getMinValue(GameVersionNumber gameVersionNumber) { + return minValue.getValue(gameVersionNumber, Integer.MIN_VALUE); + } + + public void addMinValue(int minValue) { + this.minValue.putMinVersion("1.4.2", minValue); + } + + public void addMinValue(String versionName, int minValue) { + this.minValue.putMinVersion(versionName, minValue); + } + } + + /// Custom GSON deserializer for [GameRule]. + /// Determines whether to create an [IntGameRule] or [BooleanGameRule] based on the JSON content. + static class GameRuleDeserializer implements JsonDeserializer { + + @Override + public GameRule deserialize(JsonElement jsonElement, Type type, JsonDeserializationContext context) throws JsonParseException { + JsonObject jsonObject = jsonElement.getAsJsonObject(); + GameRule gameRule; + switch (jsonObject.get("type").getAsString()) { + case "int" -> gameRule = new IntGameRule(); + case "boolean" -> gameRule = new BooleanGameRule(); + default -> throw new JsonParseException("Unknown GameRule type: " + jsonObject.get("type").getAsString()); + } + return gameRule.deserialize(jsonObject, type, context); + } + } + + static final class GameRuleHolder { + private static final Map metaDataGameRuleMap = new HashMap<>(); + + static { + try { + InputStream is = GameRule.class.getResourceAsStream("/assets/gamerule/gamerule.json"); + String jsonContent = IOUtils.readFullyAsString(is); + List gameRules = JsonUtils.fromNonNullJson(jsonContent, JsonUtils.listTypeOf(GameRule.class)); + + for (GameRule gameRule : gameRules) { + for (String s : gameRule.ruleKey) { + metaDataGameRuleMap.put(s, gameRule); + } + } + } catch (IOException e) { + LOG.warning("Cannot read gamerule.json" + e.getMessage()); + } catch (JsonParseException e) { + LOG.warning("Cannot parse gamerule.json" + e.getMessage()); + } + } + + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/gamerule/GameRuleNBT.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/gamerule/GameRuleNBT.java new file mode 100644 index 0000000000..e222ff6ff3 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/gamerule/GameRuleNBT.java @@ -0,0 +1,97 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.gamerule; + +import com.github.steveice10.opennbt.tag.builtin.ByteTag; +import com.github.steveice10.opennbt.tag.builtin.IntTag; +import com.github.steveice10.opennbt.tag.builtin.StringTag; +import com.github.steveice10.opennbt.tag.builtin.Tag; +import org.jackhuang.hmcl.util.Lang; + +/// A sealed abstract wrapper for a game rule stored in NBT. +/// +/// This class **holds a single NBT `Tag` instance** (`gameRuleTag`) and provides a unified API ([#changeValue(Object)]) +/// to update that tag’s underlying value from a higher-level Java value. +/// +/// @param The Java type used to represent the game rule's value (e.g., [String], [Boolean]). +/// @param The specific NBT [Tag] type that this object wraps and persists. +public sealed abstract class GameRuleNBT permits GameRuleNBT.IntGameRuleNBT, GameRuleNBT.ByteGameRuleNBT, GameRuleNBT.StringIntGameRuleNBT, GameRuleNBT.StringByteGameRuleNBT { + + private final V gameRuleTag; + + protected GameRuleNBT(V gameRuleTag) { + this.gameRuleTag = gameRuleTag; + } + + public abstract void changeValue(T newValue); + + public V getGameRuleTag() { + return gameRuleTag; + } + + static final class IntGameRuleNBT extends GameRuleNBT { + + public IntGameRuleNBT(IntTag gameRuleTag) { + super(gameRuleTag); + } + + @Override + public void changeValue(String newValue) { + Integer value = Lang.toIntOrNull(newValue); + if (value != null) { + getGameRuleTag().setValue(value); + } + } + } + + static final class ByteGameRuleNBT extends GameRuleNBT { + + public ByteGameRuleNBT(ByteTag gameRuleTag) { + super(gameRuleTag); + } + + @Override + public void changeValue(Boolean newValue) { + getGameRuleTag().setValue((byte) (newValue ? 1 : 0)); + } + } + + static final class StringIntGameRuleNBT extends GameRuleNBT { + + public StringIntGameRuleNBT(StringTag gameRuleTag) { + super(gameRuleTag); + } + + @Override + public void changeValue(String newValue) { + getGameRuleTag().setValue(newValue); + } + } + + static final class StringByteGameRuleNBT extends GameRuleNBT { + + public StringByteGameRuleNBT(StringTag gameRuleTag) { + super(gameRuleTag); + } + + @Override + public void changeValue(Boolean newValue) { + getGameRuleTag().setValue(newValue ? "true" : "false"); + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/versioning/VersionedValue.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/versioning/VersionedValue.java new file mode 100644 index 0000000000..addad45451 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/versioning/VersionedValue.java @@ -0,0 +1,71 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util.versioning; + +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; + +/** + * Represents a value that varies based on the game version. + *

+ * This class uses a {@link TreeMap} to store values associated with minimum version thresholds. + * When retrieving a value for a specific version, it returns the value associated with the + * highest version key that is less than or equal to the requested version. + *

+ * + * @param The type of the value being versioned. + */ +public class VersionedValue { + private final TreeMap versionValues = new TreeMap<>(); + + public VersionedValue() { + } + + public VersionedValue(String minVersion, T value) { + versionValues.put(GameVersionNumber.asGameVersion(minVersion), value); + } + + public void putMinVersion(String version, T value) { + versionValues.put(GameVersionNumber.asGameVersion(version), value); + } + + public Optional getValue(String version) { + return getValue(GameVersionNumber.asGameVersion(version)); + } + + public Optional getValue(GameVersionNumber version) { + return Optional.ofNullable(versionValues.floorEntry(version)).map(Map.Entry::getValue); + } + + public T getValue(String version, T defaultValue) { + return getValue(version).orElse(defaultValue); + } + + public T getValue(GameVersionNumber version, T defaultValue) { + return getValue(version).orElse(defaultValue); + } + + public TreeMap asMap() { + return versionValues; + } + + public void putAll(VersionedValue other) { + versionValues.putAll(other.asMap()); + } +} diff --git a/HMCLCore/src/main/resources/assets/gamerule/gamerule.json b/HMCLCore/src/main/resources/assets/gamerule/gamerule.json new file mode 100644 index 0000000000..9f490f6bf8 --- /dev/null +++ b/HMCLCore/src/main/resources/assets/gamerule/gamerule.json @@ -0,0 +1,628 @@ +[ + { + "ruleKey": [ + "minecraft:advance_time", + "doDaylightCycle" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.advance_time", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:advance_weather", + "doWeatherCycle" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.advance_weather", + "defaultValue": true + }, + { + "ruleKey": [ + "allowFireTicksAwayFromPlayer" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.allow_fire_ticks_away_from_player", + "defaultValue": false + }, + { + "ruleKey": [ + "minecraft:allow_entering_nether_using_portals", + "allowEnteringNetherUsingPortals" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.allow_entering_nether_using_portals", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:block_drops", + "doTileDrops" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.block_drops", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:block_explosion_drop_decay", + "blockExplosionDropDecay" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.block_explosion_drop_decay", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:command_block_output", + "commandBlockOutput" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.command_block_output", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:command_blocks_work", + "commandBlocksEnabled", + "enableCommandBlocks" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.command_blocks_work", + "defaultValue": true + }, + { + "ruleKey": [ + "disableRaids" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.disable_raids", + "defaultValue": false + }, + { + "ruleKey": [ + "doFireTick" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.do_fire_tick", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:drowning_damage", + "drowningDamage" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.drowning_damage", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:elytra_movement_check" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.elytra_movement_check", + "defaultValue": true + }, + { + "ruleKey": [ + "disableElytraMovementCheck" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.disable_elytra_movement_check", + "defaultValue": false + }, + { + "ruleKey": [ + "minecraft:ender_pearls_vanish_on_death", + "enderPearlsVanishOnDeath" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.ender_pearls_vanish_on_death", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:entity_drops", + "doEntityDrops" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.entity_drops", + "defaultValue": true + }, + { + "ruleKey": [ + "entitiesWithPassengersCanUsePortals" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.entities_with_passengers_can_use_portals", + "defaultValue": false + }, + { + "ruleKey": [ + "minecraft:fall_damage", + "fallDamage" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.fall_damage", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:fire_damage", + "fireDamage" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.fire_damage", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:fire_spread_radius_around_player" + ], + "type": "int", + "displayI18nKey": "gamerule.rule.fire_spread_radius_around_player", + "defaultValue": 128, + "minValue": -1 + }, + { + "ruleKey": [ + "minecraft:forgive_dead_players", + "forgiveDeadPlayers" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.forgive_dead_players", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:freeze_damage", + "freezeDamage" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.freeze_damage", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:global_sound_events", + "globalSoundEvents" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.global_sound_events", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:immediate_respawn", + "doImmediateRespawn" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.immediate_respawn", + "defaultValue": false + }, + { + "ruleKey": [ + "minecraft:keep_inventory", + "keepInventory" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.keep_inventory", + "defaultValue": false + }, + { + "ruleKey": [ + "minecraft:lava_source_conversion", + "lavaSourceConversion" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.lava_source_conversion", + "defaultValue": false + }, + { + "ruleKey": [ + "minecraft:limited_crafting", + "doLimitedCrafting" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.limited_crafting", + "defaultValue": false + }, + { + "ruleKey": [ + "minecraft:locator_bar", + "locatorBar", + "useLocatorBar" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.locator_bar", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:log_admin_commands", + "logAdminCommands" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.log_admin_commands", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:max_block_modifications", + "commandModificationBlockLimit" + ], + "type": "int", + "displayI18nKey": "gamerule.rule.max_block_modifications", + "defaultValue": 32768, + "minValue": { + "23w03a": "INT_MIN", + "25w44a": 1 + } + }, + { + "ruleKey": [ + "minecraft:max_command_forks", + "maxCommandForkCount" + ], + "type": "int", + "displayI18nKey": "gamerule.rule.max_command_forks", + "defaultValue": 65536, + "minValue": { + "23w41a": "INT_MIN", + "25w44a": 0 + } + }, + { + "ruleKey": [ + "minecraft:max_command_sequence_length", + "maxCommandChainLength" + ], + "type": "int", + "displayI18nKey": "gamerule.rule.max_command_sequence_length", + "defaultValue": 65536, + "minValue": { + "17w16b": "INT_MIN", + "25w44a": 0 + } + }, + { + "ruleKey": [ + "minecraft:max_entity_cramming", + "maxEntityCramming" + ], + "type": "int", + "displayI18nKey": "gamerule.rule.max_entity_cramming", + "defaultValue": 24, + "minValue": { + "16w38a": "INT_MIN", + "25w44a": 1, + "25w45a": 0 + } + }, + { + "ruleKey": [ + "minecraft:max_minecart_speed", + "minecartMaxSpeed" + ], + "type": "int", + "displayI18nKey": "gamerule.rule.max_minecart_speed", + "defaultValue": 8, + "minValue": 1, + "maxValue": 1000 + }, + { + "ruleKey": [ + "minecraft:max_snow_accumulation_height", + "snowAccumulationHeight" + ], + "type": "int", + "displayI18nKey": "gamerule.rule.max_snow_accumulation_height", + "defaultValue": 1, + "minValue": { + "22w44a": "INT_MIN", + "25w44a": 0 + }, + "maxValue": { + "22w44a": "INT_MAX", + "25w44a": 8 + } + }, + { + "ruleKey": [ + "minecraft:mob_drops", + "doMobLoot" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.mob_drops", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:mob_explosion_drop_decay", + "mobExplosionDropDecay" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.mob_explosion_drop_decay", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:mob_griefing", + "mobGriefing" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.mob_griefing", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:natural_health_regeneration", + "naturalRegeneration" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.natural_health_regeneration", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:player_movement_check", + "disablePlayerMovementCheck" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.player_movement_check", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:players_nether_portal_creative_delay", + "playersNetherPortalCreativeDelay" + ], + "type": "int", + "displayI18nKey": "gamerule.rule.players_nether_portal_creative_delay", + "defaultValue": 0, + "minValue": { + "23w42a": "INT_MIN", + "25w44a": 0 + } + }, + { + "ruleKey": [ + "minecraft:players_nether_portal_default_delay", + "playersNetherPortalDefaultDelay" + ], + "type": "int", + "displayI18nKey": "gamerule.rule.players_nether_portal_default_delay", + "defaultValue": 80, + "minValue": { + "23w42a": "INT_MIN", + "25w44a": 0 + } + }, + { + "ruleKey": [ + "minecraft:players_sleeping_percentage", + "playersSleepingPercentage" + ], + "type": "int", + "displayI18nKey": "gamerule.rule.players_sleeping_percentage", + "defaultValue": 100, + "minValue": { + "20w51a": "INT_MIN", + "25w44a": 0 + } + }, + { + "ruleKey": [ + "minecraft:projectiles_can_break_blocks", + "projectilesCanBreakBlocks" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.projectiles_can_break_blocks", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:pvp", + "pvp" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.pvp", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:raids" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.raids", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:random_tick_speed", + "randomTickSpeed" + ], + "type": "int", + "displayI18nKey": "gamerule.rule.random_tick_speed", + "defaultValue": 3, + "minValue": { + "14w17a": "INT_MIN", + "25w44a": 0 + } + }, + { + "ruleKey": [ + "minecraft:reduced_debug_info", + "reducedDebugInfo" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.reduced_debug_info", + "defaultValue": false + }, + { + "ruleKey": [ + "minecraft:respawn_radius", + "spawnRadius" + ], + "type": "int", + "displayI18nKey": "gamerule.rule.respawn_radius", + "defaultValue": 10, + "minValue": { + "15w51a": "INT_MIN", + "25w44a": 0 + } + }, + { + "ruleKey": [ + "minecraft:send_command_feedback", + "sendCommandFeedback" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.send_command_feedback", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:show_advancement_messages", + "announceAdvancements" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.show_advancement_messages", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:show_death_messages", + "showDeathMessages" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.show_death_messages", + "defaultValue": true + }, + { + "ruleKey": [ + "spawnChunkRadius" + ], + "type": "int", + "displayI18nKey": "gamerule.rule.spawn_chunk_radius", + "defaultValue": 2 + }, + { + "ruleKey": [ + "minecraft:spawn_mobs", + "doMobSpawning" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.spawn_mobs", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:spawn_monsters", + "spawnMonsters" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.spawn_monsters", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:spawn_patrols", + "doPatrolSpawning" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.spawn_patrols", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:spawn_phantoms", + "doInsomnia" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.spawn_phantoms", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:spawn_wandering_traders", + "doTraderSpawning" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.spawn_wandering_traders", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:spawn_wardens", + "doWardenSpawning" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.spawn_wardens", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:spawner_blocks_work", + "spawnerBlocksEnabled" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.spawner_blocks_work", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:spectators_generate_chunks", + "spectatorsGenerateChunks" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.spectators_generate_chunks", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:spread_vines", + "doVinesSpread" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.spread_vines", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:tnt_explodes", + "tntExplodes" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.tnt_explodes", + "defaultValue": true + }, + { + "ruleKey": [ + "minecraft:tnt_explosion_drop_decay", + "tntExplosionDropDecay" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.tnt_explosion_drop_decay", + "defaultValue": false + }, + { + "ruleKey": [ + "minecraft:universal_anger", + "universalAnger" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.universal_anger", + "defaultValue": false + }, + { + "ruleKey": [ + "minecraft:water_source_conversion", + "waterSourceConversion" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.water_source_conversion", + "defaultValue": true + } +] diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/gamerule/GameRuleNBTTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/gamerule/GameRuleNBTTest.java new file mode 100644 index 0000000000..73ade76b76 --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/gamerule/GameRuleNBTTest.java @@ -0,0 +1,77 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.gamerule; + +import com.github.steveice10.opennbt.tag.builtin.ByteTag; +import com.github.steveice10.opennbt.tag.builtin.IntTag; +import com.github.steveice10.opennbt.tag.builtin.StringTag; +import com.github.steveice10.opennbt.tag.builtin.Tag; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +public class GameRuleNBTTest { + @Test + public void testByteTag() { + ByteTag tag = new ByteTag("byte_tag", (byte) 1); + GameRuleNBT gameRuleNBT = GameRule.createGameRuleNBT(tag).orElseThrow(() -> new AssertionError("Expected GameRuleNBT to be created for ByteTag")); + + GameRuleNBT.ByteGameRuleNBT byteGameRuleNBT = assertInstanceOf(GameRuleNBT.ByteGameRuleNBT.class, gameRuleNBT); + byteGameRuleNBT.changeValue(false); + assertEquals((byte) 0, tag.getValue()); + } + + @Test + public void testStringByteTag() { + StringTag tag = new StringTag("string_byte_tag", "true"); + GameRuleNBT gameRuleNBT = GameRule.createGameRuleNBT(tag).orElseThrow(() -> new AssertionError("Expected GameRuleNBT to be created for StringedByteTag")); + + GameRuleNBT.StringByteGameRuleNBT stringedByteGameRuleNBT = assertInstanceOf(GameRuleNBT.StringByteGameRuleNBT.class, gameRuleNBT); + stringedByteGameRuleNBT.changeValue(false); + assertEquals("false", tag.getValue()); + } + + @Test + public void testIntTag() { + IntTag tag = new IntTag("int_tag", 1); + GameRuleNBT gameRuleNBT = GameRule.createGameRuleNBT(tag).orElseThrow(() -> new AssertionError("Expected GameRuleNBT to be created for IntTag")); + + GameRuleNBT.IntGameRuleNBT intGameRuleNBT = assertInstanceOf(GameRuleNBT.IntGameRuleNBT.class, gameRuleNBT); + intGameRuleNBT.changeValue("2"); + assertEquals(2, tag.getValue()); + } + + @Test + public void testStringIntTag() { + StringTag tag = new StringTag("string_int_tag", "1"); + GameRuleNBT gameRuleNBT = GameRule.createGameRuleNBT(tag).orElseThrow(() -> new AssertionError("Expected GameRuleNBT to be created for StringedIntTag")); + + GameRuleNBT.StringIntGameRuleNBT stringIntGameRuleNBT = assertInstanceOf(GameRuleNBT.StringIntGameRuleNBT.class, gameRuleNBT); + stringIntGameRuleNBT.changeValue("2"); + assertEquals("2", tag.getValue()); + } + + @Test + public void testWrongTag() { + StringTag tag = new StringTag("wrong_tag", "abc"); + Optional> gameRuleNBT = GameRule.createGameRuleNBT(tag); + assertTrue(gameRuleNBT.isEmpty()); + } +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/gamerule/GameRuleTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/gamerule/GameRuleTest.java new file mode 100644 index 0000000000..3a6a47830e --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/gamerule/GameRuleTest.java @@ -0,0 +1,126 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2025 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.gamerule; + +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.IOUtils; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +public class GameRuleTest { + + private String getMataDataJson() throws IOException { + InputStream is = GameRule.class.getResourceAsStream("/assets/gamerule/gamerule.json"); + return IOUtils.readFullyAsString(is); + } + + @Test + public void testParseMataData() throws IOException { + Map metaDataGameRuleMap = new HashMap<>(); + + String jsonContent = getMataDataJson(); + List gameRules = JsonUtils.fromNonNullJson(jsonContent, JsonUtils.listTypeOf(GameRule.class)); + + for (GameRule gameRule : gameRules) { + for (String s : gameRule.getRuleKey()) { + metaDataGameRuleMap.put(s, gameRule); + } + } + + assertFalse(gameRules.isEmpty()); + + } + + public void assertParseSingleIntGameRule(String jsonContent, List gameRuleKeys, Map defaultValueMap, Map minValueMap, Map maxValueMap) { + GameRule gameRules = JsonUtils.fromNonNullJson(jsonContent, GameRule.class); + GameRule.IntGameRule intGameRule = assertInstanceOf(GameRule.IntGameRule.class, gameRules); + assertEquals(intGameRule.getRuleKey(), gameRuleKeys); + defaultValueMap.forEach((key, value) -> { + assertEquals(value, intGameRule.getDefaultValue(GameVersionNumber.asGameVersion(key)).orElseThrow(() -> new AssertionError("cannot get default value for defaultValue"))); + }); + minValueMap.forEach((key, value) -> { + assertEquals(value, intGameRule.getMinValue(GameVersionNumber.asGameVersion(key))); + }); + maxValueMap.forEach((key, value) -> { + assertEquals(value, intGameRule.getMaxValue(GameVersionNumber.asGameVersion(key))); + }); + } + + public void assertParseSingleBooleanRule(String jsonContent, List gameRuleKeys, Map defaultValueMap) { + GameRule gameRules = JsonUtils.fromNonNullJson(jsonContent, GameRule.class); + GameRule.BooleanGameRule booleanGameRule = assertInstanceOf(GameRule.BooleanGameRule.class, gameRules); + defaultValueMap.forEach((key, value) -> { + assertEquals(value, booleanGameRule.getDefaultValue(GameVersionNumber.asGameVersion(key)).orElseThrow(() -> new AssertionError("cannot get default value for defaultValue"))); + }); + } + + @Test + public void testParseGameRule() { + assertParseSingleIntGameRule( + """ + { + "ruleKey": [ + "minecraft:test", + "test" + ], + "type": "int", + "displayI18nKey": "gamerule.rule.test", + "defaultValue": 1, + "minValue": { + "22w44a": "INT_MIN", + "25w44a": 0 + }, + "maxValue": { + "22w44a": "INT_MAX", + "25w44a": 8 + } + } + """, + List.of("minecraft:test", "test"), + Map.of("25w45a", 1), + Map.of("23w44a", Integer.MIN_VALUE, "25w45a", 0), + Map.of("23w44a", Integer.MAX_VALUE, "25w45a", 8) + ); + assertParseSingleBooleanRule( + """ + { + "ruleKey": [ + "minecraft:test", + "test" + ], + "type": "boolean", + "displayI18nKey": "gamerule.rule.test", + "defaultValue": { + "22w44a": true, + "25w44a": false + } + } + """, + List.of("minecraft:test", "test"), + Map.of("25w45a", false, "23w44a", true) + ); + } +}