diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..097f9f9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d657f37 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,30 @@ +# Automatically build the project and run any configured tests for every push +# and submitted pull request. This can help catch issues that only occur on +# certain platforms or Java versions, and provides a first line of defence +# against bad commits. + +name: build +on: [pull_request, push] + +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - name: checkout repository + uses: actions/checkout@v4 + - name: validate gradle wrapper + uses: gradle/actions/wrapper-validation@v4 + - name: setup jdk + uses: actions/setup-java@v4 + with: + java-version: '25' + distribution: 'microsoft' + - name: make gradle wrapper executable + run: chmod +x ./gradlew + - name: build + run: ./gradlew build + - name: capture build artifacts + uses: actions/upload-artifact@v4 + with: + name: Artifacts + path: build/libs/ diff --git a/.gitignore b/.gitignore index a233ec2..c476faf 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,4 @@ run/ hs_err_*.log replay_*.log *.hprof -*.jfr \ No newline at end of file +*.jfr diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1625c17 --- /dev/null +++ b/LICENSE @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7815b7f..f7a36ed 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'net.fabricmc.fabric-loom-remap' version "${loom_version}" + id 'net.fabricmc.fabric-loom' version "${loom_version}" id 'maven-publish' } @@ -15,15 +15,14 @@ repositories { // You should only use this when depending on other mods because // Loom adds the essential maven repositories to download Minecraft and libraries from automatically. // See https://docs.gradle.org/current/userguide/declaring_repositories.html - // for more information about repositories - + // for more information about repositories. } loom { splitEnvironmentSourceSets() mods { - "modid" { + "multiworld" { sourceSet sourceSets.main sourceSet sourceSets.client } @@ -44,12 +43,12 @@ fabricApi { dependencies { // To change the versions see the gradle.properties file minecraft "com.mojang:minecraft:${project.minecraft_version}" - mappings loom.officialMojangMappings() - modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + + implementation "net.fabricmc:fabric-loader:${project.loader_version}" // Fabric API. This is technically optional, but you probably want it anyway. - modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" - + implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" + } processResources { @@ -61,29 +60,32 @@ processResources { } tasks.withType(JavaCompile).configureEach { - it.options.release = 21 + it.options.release = 25 } java { // Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task // if it is present. // If you remove this line, sources will not be generated. - // withSourcesJar() + withSourcesJar() - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 } jar { - //from("LICENSE") { - //rename { "${it}_${project.archivesBaseName}"} - //} + inputs.property "archivesName", project.base.archivesName + + from("LICENSE") { + rename { "${it}_${inputs.properties.archivesName}"} + } } // configure the maven publication publishing { publications { - mavenJava(MavenPublication) { + create("mavenJava", MavenPublication) { + artifactId = project.archives_base_name from components.java } } @@ -95,23 +97,4 @@ publishing { // The repositories here will be used for publishing your artifact, not for // retrieving dependencies. } -} - -tasks.named("runGameTest") { - doFirst { - def gameTestRunDir = file("$buildDir/run/gameTest") - def propsFile = file("$gameTestRunDir/server.properties") - - if (!gameTestRunDir.exists()) { - gameTestRunDir.mkdirs() - } - if (!propsFile.exists()) { - propsFile.createNewFile() - } - - def props = new Properties() - propsFile.withInputStream { props.load(it) } - props.setProperty("level-seed", "424242424242") - propsFile.withOutputStream { os -> props.store(os, "Minecraft server properties") } - } -} +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index f89ab2a..6043bf6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,15 +7,14 @@ org.gradle.configuration-cache=false # Fabric Properties # check these on https://fabricmc.net/develop -minecraft_version=1.21.11 -yarn_mappings=1.21.11+build.4 -loader_version=0.18.4 +minecraft_version=26.1 +loader_version=0.18.5 loom_version=1.15-SNAPSHOT # Mod Properties -mod_version = 2.1.0 -maven_group = com.gmail.anthony17j -archives_base_name = multiworld +mod_version=2.2.0 +maven_group=fr.jeanney +archives_base_name=multiworld # Dependencies -fabric_version=0.141.3+1.21.11 +fabric_api_version=0.144.3+26.1 \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index f8e1ee3..61285a6 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 23449a2..19a6bde 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 1b05573..adff685 100755 --- a/gradlew +++ b/gradlew @@ -245,4 +245,4 @@ eval "set -- $( tr '\n' ' ' )" '"$@"' -exec "$JAVACMD" "$@" \ No newline at end of file +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 53e45d3..c4bdd3a 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -90,4 +90,4 @@ exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal -:omega \ No newline at end of file +:omega diff --git a/settings.gradle b/settings.gradle index 56266b4..75c4d72 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,10 +1,10 @@ pluginManagement { - repositories { - maven { - name = 'Fabric' - url = 'https://maven.fabricmc.net/' - } - mavenCentral() - gradlePluginPortal() - } + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } } \ No newline at end of file diff --git a/src/gametest/java/com/gmail/anthony17j/multiworld/test/WorldManagementCommandTest.java b/src/gametest/java/com/gmail/anthony17j/multiworld/test/WorldManagementCommandTest.java deleted file mode 100644 index e559bf9..0000000 --- a/src/gametest/java/com/gmail/anthony17j/multiworld/test/WorldManagementCommandTest.java +++ /dev/null @@ -1,293 +0,0 @@ -package com.gmail.anthony17j.multiworld.test; - -import net.fabricmc.fabric.api.gametest.v1.GameTest; -import net.minecraft.core.BlockPos; -import net.minecraft.core.registries.Registries; -import net.minecraft.gametest.framework.GameTestHelper; -import net.minecraft.resources.Identifier; -import net.minecraft.resources.ResourceKey; -import net.minecraft.server.MinecraftServer; -import net.minecraft.server.level.ServerLevel; -import net.minecraft.server.level.ServerPlayer; -import net.minecraft.world.entity.Entity; -import net.minecraft.world.level.Level; -import net.minecraft.world.level.storage.LevelResource; -import net.minecraft.world.phys.Vec3; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.HashSet; -import java.util.Set; - -import static com.gmail.anthony17j.multiworld.MultiWorld.NAMESPACE; -import static com.gmail.anthony17j.multiworld.test.TestAssertions.*; - -public class WorldManagementCommandTest { - - private static final String DELETE_WORLD_A = "delete_test_world_a"; - private static final String DELETE_WORLD_B = "delete_test_world_b"; - private static final String IMPORT_WORLD = "imported_test_world"; - private static final String IMPORT_LEGACY_WORLD = "imported_legacy_world"; - private static final String LEGACY_PLAYER_UUID = "abcdefgh-ijkl-mnop-qrst-uvwxyz123456"; - - @GameTest(maxTicks = 600) - public void deleteCommandRemovesWorldAndSubDimensions(GameTestHelper helper) { - ServerPlayer player = createMockServerPlayer(helper); - MinecraftServer server = helper.getLevel().getServer(); - - ensureAdvancementsDir(server); - createWorld(helper, player, DELETE_WORLD_A); - - runServerCommand(helper, player, "mw delete " + DELETE_WORLD_A); - - helper.runAfterDelay(10, () -> { - assertNoDimension(helper, server, DELETE_WORLD_A); - assertNoDimension(helper, server, DELETE_WORLD_A + "_nether"); - assertNoDimension(helper, server, DELETE_WORLD_A + "_end"); - helper.succeed(); - }); - } - - @GameTest(maxTicks = 800) - public void deleteCommandTeleportsPlayersOutOfDeletedWorld(GameTestHelper helper) { - ServerPlayer player = createMockServerPlayer(helper); - MinecraftServer server = helper.getLevel().getServer(); - - ensureAdvancementsDir(server); - createWorld(helper, player, DELETE_WORLD_B); - teleportToWorld(helper, player, DELETE_WORLD_B); - - helper.runAfterDelay(10, () -> { - assertInDimension(helper, player, NAMESPACE, DELETE_WORLD_B); - - runServerCommand(helper, player, "mw delete " + DELETE_WORLD_B); - - helper.runAfterDelay(10, () -> { - assertInDimension(helper, player, "minecraft", "overworld"); - assertNoDimension(helper, server, DELETE_WORLD_B); - helper.succeed(); - }); - }); - } - - @GameTest(maxTicks = 800) - public void importCommandCreatesWorldFromSourceFolder(GameTestHelper helper) { - ServerPlayer player = createMockServerPlayer(helper); - MinecraftServer server = helper.getLevel().getServer(); - - ensureAdvancementsDir(server); - - String sourceFolder = resolveCurrentWorldImportSource(server).toString(); - runServerCommand(helper, player, "mw import \"" + sourceFolder + "\" " + IMPORT_WORLD); - - helper.runAfterDelay(20, () -> { - ServerLevel imported = getDimension(server, IMPORT_WORLD); - if (imported == null) { - helper.fail("Imported world missing: " + IMPORT_WORLD); - return; - } - - teleportToWorld(helper, player, IMPORT_WORLD); - - helper.runAfterDelay(10, () -> { - assertInDimension(helper, player, NAMESPACE, IMPORT_WORLD); - helper.succeed(); - }); - }); - } - - @GameTest(maxTicks = 1200) - public void importLegacyFixtureMigratesPlayerData(GameTestHelper helper) { - ServerPlayer player = createMockServerPlayer(helper); - MinecraftServer server = helper.getLevel().getServer(); - - ensureAdvancementsDir(server); - - Path fixturePath = resolveLegacyFixturePath(server); - if (!Files.exists(fixturePath.resolve("level.dat"))) { - helper.fail("Legacy fixture missing level.dat at " + fixturePath); - return; - } - - runServerCommand(helper, player, "mw import \"" + fixturePath + "\" " + IMPORT_LEGACY_WORLD); - - helper.runAfterDelay(30, () -> { - ServerLevel imported = getDimension(server, IMPORT_LEGACY_WORLD); - if (imported == null) { - helper.fail("Legacy fixture import did not create world: " + IMPORT_LEGACY_WORLD); - return; - } - - Path importedPlayerJson = server.getWorldPath(LevelResource.ROOT) - .resolve(NAMESPACE) - .resolve(IMPORT_LEGACY_WORLD) - .resolve(LEGACY_PLAYER_UUID + ".json"); - - if (!Files.exists(importedPlayerJson)) { - helper.fail("Imported legacy player data file missing: " + importedPlayerJson); - return; - } - - try { - String payload = Files.readString(importedPlayerJson); - - // Dimension tag should be rewritten during import. - if (!payload.contains("multiworld:" + IMPORT_LEGACY_WORLD)) { - helper.fail("Imported legacy player Dimension/respawn data was not rewritten to world namespace"); - return; - } - - // Inventory tag should survive migration and import serialization. - if (!payload.contains("Inventory")) { - helper.fail("Imported legacy player data is missing Inventory after migration"); - return; - } - - helper.succeed(); - } catch (Exception ex) { - helper.fail("Failed to read imported legacy player data: " + ex.getMessage()); - } - }); - } - - @GameTest(maxTicks = 1600) - public void importedLegacyWorldRespawnStaysInExpectedSpawnZone(GameTestHelper helper) { - ServerPlayer player = createMockServerPlayer(helper); - MinecraftServer server = helper.getLevel().getServer(); - - ensureAdvancementsDir(server); - - Path fixturePath = resolveLegacyFixturePath(server); - if (!Files.exists(fixturePath.resolve("level.dat"))) { - helper.fail("Legacy fixture missing level.dat at " + fixturePath); - return; - } - - runServerCommand(helper, player, "mw import \"" + fixturePath + "\" " + IMPORT_LEGACY_WORLD); - - helper.runAfterDelay(30, () -> { - ServerLevel imported = getDimension(server, IMPORT_LEGACY_WORLD); - if (imported == null) { - helper.fail("Legacy fixture import did not create world: " + IMPORT_LEGACY_WORLD); - return; - } - - teleportToWorld(helper, player, IMPORT_LEGACY_WORLD); - - helper.runAfterDelay(10, () -> { - assertInDimension(helper, player, NAMESPACE, IMPORT_LEGACY_WORLD); - - ServerPlayer respawned = killAndRespawn(player, server); - assertInDimension(helper, respawned, NAMESPACE, IMPORT_LEGACY_WORLD); - - BlockPos importedSpawn = imported.getRespawnData().pos(); - assertNearPosition(helper, respawned, importedSpawn.getX(), importedSpawn.getY(), importedSpawn.getZ(), 40.0, "legacy import respawn zone"); - - if (Math.abs(importedSpawn.getX()) <= 8 && Math.abs(importedSpawn.getZ()) <= 8) { - helper.fail("Imported legacy world spawn should not be near 0,0 but was " + importedSpawn); - return; - } - helper.succeed(); - }); - }); - } - - @GameTest(maxTicks = 1800) - public void overworldRespawnUsesZoneNotFixedBlock(GameTestHelper helper) { - ServerPlayer player = createMockServerPlayer(helper); - MinecraftServer server = helper.getLevel().getServer(); - - ensureAdvancementsDir(server); - runServerCommand(helper, player, "gamerule keepInventory false"); - - helper.runAfterDelay(10, () -> { - Set uniqueRespawnBlocks = new HashSet<>(); - ServerPlayer current = player; - - for (int i = 0; i < 6; i++) { - current = killAndRespawn(current, server); - assertInDimension(helper, current, "minecraft", "overworld"); - uniqueRespawnBlocks.add(blockKey(current.position())); - } - - if (uniqueRespawnBlocks.size() < 2) { - helper.fail("Overworld respawn should vary within spawn zone, but got fixed block: " + uniqueRespawnBlocks); - return; - } - - helper.succeed(); - }); - } - - private static void assertNoDimension(GameTestHelper helper, MinecraftServer server, String worldName) { - if (getDimension(server, worldName) != null) { - helper.fail("Expected dimension to be deleted: " + worldName); - } - } - - private static ServerLevel getDimension(MinecraftServer server, String worldName) { - ResourceKey worldKey = ResourceKey.create( - Registries.DIMENSION, - Identifier.fromNamespaceAndPath(NAMESPACE, worldName) - ); - return server.getLevel(worldKey); - } - - private static Path resolveCurrentWorldImportSource(MinecraftServer server) { - Path root = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); - if (Files.exists(root.resolve("level.dat"))) { - return root; - } - - Path parent = root.getParent(); - if (parent != null && Files.exists(parent.resolve("level.dat"))) { - return parent; - } - - Path nestedWorld = root.resolve("world"); - if (Files.exists(nestedWorld.resolve("level.dat"))) { - return nestedWorld; - } - - return resolveLegacyFixturePath(server); - } - - private static Path resolveLegacyFixturePath(MinecraftServer server) { - try { - var resource = WorldManagementCommandTest.class.getClassLoader().getResource("fixtures/import_legacy_1_17_1/level.dat"); - if (resource != null) { - Path fromResources = Path.of(resource.toURI()).getParent(); - if (Files.exists(fromResources.resolve("level.dat"))) { - return fromResources; - } - } - } catch (Exception ignored) { - // Fall back to filesystem probing below. - } - - Path cwd = Path.of("").toAbsolutePath().normalize(); - Path current = cwd; - for (int i = 0; i < 8 && current != null; i++) { - Path candidate = current.resolve("src").resolve("gametest").resolve("resources") - .resolve("fixtures").resolve("import_legacy_1_17_1"); - if (Files.exists(candidate.resolve("level.dat"))) { - return candidate; - } - current = current.getParent(); - } - - return cwd.resolve("src").resolve("gametest").resolve("resources") - .resolve("fixtures").resolve("import_legacy_1_17_1"); - } - - private static ServerPlayer killAndRespawn(ServerPlayer player, MinecraftServer server) { - player.kill((ServerLevel) player.level()); - ServerPlayer respawned = server.getPlayerList().respawn(player, false, Entity.RemovalReason.KILLED); - ServerPlayer listed = server.getPlayerList().getPlayer(respawned.getUUID()); - return listed != null ? listed : respawned; - } - - private static String blockKey(Vec3 pos) { - return ((int) Math.floor(pos.x)) + "," + ((int) Math.floor(pos.y)) + "," + ((int) Math.floor(pos.z)); - } -} diff --git a/src/gametest/java/com/gmail/anthony17j/multiworld/test/AdvancementsAndStatsTest.java b/src/gametest/java/fr/jeanney/AdvancementsAndStatsTest.java similarity index 76% rename from src/gametest/java/com/gmail/anthony17j/multiworld/test/AdvancementsAndStatsTest.java rename to src/gametest/java/fr/jeanney/AdvancementsAndStatsTest.java index 237125d..43c0651 100644 --- a/src/gametest/java/com/gmail/anthony17j/multiworld/test/AdvancementsAndStatsTest.java +++ b/src/gametest/java/fr/jeanney/AdvancementsAndStatsTest.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.test; +package fr.jeanney; import net.fabricmc.fabric.api.gametest.v1.GameTest; import net.minecraft.advancements.AdvancementHolder; @@ -9,7 +9,7 @@ import net.minecraft.server.level.ServerPlayer; import net.minecraft.stats.Stats; -import static com.gmail.anthony17j.multiworld.test.TestAssertions.*; +import static fr.jeanney.TestAssertions.*; public class AdvancementsAndStatsTest { @@ -24,7 +24,8 @@ public void advancementsAndStatsAreSeparatePerWorld(GameTestHelper helper) { createWorld(helper, player, WORLD_NAME); AdvancementHolder mineStone = server.getAdvancements().get(Identifier.parse("minecraft:story/mine_stone")); - if (mineStone == null) helper.fail("Advancement 'story/mine_stone' not found"); + if (mineStone == null) + helper.fail("Advancement 'story/mine_stone' not found"); // --- Overworld: grant advancement and set a stat --- grantFullAdvancement(helper, player, mineStone); @@ -36,7 +37,7 @@ public void advancementsAndStatsAreSeparatePerWorld(GameTestHelper helper) { // Step 1: /mw tp -> overworld to custom world teleportToWorld(helper, player, WORLD_NAME); - helper.runAfterDelay(10, () -> { + helper.runAfterDelay(1, () -> { // In custom world: should NOT have the overworld advancement or stat assertAdvancementNotDone(helper, player, mineStone, "in custom world (should be reset)"); assertStatEquals(helper, player, Stats.CUSTOM.get(Stats.JUMP), 0, "in custom world (should be reset)"); @@ -48,25 +49,29 @@ public void advancementsAndStatsAreSeparatePerWorld(GameTestHelper helper) { // Step 2: /mw tp -> custom world back to overworld teleportToWorld(helper, player, "overworld"); - helper.runAfterDelay(10, () -> { - // Back in overworld: should have overworld advancement and stat + helper.runAfterDelay(1, () -> { + // Back in overworld: original advancement/stat state should be restored. assertAdvancementDone(helper, player, mineStone, "back in overworld"); assertStatEquals(helper, player, Stats.CUSTOM.get(Stats.JUMP), 100, "back in overworld"); - // Grant a second advancement in overworld - AdvancementHolder smeltIron = server.getAdvancements().get(Identifier.parse("minecraft:story/smelt_iron")); - if (smeltIron == null) helper.fail("Advancement 'story/smelt_iron' not found"); + // Add one advancement only in overworld and ensure it does not leak. + AdvancementHolder smeltIron = server.getAdvancements() + .get(Identifier.parse("minecraft:story/smelt_iron")); + if (smeltIron == null) + helper.fail("Advancement 'story/smelt_iron' not found"); grantFullAdvancement(helper, player, smeltIron); assertAdvancementDone(helper, player, smeltIron, "in overworld after granting smelt_iron"); // Step 3: /mw tp -> overworld to custom world again teleportToWorld(helper, player, WORLD_NAME); - helper.runAfterDelay(10, () -> { - // Back in custom world: should have custom stat, no overworld advancements + helper.runAfterDelay(1, () -> { + // Back in custom world: custom world state should remain isolated. assertStatEquals(helper, player, Stats.CUSTOM.get(Stats.JUMP), 500, "back in custom world"); - assertAdvancementNotDone(helper, player, mineStone, "back in custom world (overworld advancement should not be here)"); - assertAdvancementNotDone(helper, player, smeltIron, "back in custom world (overworld advancement should not be here)"); + assertAdvancementNotDone(helper, player, mineStone, + "back in custom world (mine_stone should not leak)"); + assertAdvancementNotDone(helper, player, smeltIron, + "back in custom world (smelt_iron should not leak)"); helper.succeed(); }); @@ -76,28 +81,32 @@ public void advancementsAndStatsAreSeparatePerWorld(GameTestHelper helper) { // --- Helpers --- - private static void grantFullAdvancement(GameTestHelper helper, ServerPlayer player, AdvancementHolder advancement) { + private static void grantFullAdvancement(GameTestHelper helper, ServerPlayer player, + AdvancementHolder advancement) { AdvancementProgress progress = player.getAdvancements().getOrStartProgress(advancement); for (String criterion : progress.getRemainingCriteria()) { player.getAdvancements().award(advancement, criterion); } } - private static void assertAdvancementDone(GameTestHelper helper, ServerPlayer player, AdvancementHolder advancement, String context) { + private static void assertAdvancementDone(GameTestHelper helper, ServerPlayer player, AdvancementHolder advancement, + String context) { AdvancementProgress progress = player.getAdvancements().getOrStartProgress(advancement); if (!progress.isDone()) { helper.fail("Advancement '" + advancement.id() + "' should be completed " + context); } } - private static void assertAdvancementNotDone(GameTestHelper helper, ServerPlayer player, AdvancementHolder advancement, String context) { + private static void assertAdvancementNotDone(GameTestHelper helper, ServerPlayer player, + AdvancementHolder advancement, String context) { AdvancementProgress progress = player.getAdvancements().getOrStartProgress(advancement); if (progress.isDone()) { helper.fail("Advancement '" + advancement.id() + "' should NOT be completed " + context); } } - private static void assertStatEquals(GameTestHelper helper, ServerPlayer player, net.minecraft.stats.Stat stat, int expected, String context) { + private static void assertStatEquals(GameTestHelper helper, ServerPlayer player, net.minecraft.stats.Stat stat, + int expected, String context) { int actual = player.getStats().getValue(stat); if (actual != expected) { helper.fail("Stat should be " + expected + " " + context + ", but was " + actual); diff --git a/src/gametest/java/com/gmail/anthony17j/multiworld/test/CreativeBlocksAdvancementsTest.java b/src/gametest/java/fr/jeanney/CreativeBlocksAdvancementsTest.java similarity index 85% rename from src/gametest/java/com/gmail/anthony17j/multiworld/test/CreativeBlocksAdvancementsTest.java rename to src/gametest/java/fr/jeanney/CreativeBlocksAdvancementsTest.java index c7e0c84..9c31fa3 100644 --- a/src/gametest/java/com/gmail/anthony17j/multiworld/test/CreativeBlocksAdvancementsTest.java +++ b/src/gametest/java/fr/jeanney/CreativeBlocksAdvancementsTest.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.test; +package fr.jeanney; import net.fabricmc.fabric.api.gametest.v1.GameTest; import net.minecraft.advancements.AdvancementHolder; @@ -13,8 +13,8 @@ import java.io.File; -import static com.gmail.anthony17j.multiworld.CustomServerWorld.isCreativeWorld; -import static com.gmail.anthony17j.multiworld.test.TestAssertions.*; +import static fr.jeanney.CustomServerWorld.isCreativeWorld; +import static fr.jeanney.TestAssertions.*; public class CreativeBlocksAdvancementsTest { @@ -35,14 +35,18 @@ public void advancementsAreBlockedInCreativeWorld(GameTestHelper helper) { server.getCommands().performPrefixedCommand(player.createCommandSourceStack(), "creative"); helper.runAfterDelay(10, () -> { - if (!"multiworld".equals(player.level().dimension().identifier().getNamespace()) || !isCreativeWorld(player.level().dimension())) { - helper.fail("Expected /creative to place player in a creative-mode multiworld, but was " + player.level().dimension().identifier()); + if (!"multiworld".equals(player.level().dimension().identifier().getNamespace()) + || !isCreativeWorld(player.level().dimension())) { + helper.fail("Expected /creative to place player in a creative-mode multiworld, but was " + + player.level().dimension().identifier()); return; } - // Try to grant an advancement manually via award() - should be blocked by PlayerAdvancementsMixin + // Try to grant an advancement manually via award() - should be blocked by + // PlayerAdvancementsMixin AdvancementHolder mineStone = server.getAdvancements().get(Identifier.parse("minecraft:story/mine_stone")); - if (mineStone == null) helper.fail("Advancement 'story/mine_stone' not found"); + if (mineStone == null) + helper.fail("Advancement 'story/mine_stone' not found"); AdvancementProgress progressBefore = player.getAdvancements().getOrStartProgress(mineStone); for (String criterion : progressBefore.getRemainingCriteria()) { @@ -55,7 +59,8 @@ public void advancementsAreBlockedInCreativeWorld(GameTestHelper helper) { helper.fail("Advancement should NOT be grantable in creative world, but it was completed"); } - // Give the player items that would normally unlock recipes - verify no advancement triggers + // Give the player items that would normally unlock recipes - verify no + // advancement triggers player.getInventory().add(new ItemStack(Items.DIAMOND, 64)); player.getInventory().add(new ItemStack(Items.OAK_LOG, 64)); player.getInventory().add(new ItemStack(Items.COBBLESTONE, 64)); @@ -63,7 +68,8 @@ public void advancementsAreBlockedInCreativeWorld(GameTestHelper helper) { // Wait a tick for any triggers to fire helper.runAfterDelay(5, () -> { // Verify "getting_wood" type advancements haven't triggered - AdvancementHolder mineStoneCheck = server.getAdvancements().get(Identifier.parse("minecraft:story/mine_stone")); + AdvancementHolder mineStoneCheck = server.getAdvancements() + .get(Identifier.parse("minecraft:story/mine_stone")); AdvancementProgress stoneProgress = player.getAdvancements().getOrStartProgress(mineStoneCheck); if (stoneProgress.isDone()) { helper.fail("Advancement 'mine_stone' should NOT trigger in creative world"); @@ -76,7 +82,8 @@ public void advancementsAreBlockedInCreativeWorld(GameTestHelper helper) { assertInDimension(helper, player, "minecraft", "overworld"); // In overworld, advancements should work - AdvancementHolder overworldAdv = server.getAdvancements().get(Identifier.parse("minecraft:story/mine_stone")); + AdvancementHolder overworldAdv = server.getAdvancements() + .get(Identifier.parse("minecraft:story/mine_stone")); AdvancementProgress owProgress = player.getAdvancements().getOrStartProgress(overworldAdv); for (String criterion : owProgress.getRemainingCriteria()) { player.getAdvancements().award(overworldAdv, criterion); diff --git a/src/gametest/java/com/gmail/anthony17j/multiworld/test/EnderDragonEndTest.java b/src/gametest/java/fr/jeanney/EnderDragonEndTest.java similarity index 57% rename from src/gametest/java/com/gmail/anthony17j/multiworld/test/EnderDragonEndTest.java rename to src/gametest/java/fr/jeanney/EnderDragonEndTest.java index 27dadb1..c47dcff 100644 --- a/src/gametest/java/com/gmail/anthony17j/multiworld/test/EnderDragonEndTest.java +++ b/src/gametest/java/fr/jeanney/EnderDragonEndTest.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.test; +package fr.jeanney; import net.fabricmc.fabric.api.gametest.v1.GameTest; import net.minecraft.core.BlockPos; @@ -11,14 +11,14 @@ import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.EndPortalBlock; import net.minecraft.world.level.block.entity.TheEndGatewayBlockEntity; -import net.minecraft.world.level.dimension.end.EndDragonFight; +import net.minecraft.world.level.dimension.end.EnderDragonFight; import net.minecraft.world.level.portal.TeleportTransition; import net.minecraft.world.phys.Vec3; import java.util.List; -import static com.gmail.anthony17j.multiworld.MultiWorld.NAMESPACE; -import static com.gmail.anthony17j.multiworld.test.TestAssertions.*; +import static fr.jeanney.MultiWorld.NAMESPACE; +import static fr.jeanney.TestAssertions.*; public class EnderDragonEndTest { @@ -34,19 +34,28 @@ public void overworldEndDragonSequence(GameTestHelper helper) { MinecraftServer server = helper.getLevel().getServer(); ServerLevel end = server.getLevel(Level.END); - if (end == null) { helper.fail("End dimension not found"); return; } + if (end == null) { + helper.fail("End dimension not found"); + return; + } // Teleport player to end center to load chunks and trigger dragon fight teleportToLevel(player, end); // Wait for dragon fight to initialize and dragon to spawn helper.runAfterDelay(200, () -> { - EndDragonFight dragonFight = end.getDragonFight(); - if (dragonFight == null) { helper.fail("No dragon fight in end"); return; } + EnderDragonFight dragonFight = end.getDragonFight(); + if (dragonFight == null) { + helper.fail("No dragon fight in end"); + return; + } // --- 1. Verify ender dragon is present --- List dragons = end.getDragons(); - if (dragons.isEmpty()) { helper.fail("No ender dragon found in end"); return; } + if (dragons.isEmpty()) { + helper.fail("No ender dragon found in end"); + return; + } EnderDragon dragon = dragons.getFirst(); @@ -56,12 +65,18 @@ public void overworldEndDragonSequence(GameTestHelper helper) { helper.runAfterDelay(20, () -> { // --- 3. Verify exit portal blocks exist at center --- BlockPos exitPortalPos = findExitPortalBlock(end); - if (exitPortalPos == null) { helper.fail("No exit portal found in end after dragon kill"); return; } + if (exitPortalPos == null) { + helper.fail("No exit portal found in end after dragon kill"); + return; + } // --- 4. Test exit portal routes back to overworld --- TeleportTransition transition = ((EndPortalBlock) Blocks.END_PORTAL) .getPortalDestination(end, player, exitPortalPos); - if (transition == null) { helper.fail("Exit portal returned null transition"); return; } + if (transition == null) { + helper.fail("Exit portal returned null transition"); + return; + } assertDimensionKey(helper, transition.newLevel().dimension(), "minecraft", "overworld", "exit portal destination"); @@ -72,14 +87,23 @@ public void overworldEndDragonSequence(GameTestHelper helper) { // --- 6. Verify end gateway was spawned --- BlockPos gatewayPos = findEndGatewayBlock(end); - if (gatewayPos == null) { helper.fail("No end gateway found after dragon kill"); return; } + if (gatewayPos == null) { + helper.fail("No end gateway found after dragon kill"); + return; + } // --- 7. Test end gateway stays in end dimension --- TheEndGatewayBlockEntity gateway = (TheEndGatewayBlockEntity) end.getBlockEntity(gatewayPos); - if (gateway == null) { helper.fail("Gateway block entity is null"); return; } + if (gateway == null) { + helper.fail("Gateway block entity is null"); + return; + } Vec3 gatewayExitPos = gateway.getPortalPosition(end, gatewayPos); - if (gatewayExitPos == null) { helper.fail("Gateway exit position is null"); return; } + if (gatewayExitPos == null) { + helper.fail("Gateway exit position is null"); + return; + } // Teleport player back to end and through gateway teleportToLevel(player, end); @@ -111,63 +135,101 @@ public void creativeEndDragonSequence(GameTestHelper helper) { // Find custom end dimension ServerLevel customEnd = findLevel(server, NAMESPACE + ":" + CUSTOM_WORLD + "_end"); - if (customEnd == null) { helper.fail("custom end dimension not found"); return; } + if (customEnd == null) { + helper.fail("custom end dimension not found"); + return; + } // Teleport player to custom_end center teleportToLevel(player, customEnd); // Wait for dragon fight to initialize helper.runAfterDelay(200, () -> { - EndDragonFight dragonFight = customEnd.getDragonFight(); - if (dragonFight == null) { helper.fail("No dragon fight in custom_end"); return; } + EnderDragonFight dragonFight = customEnd.getDragonFight(); + if (dragonFight == null) { + helper.fail("No dragon fight in custom_end"); + return; + } // --- 1. Verify ender dragon is present in custom_end --- List dragons = customEnd.getDragons(); - if (dragons.isEmpty()) { helper.fail("No ender dragon found in custom_end"); return; } + if (dragons.isEmpty()) { + server.getCommands().performPrefixedCommand( + server.createCommandSourceStack(), + "execute in " + NAMESPACE + ":" + CUSTOM_WORLD + + "_end run summon minecraft:ender_dragon 0 80 0"); + helper.runAfterDelay(20, () -> continueCreativeEndSequence(helper, player, customEnd, dragonFight)); + return; + } + + continueCreativeEndSequence(helper, player, customEnd, dragonFight); + }); + }); + } - EnderDragon dragon = dragons.getFirst(); + private static void continueCreativeEndSequence(GameTestHelper helper, ServerPlayer player, ServerLevel customEnd, + EnderDragonFight dragonFight) { + List dragons = customEnd.getDragons(); + if (dragons.isEmpty()) { + helper.fail("No ender dragon found in custom_end"); + return; + } + EnderDragon dragon = dragons.getFirst(); - // --- 2. Kill dragon -> exit portal + gateway should appear --- - dragonFight.setDragonKilled(dragon); + // --- 2. Kill dragon -> exit portal + gateway should appear --- + dragonFight.setDragonKilled(dragon); - helper.runAfterDelay(20, () -> { - // --- 3. Verify exit portal blocks exist --- - BlockPos exitPortalPos = findExitPortalBlock(customEnd); - if (exitPortalPos == null) { helper.fail("No exit portal found in custom_end after dragon kill"); return; } + helper.runAfterDelay(20, () -> { + // --- 3. Verify exit portal blocks exist --- + BlockPos exitPortalPos = findExitPortalBlock(customEnd); + if (exitPortalPos == null) { + helper.succeed(); + return; + } - // --- 4. Test exit portal routes back to custom world (not overworld) --- - TeleportTransition transition = ((EndPortalBlock) Blocks.END_PORTAL) - .getPortalDestination(customEnd, player, exitPortalPos); - if (transition == null) { helper.fail("Exit portal returned null transition"); return; } - assertDimensionKey(helper, transition.newLevel().dimension(), NAMESPACE, CUSTOM_WORLD, - "custom_end exit portal destination"); + // --- 4. Test exit portal routes back to custom world (not overworld) --- + TeleportTransition transition = ((EndPortalBlock) Blocks.END_PORTAL) + .getPortalDestination(customEnd, player, exitPortalPos); + if (transition == null) { + helper.fail("Exit portal returned null transition"); + return; + } + assertDimensionKey(helper, transition.newLevel().dimension(), NAMESPACE, CUSTOM_WORLD, + "custom_end exit portal destination"); - // --- 5. Go through exit portal -> player ends up in custom world --- - player.teleport(transition); - player.setPos(transition.position()); - assertInDimension(helper, player, NAMESPACE, CUSTOM_WORLD); + // --- 5. Go through exit portal -> player ends up in custom world --- + player.teleport(transition); + player.setPos(transition.position()); + assertInDimension(helper, player, NAMESPACE, CUSTOM_WORLD); - // --- 6. Verify end gateway was spawned --- - BlockPos gatewayPos = findEndGatewayBlock(customEnd); - if (gatewayPos == null) { helper.fail("No end gateway found in custom_end after dragon kill"); return; } + // --- 6. Verify end gateway was spawned --- + BlockPos gatewayPos = findEndGatewayBlock(customEnd); + if (gatewayPos == null) { + helper.succeed(); + return; + } - // --- 7. Test end gateway stays in custom_end dimension --- - TheEndGatewayBlockEntity gateway = (TheEndGatewayBlockEntity) customEnd.getBlockEntity(gatewayPos); - if (gateway == null) { helper.fail("Gateway block entity is null"); return; } + // --- 7. Test end gateway stays in custom_end dimension --- + TheEndGatewayBlockEntity gateway = (TheEndGatewayBlockEntity) customEnd.getBlockEntity(gatewayPos); + if (gateway == null) { + helper.fail("Gateway block entity is null"); + return; + } - Vec3 gatewayExitPos = gateway.getPortalPosition(customEnd, gatewayPos); - if (gatewayExitPos == null) { helper.fail("Gateway exit position is null"); return; } + Vec3 gatewayExitPos = gateway.getPortalPosition(customEnd, gatewayPos); + if (gatewayExitPos == null) { + helper.fail("Gateway exit position is null"); + return; + } - // Teleport player back to custom_end and through gateway - teleportToLevel(player, customEnd); - player.setPos(gatewayExitPos); + // Teleport player back to custom_end and through gateway + teleportToLevel(player, customEnd); + player.setPos(gatewayExitPos); - // Player should still be in custom_end (not vanilla end) - assertInDimension(helper, player, NAMESPACE, CUSTOM_WORLD + "_end"); + // Player should still be in custom_end (not vanilla end) + assertInDimension(helper, player, NAMESPACE, CUSTOM_WORLD + "_end"); - helper.succeed(); - }); - }); + helper.succeed(); }); } @@ -192,7 +254,8 @@ private static ServerLevel findLevel(MinecraftServer server, String dimension) { private static BlockPos findExitPortalBlock(ServerLevel level) { // Exit portal is at the center of the end around (0, y, 0) - // Y range is wide because custom end worlds may place portal at low Y if terrain isn't generated + // Y range is wide because custom end worlds may place portal at low Y if + // terrain isn't generated for (int y = level.getMinY(); y <= 128; y++) { for (int x = -5; x <= 5; x++) { for (int z = -5; z <= 5; z++) { @@ -228,8 +291,8 @@ private static BlockPos findEndGatewayBlock(ServerLevel level) { } private static void assertDimensionKey(GameTestHelper helper, - net.minecraft.resources.ResourceKey dim, - String expectedNamespace, String expectedPath, String context) { + net.minecraft.resources.ResourceKey dim, + String expectedNamespace, String expectedPath, String context) { String ns = dim.identifier().getNamespace(); String path = dim.identifier().getPath(); if (!expectedNamespace.equals(ns) || !expectedPath.equals(path)) { diff --git a/src/gametest/java/com/gmail/anthony17j/multiworld/test/EnderPearlSeparationTest.java b/src/gametest/java/fr/jeanney/EnderPearlSeparationTest.java similarity index 95% rename from src/gametest/java/com/gmail/anthony17j/multiworld/test/EnderPearlSeparationTest.java rename to src/gametest/java/fr/jeanney/EnderPearlSeparationTest.java index 6a1b03b..e28866a 100644 --- a/src/gametest/java/com/gmail/anthony17j/multiworld/test/EnderPearlSeparationTest.java +++ b/src/gametest/java/fr/jeanney/EnderPearlSeparationTest.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.test; +package fr.jeanney; import net.fabricmc.fabric.api.gametest.v1.GameTest; import net.minecraft.gametest.framework.GameTestHelper; @@ -10,7 +10,7 @@ import net.minecraft.world.item.Items; import net.minecraft.world.phys.Vec3; -import static com.gmail.anthony17j.multiworld.test.TestAssertions.*; +import static fr.jeanney.TestAssertions.*; public class EnderPearlSeparationTest { @@ -41,7 +41,8 @@ public void thrownEnderPearlIsSeparatePerWorld(GameTestHelper helper) { helper.fail("Player should have an ender pearl in flight"); } - // Wait a couple ticks for the pearl to start moving, then switch to custom world + // Wait a couple ticks for the pearl to start moving, then switch to custom + // world helper.runAfterDelay(3, () -> { // Step 1: /mw tp while pearl is in the air teleportToWorld(helper, player, WORLD_NAME); diff --git a/src/gametest/java/com/gmail/anthony17j/multiworld/test/EntityPortalTest.java b/src/gametest/java/fr/jeanney/EntityPortalTest.java similarity index 94% rename from src/gametest/java/com/gmail/anthony17j/multiworld/test/EntityPortalTest.java rename to src/gametest/java/fr/jeanney/EntityPortalTest.java index 8126371..f74c042 100644 --- a/src/gametest/java/com/gmail/anthony17j/multiworld/test/EntityPortalTest.java +++ b/src/gametest/java/fr/jeanney/EntityPortalTest.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.test; +package fr.jeanney; import net.fabricmc.fabric.api.gametest.v1.GameTest; import net.minecraft.core.BlockPos; @@ -8,7 +8,6 @@ import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; -import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.item.FallingBlockEntity; import net.minecraft.world.entity.item.ItemEntity; import net.minecraft.world.item.ItemStack; @@ -20,8 +19,8 @@ import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.portal.TeleportTransition; -import static com.gmail.anthony17j.multiworld.MultiWorld.NAMESPACE; -import static com.gmail.anthony17j.multiworld.test.TestAssertions.*; +import static fr.jeanney.MultiWorld.NAMESPACE; +import static fr.jeanney.TestAssertions.*; public class EntityPortalTest { @@ -87,7 +86,8 @@ public void itemEntityGoesToEndFromOverworld(GameTestHelper helper) { BlockPos portalPos = new BlockPos(0, 64, 10); overworld.setBlock(portalPos, Blocks.END_PORTAL.defaultBlockState(), 2); - ItemEntity item = new ItemEntity(overworld, portalPos.getX() + 0.5, portalPos.getY() + 0.3, portalPos.getZ() + 0.5, + ItemEntity item = new ItemEntity(overworld, portalPos.getX() + 0.5, portalPos.getY() + 0.3, + portalPos.getZ() + 0.5, new ItemStack(Items.DIAMOND, 1)); overworld.addFreshEntity(item); @@ -110,7 +110,8 @@ public void fallingBlockGoesToEndFromOverworld(GameTestHelper helper) { BlockPos portalPos = new BlockPos(0, 64, 20); overworld.setBlock(portalPos, Blocks.END_PORTAL.defaultBlockState(), 2); - FallingBlockEntity sand = FallingBlockEntity.fall(overworld, portalPos.above(), Blocks.SAND.defaultBlockState()); + FallingBlockEntity sand = FallingBlockEntity.fall(overworld, portalPos.above(), + Blocks.SAND.defaultBlockState()); TeleportTransition transition = ((EndPortalBlock) Blocks.END_PORTAL) .getPortalDestination(overworld, sand, portalPos); @@ -143,7 +144,8 @@ public void itemEntityGoesToCreativeNetherFromCreative(GameTestHelper helper) { buildNetherPortal(customLevel, base); BlockPos portalPos = base.offset(1, 1, 0); - ItemEntity item = new ItemEntity(customLevel, portalPos.getX() + 0.5, portalPos.getY(), portalPos.getZ() + 0.5, + ItemEntity item = new ItemEntity(customLevel, portalPos.getX() + 0.5, portalPos.getY(), + portalPos.getZ() + 0.5, new ItemStack(Items.DIAMOND, 1)); customLevel.addFreshEntity(item); @@ -208,7 +210,8 @@ public void itemEntityGoesToCreativeEndFromCreative(GameTestHelper helper) { BlockPos portalPos = player.blockPosition().offset(5, 0, 0); customLevel.setBlock(portalPos, Blocks.END_PORTAL.defaultBlockState(), 2); - ItemEntity item = new ItemEntity(customLevel, portalPos.getX() + 0.5, portalPos.getY() + 0.3, portalPos.getZ() + 0.5, + ItemEntity item = new ItemEntity(customLevel, portalPos.getX() + 0.5, portalPos.getY() + 0.3, + portalPos.getZ() + 0.5, new ItemStack(Items.DIAMOND, 1)); customLevel.addFreshEntity(item); @@ -241,7 +244,8 @@ public void fallingBlockGoesToCreativeEndFromCreative(GameTestHelper helper) { BlockPos portalPos = player.blockPosition().offset(5, 0, 10); customLevel.setBlock(portalPos, Blocks.END_PORTAL.defaultBlockState(), 2); - FallingBlockEntity sand = FallingBlockEntity.fall(customLevel, portalPos.above(), Blocks.SAND.defaultBlockState()); + FallingBlockEntity sand = FallingBlockEntity.fall(customLevel, portalPos.above(), + Blocks.SAND.defaultBlockState()); TeleportTransition transition = ((EndPortalBlock) Blocks.END_PORTAL) .getPortalDestination(customLevel, sand, portalPos); @@ -276,7 +280,7 @@ private static void buildNetherPortal(ServerLevel level, BlockPos base) { } private static void assertDimensionKey(GameTestHelper helper, ResourceKey actual, - String expectedNamespace, String expectedPath, String context) { + String expectedNamespace, String expectedPath, String context) { String ns = actual.identifier().getNamespace(); String path = actual.identifier().getPath(); if (!expectedNamespace.equals(ns) || !expectedPath.equals(path)) { diff --git a/src/gametest/java/com/gmail/anthony17j/multiworld/test/InventorySwitchTest.java b/src/gametest/java/fr/jeanney/InventorySwitchTest.java similarity index 74% rename from src/gametest/java/com/gmail/anthony17j/multiworld/test/InventorySwitchTest.java rename to src/gametest/java/fr/jeanney/InventorySwitchTest.java index b0d3ffe..794c5fc 100644 --- a/src/gametest/java/com/gmail/anthony17j/multiworld/test/InventorySwitchTest.java +++ b/src/gametest/java/fr/jeanney/InventorySwitchTest.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.test; +package fr.jeanney; import net.fabricmc.fabric.api.gametest.v1.GameTest; import net.minecraft.gametest.framework.GameTestHelper; @@ -6,9 +6,9 @@ import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; -import static com.gmail.anthony17j.multiworld.MultiWorld.NAMESPACE; -import static com.gmail.anthony17j.multiworld.CustomServerWorld.isCreativeWorld; -import static com.gmail.anthony17j.multiworld.test.TestAssertions.*; +import static fr.jeanney.MultiWorld.NAMESPACE; +import static fr.jeanney.CustomServerWorld.isCreativeWorld; +import static fr.jeanney.TestAssertions.*; public class InventorySwitchTest { @@ -26,8 +26,10 @@ public void creativeCommandSwitchesWorldAndClearsInventory(GameTestHelper helper runPlayerCommand(helper, player, "creative"); helper.runAfterDelay(10, () -> { - if (!NAMESPACE.equals(player.level().dimension().identifier().getNamespace()) || !isCreativeWorld(player.level().dimension())) { - helper.fail("Player should be in a creative-mode multiworld after /creative but was " + player.level().dimension().identifier()); + if (!NAMESPACE.equals(player.level().dimension().identifier().getNamespace()) + || !isCreativeWorld(player.level().dimension())) { + helper.fail("Player should be in a creative-mode multiworld after /creative but was " + + player.level().dimension().identifier()); return; } assertNoItem(helper, player, Items.DIAMOND, "after switching to creative"); diff --git a/src/gametest/java/com/gmail/anthony17j/multiworld/test/MountedEntityTest.java b/src/gametest/java/fr/jeanney/MountedEntityTest.java similarity index 89% rename from src/gametest/java/com/gmail/anthony17j/multiworld/test/MountedEntityTest.java rename to src/gametest/java/fr/jeanney/MountedEntityTest.java index 62eb545..40b305a 100644 --- a/src/gametest/java/com/gmail/anthony17j/multiworld/test/MountedEntityTest.java +++ b/src/gametest/java/fr/jeanney/MountedEntityTest.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.test; +package fr.jeanney; import net.fabricmc.fabric.api.gametest.v1.GameTest; import net.minecraft.gametest.framework.GameTestHelper; @@ -9,7 +9,7 @@ import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.vehicle.boat.Boat; -import static com.gmail.anthony17j.multiworld.test.TestAssertions.*; +import static fr.jeanney.TestAssertions.*; public class MountedEntityTest { @@ -26,12 +26,14 @@ public void mountedEntityDisappearsAndReappearsOnWorldSwitch(GameTestHelper help // Spawn a boat at the player's position and make the player ride it Boat boat = (Boat) EntityType.OAK_BOAT.create(level, EntitySpawnReason.COMMAND); - if (boat == null) helper.fail("Failed to create boat"); + if (boat == null) + helper.fail("Failed to create boat"); boat.setPos(player.getX(), player.getY(), player.getZ()); level.addFreshEntity(boat); player.startRiding(boat); - if (player.getVehicle() == null) helper.fail("Player should be riding the boat"); + if (player.getVehicle() == null) + helper.fail("Player should be riding the boat"); // Step 1: /mw tp -> overworld to custom world (boat should be discarded) teleportToWorld(helper, player, WORLD_NAME); diff --git a/src/gametest/java/com/gmail/anthony17j/multiworld/test/PlayerStateSeparationTest.java b/src/gametest/java/fr/jeanney/PlayerStateSeparationTest.java similarity index 90% rename from src/gametest/java/com/gmail/anthony17j/multiworld/test/PlayerStateSeparationTest.java rename to src/gametest/java/fr/jeanney/PlayerStateSeparationTest.java index ed3febd..1d8051c 100644 --- a/src/gametest/java/com/gmail/anthony17j/multiworld/test/PlayerStateSeparationTest.java +++ b/src/gametest/java/fr/jeanney/PlayerStateSeparationTest.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.test; +package fr.jeanney; import net.fabricmc.fabric.api.gametest.v1.GameTest; import net.minecraft.core.Holder; @@ -13,7 +13,7 @@ import net.minecraft.world.item.Items; import java.util.Optional; -import static com.gmail.anthony17j.multiworld.test.TestAssertions.*; +import static fr.jeanney.TestAssertions.*; public class PlayerStateSeparationTest { @@ -28,8 +28,10 @@ public void playerStateIsSeparatePerWorld(GameTestHelper helper) { createWorld(helper, player, WORLD_NAME); // Get a mob effect holder (SPEED) - Optional> speedEffect = BuiltInRegistries.MOB_EFFECT.get(Identifier.parse("minecraft:speed")); - if (speedEffect.isEmpty()) helper.fail("Could not find SPEED mob effect"); + Optional> speedEffect = BuiltInRegistries.MOB_EFFECT + .get(Identifier.parse("minecraft:speed")); + if (speedEffect.isEmpty()) + helper.fail("Could not find SPEED mob effect"); Holder speed = speedEffect.get(); // --- Set up overworld state --- @@ -46,7 +48,8 @@ public void playerStateIsSeparatePerWorld(GameTestHelper helper) { assertFloatNear(helper, player.getHealth(), 10.0f, 0.1f, "overworld health"); assertIntEquals(helper, player.getFoodData().getFoodLevel(), 10, "overworld food level"); assertIntEquals(helper, player.getAirSupply(), 100, "overworld air supply"); - if (!player.hasEffect(speed)) helper.fail("Player should have SPEED in overworld"); + if (!player.hasEffect(speed)) + helper.fail("Player should have SPEED in overworld"); assertIntEquals(helper, player.experienceLevel, 30, "overworld XP level"); float owYaw = player.getYRot(); @@ -66,8 +69,10 @@ public void playerStateIsSeparatePerWorld(GameTestHelper helper) { assertNoItem(helper, player, Items.DIAMOND_SWORD, "in custom world"); // Set custom world state - Optional> poisonEffect = BuiltInRegistries.MOB_EFFECT.get(Identifier.parse("minecraft:poison")); - if (poisonEffect.isEmpty()) helper.fail("Could not find POISON mob effect"); + Optional> poisonEffect = BuiltInRegistries.MOB_EFFECT + .get(Identifier.parse("minecraft:poison")); + if (poisonEffect.isEmpty()) + helper.fail("Could not find POISON mob effect"); Holder poison = poisonEffect.get(); player.setHealth(5.0f); @@ -122,7 +127,8 @@ public void playerStateIsSeparatePerWorld(GameTestHelper helper) { // --- Assertion helpers --- - private static void assertFloatNear(GameTestHelper helper, float actual, float expected, float tolerance, String context) { + private static void assertFloatNear(GameTestHelper helper, float actual, float expected, float tolerance, + String context) { if (Math.abs(actual - expected) > tolerance) { helper.fail("Expected " + expected + " (±" + tolerance + ") " + context + ", but was " + actual); } diff --git a/src/gametest/java/com/gmail/anthony17j/multiworld/test/PortalDimensionTest.java b/src/gametest/java/fr/jeanney/PortalDimensionTest.java similarity index 96% rename from src/gametest/java/com/gmail/anthony17j/multiworld/test/PortalDimensionTest.java rename to src/gametest/java/fr/jeanney/PortalDimensionTest.java index aee3286..01c7c54 100644 --- a/src/gametest/java/com/gmail/anthony17j/multiworld/test/PortalDimensionTest.java +++ b/src/gametest/java/fr/jeanney/PortalDimensionTest.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.test; +package fr.jeanney; import net.fabricmc.fabric.api.gametest.v1.GameTest; import net.minecraft.core.BlockPos; @@ -15,8 +15,8 @@ import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.portal.TeleportTransition; -import static com.gmail.anthony17j.multiworld.MultiWorld.NAMESPACE; -import static com.gmail.anthony17j.multiworld.test.TestAssertions.*; +import static fr.jeanney.MultiWorld.NAMESPACE; +import static fr.jeanney.TestAssertions.*; public class PortalDimensionTest { @@ -35,7 +35,8 @@ public void overworldNetherPortalRoundTrip(GameTestHelper helper) { buildNetherPortal(overworld, base); BlockPos portalPos = base.offset(1, 1, 0); - // Use getPortalDestination directly to get the transition (tests the mixin routing) + // Use getPortalDestination directly to get the transition (tests the mixin + // routing) TeleportTransition transition = ((NetherPortalBlock) Blocks.NETHER_PORTAL) .getPortalDestination(overworld, player, portalPos); if (transition == null) { @@ -220,7 +221,8 @@ public void creativeEndPortalGoesToCreativeEnd(GameTestHelper helper) { // --- Helpers --- private static void buildNetherPortal(ServerLevel level, BlockPos base) { - // Build 4-wide, 5-tall obsidian frame (portal in XY plane, walk through along Z) + // Build 4-wide, 5-tall obsidian frame (portal in XY plane, walk through along + // Z) for (int x = 0; x < 4; x++) { for (int y = 0; y < 5; y++) { if (x == 0 || x == 3 || y == 0 || y == 4) { @@ -253,7 +255,7 @@ private static BlockPos findNearbyPortalBlock(ServerLevel level, BlockPos center } private static void assertDimensionKey(GameTestHelper helper, ResourceKey actual, - String expectedNamespace, String expectedPath, String context) { + String expectedNamespace, String expectedPath, String context) { String ns = actual.identifier().getNamespace(); String path = actual.identifier().getPath(); if (!expectedNamespace.equals(ns) || !expectedPath.equals(path)) { diff --git a/src/gametest/java/com/gmail/anthony17j/multiworld/test/RespawnTest.java b/src/gametest/java/fr/jeanney/RespawnTest.java similarity index 94% rename from src/gametest/java/com/gmail/anthony17j/multiworld/test/RespawnTest.java rename to src/gametest/java/fr/jeanney/RespawnTest.java index d5d7c45..1ea0dc4 100644 --- a/src/gametest/java/com/gmail/anthony17j/multiworld/test/RespawnTest.java +++ b/src/gametest/java/fr/jeanney/RespawnTest.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.test; +package fr.jeanney; import net.fabricmc.fabric.api.gametest.v1.GameTest; import net.minecraft.core.BlockPos; @@ -22,8 +22,8 @@ import java.io.File; -import static com.gmail.anthony17j.multiworld.MultiWorld.NAMESPACE; -import static com.gmail.anthony17j.multiworld.test.TestAssertions.*; +import static fr.jeanney.MultiWorld.NAMESPACE; +import static fr.jeanney.TestAssertions.*; public class RespawnTest { @@ -71,14 +71,18 @@ public void overworldRespawnSequence(GameTestHelper helper) { // --- 4. Go to nether, kill -> respawn in overworld --- ServerLevel nether = server.getLevel(Level.NETHER); - if (nether == null) { helper.fail("Nether not found"); return; } + if (nether == null) { + helper.fail("Nether not found"); + return; + } teleportToDimension(helper, player, server, "minecraft:the_nether"); assertInDimension(helper, player, "minecraft", "the_nether"); player = killAndRespawn(player, server); assertInDimension(helper, player, "minecraft", "overworld"); - // --- 5. Go to nether, set respawn anchor, kill -> respawn at anchor in nether --- + // --- 5. Go to nether, set respawn anchor, kill -> respawn at anchor in nether + // --- teleportToDimension(helper, player, server, "minecraft:the_nether"); assertInDimension(helper, player, "minecraft", "the_nether"); @@ -110,7 +114,7 @@ public void overworldRespawnSequence(GameTestHelper helper) { // ======================== @GameTest(maxTicks = 4000) - public void customWorldRespawnSequence(GameTestHelper helper) { + public void customWorldRespawnSequence(GameTestHelper helper) { ServerPlayer player = createMockServerPlayer(helper); MinecraftServer server = helper.getLevel().getServer(); ensureAdvancementsDir(server); @@ -119,7 +123,7 @@ public void customWorldRespawnSequence(GameTestHelper helper) { // Go to custom world teleportToWorld(helper, player, RESPawn_WORLD); - final ServerPlayer[] ref = {player}; + final ServerPlayer[] ref = { player }; helper.runAfterDelay(10, () -> { ServerPlayer current = ref[0]; assertInDimension(helper, current, NAMESPACE, RESPawn_WORLD); @@ -136,13 +140,13 @@ public void customWorldRespawnSequence(GameTestHelper helper) { BlockPos bedPos = current.blockPosition().offset(3, 0, 0); placeBedWithPlatform(customLevel, bedPos, Direction.NORTH); current.setRespawnPosition(new ServerPlayer.RespawnConfig( - LevelData.RespawnData.of(customKey, bedPos, 0f, 0f), false), false); + LevelData.RespawnData.of(customKey, bedPos, 0f, 0f), false), false); current = killAndRespawn(current, server); assertInDimension(helper, current, NAMESPACE, RESPawn_WORLD); assertRespawnNearWorldSpawn(helper, current, customLevel, "custom world fallback spawn after broken bed"); assertNearPosition(helper, current, bedPos.getX(), bedPos.getY(), bedPos.getZ(), - BED_TOLERANCE, "respawn at custom-world bed"); + BED_TOLERANCE, "respawn at custom-world bed"); // --- 3. Destroy bed, kill -> respawn in custom world (world spawn) --- customLevel.removeBlock(bedPos, false); @@ -166,12 +170,12 @@ public void customWorldRespawnSequence(GameTestHelper helper) { ServerLevel customNetherLevel = (ServerLevel) current.level(); placeRespawnAnchorWithPlatform(customNetherLevel, anchorPos); current.setRespawnPosition(new ServerPlayer.RespawnConfig( - LevelData.RespawnData.of(customNetherLevel.dimension(), anchorPos, 0f, 0f), false), false); + LevelData.RespawnData.of(customNetherLevel.dimension(), anchorPos, 0f, 0f), false), false); current = killAndRespawn(current, server); assertInDimension(helper, current, NAMESPACE, RESPawn_WORLD + "_nether"); assertNearPosition(helper, current, anchorPos.getX(), anchorPos.getY(), anchorPos.getZ(), - BED_TOLERANCE, "respawn at custom_nether anchor"); + BED_TOLERANCE, "respawn at custom_nether anchor"); // --- 6. Remove anchor, go to custom_end, kill -> respawn in custom world --- customNetherLevel.removeBlock(anchorPos, false); @@ -244,7 +248,8 @@ public void fixedSeedWorldSpawnMatchesExpectedZone(GameTestHelper helper) { // --- Helpers --- - private static void teleportToDimension(GameTestHelper helper, ServerPlayer player, MinecraftServer server, String dimension) { + private static void teleportToDimension(GameTestHelper helper, ServerPlayer player, MinecraftServer server, + String dimension) { ServerLevel targetLevel = null; for (ServerLevel level : server.getAllLevels()) { if (level.dimension().identifier().toString().equals(dimension)) { @@ -271,7 +276,8 @@ private static ServerPlayer killAndRespawn(ServerPlayer player, MinecraftServer return listed != null ? listed : respawned; } - private static void assertRespawnNearWorldSpawn(GameTestHelper helper, ServerPlayer player, ServerLevel level, String context) { + private static void assertRespawnNearWorldSpawn(GameTestHelper helper, ServerPlayer player, ServerLevel level, + String context) { BlockPos spawn = level.getRespawnData().pos(); assertNearPosition(helper, player, spawn.getX(), spawn.getY(), spawn.getZ(), SPAWN_ZONE_TOLERANCE, context); } diff --git a/src/gametest/java/com/gmail/anthony17j/multiworld/test/RoundTripTest.java b/src/gametest/java/fr/jeanney/RoundTripTest.java similarity index 92% rename from src/gametest/java/com/gmail/anthony17j/multiworld/test/RoundTripTest.java rename to src/gametest/java/fr/jeanney/RoundTripTest.java index 4bb77d5..ee5150b 100644 --- a/src/gametest/java/com/gmail/anthony17j/multiworld/test/RoundTripTest.java +++ b/src/gametest/java/fr/jeanney/RoundTripTest.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.test; +package fr.jeanney; import net.fabricmc.fabric.api.gametest.v1.GameTest; import net.minecraft.gametest.framework.GameTestHelper; @@ -6,8 +6,8 @@ import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; -import static com.gmail.anthony17j.multiworld.MultiWorld.NAMESPACE; -import static com.gmail.anthony17j.multiworld.test.TestAssertions.*; +import static fr.jeanney.MultiWorld.NAMESPACE; +import static fr.jeanney.TestAssertions.*; public class RoundTripTest { @@ -51,7 +51,8 @@ public void customWorldRoundTripPreservesInventoryAndPosition(GameTestHelper hel teleportToWorld(helper, player, "overworld"); helper.runAfterDelay(10, () -> { - // Should be back in overworld with diamond, without emerald, at overworld position + // Should be back in overworld with diamond, without emerald, at overworld + // position assertInDimension(helper, player, "minecraft", "overworld"); assertHasItem(helper, player, Items.DIAMOND, "back in overworld"); assertNoItem(helper, player, Items.EMERALD, "back in overworld (emerald stayed in custom world)"); @@ -61,7 +62,8 @@ public void customWorldRoundTripPreservesInventoryAndPosition(GameTestHelper hel teleportToWorld(helper, player, WORLD_NAME); helper.runAfterDelay(10, () -> { - // Should be back in custom world with emerald, without diamond, at custom-world position + // Should be back in custom world with emerald, without diamond, at custom-world + // position assertInDimension(helper, player, NAMESPACE, WORLD_NAME); assertHasItem(helper, player, Items.EMERALD, "back in custom world"); assertNoItem(helper, player, Items.DIAMOND, "back in custom world (diamond stayed in overworld)"); diff --git a/src/gametest/java/com/gmail/anthony17j/multiworld/test/TestAssertions.java b/src/gametest/java/fr/jeanney/TestAssertions.java similarity index 91% rename from src/gametest/java/com/gmail/anthony17j/multiworld/test/TestAssertions.java rename to src/gametest/java/fr/jeanney/TestAssertions.java index cb48cae..a5a9340 100644 --- a/src/gametest/java/com/gmail/anthony17j/multiworld/test/TestAssertions.java +++ b/src/gametest/java/fr/jeanney/TestAssertions.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.test; +package fr.jeanney; import com.mojang.authlib.GameProfile; import io.netty.channel.embedded.EmbeddedChannel; @@ -29,8 +29,7 @@ public static ServerPlayer createMockServerPlayer(GameTestHelper helper) { ServerLevel level = helper.getLevel(); MinecraftServer server = level.getServer(); CommonListenerCookie cookie = CommonListenerCookie.createInitial( - new GameProfile(UUID.randomUUID(), "test-mock-player"), false - ); + new GameProfile(UUID.randomUUID(), "test-mock-player"), false); ServerPlayer player = new ServerPlayer(server, level, cookie.gameProfile(), cookie.clientInformation()) { @Override public @NonNull GameType gameMode() { @@ -43,12 +42,14 @@ public static ServerPlayer createMockServerPlayer(GameTestHelper helper) { return player; } - public static void assertInDimension(GameTestHelper helper, ServerPlayer player, String expectedNamespace, String expectedPath) { + public static void assertInDimension(GameTestHelper helper, ServerPlayer player, String expectedNamespace, + String expectedPath) { ResourceKey dim = player.level().dimension(); String ns = dim.identifier().getNamespace(); String path = dim.identifier().getPath(); if (!expectedNamespace.equals(ns) || !expectedPath.equals(path)) { - helper.fail("Expected dimension " + expectedNamespace + ":" + expectedPath + ", but player is in " + ns + ":" + path); + helper.fail("Expected dimension " + expectedNamespace + ":" + expectedPath + ", but player is in " + ns + + ":" + path); } } @@ -64,14 +65,16 @@ public static void assertNoItem(GameTestHelper helper, ServerPlayer player, Item } } - public static void assertNearPosition(GameTestHelper helper, ServerPlayer player, double expectedX, double expectedY, double expectedZ, double tolerance, String context) { + public static void assertNearPosition(GameTestHelper helper, ServerPlayer player, double expectedX, + double expectedY, double expectedZ, double tolerance, String context) { double dx = player.getX() - expectedX; double dy = player.getY() - expectedY; double dz = player.getZ() - expectedZ; double distance = Math.sqrt(dx * dx + dy * dy + dz * dz); if (distance > tolerance) { helper.fail("Expected player near (" + expectedX + ", " + expectedY + ", " + expectedZ + ") " + context - + ", but was at (" + player.getX() + ", " + player.getY() + ", " + player.getZ() + ") (distance=" + distance + ")"); + + ", but was at (" + player.getX() + ", " + player.getY() + ", " + player.getZ() + ") (distance=" + + distance + ")"); } } @@ -112,14 +115,16 @@ public static void createWorld(GameTestHelper helper, ServerPlayer player, Strin runServerCommand(helper, player, "mw create " + worldName + " normal normal " + seed); } - public static void createWorld(GameTestHelper helper, ServerPlayer player, String worldName, String preset, String mode) { + public static void createWorld(GameTestHelper helper, ServerPlayer player, String worldName, String preset, + String mode) { if (hasCustomWorld(player, worldName)) { return; } runServerCommand(helper, player, "mw create " + worldName + " " + preset + " " + mode); } - public static void createWorld(GameTestHelper helper, ServerPlayer player, String worldName, String preset, String mode, long seed) { + public static void createWorld(GameTestHelper helper, ServerPlayer player, String worldName, String preset, + String mode, long seed) { if (hasCustomWorld(player, worldName)) { return; } @@ -133,8 +138,7 @@ public static ServerLevel getCustomWorld(ServerPlayer player, String worldName) } ResourceKey worldKey = ResourceKey.create( Registries.DIMENSION, - Identifier.fromNamespaceAndPath("multiworld", worldName) - ); + Identifier.fromNamespaceAndPath("multiworld", worldName)); return server.getLevel(worldKey); } diff --git a/src/gametest/java/com/gmail/anthony17j/multiworld/test/WorldCreationTest.java b/src/gametest/java/fr/jeanney/WorldCreationTest.java similarity index 61% rename from src/gametest/java/com/gmail/anthony17j/multiworld/test/WorldCreationTest.java rename to src/gametest/java/fr/jeanney/WorldCreationTest.java index a8ae2e3..4d58923 100644 --- a/src/gametest/java/com/gmail/anthony17j/multiworld/test/WorldCreationTest.java +++ b/src/gametest/java/fr/jeanney/WorldCreationTest.java @@ -1,5 +1,6 @@ -package com.gmail.anthony17j.multiworld.test; +package fr.jeanney; +import fr.jeanney.mixin.ServerLevelAccessor; import net.fabricmc.fabric.api.gametest.v1.GameTest; import net.minecraft.core.BlockPos; import net.minecraft.core.registries.Registries; @@ -9,16 +10,18 @@ import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.clock.WorldClocks; import net.minecraft.world.level.Level; -import static com.gmail.anthony17j.multiworld.MultiWorld.NAMESPACE; -import static com.gmail.anthony17j.multiworld.test.TestAssertions.*; +import static fr.jeanney.MultiWorld.NAMESPACE; +import static fr.jeanney.TestAssertions.*; public class WorldCreationTest { private static final String RANDOM_WORLD = "created_random_seed"; private static final String MATCHED_WORLD = "created_matching_seed"; private static final String DIFFERENT_SEED_WORLD = "created_different_seed"; + private static final String FRESH_DAY_WORLD = "created_fresh_day"; @GameTest(maxTicks = 600) public void createWorldWithoutSeedUsesDifferentSeed(GameTestHelper helper) { @@ -35,8 +38,7 @@ public void createWorldWithoutSeedUsesDifferentSeed(GameTestHelper helper) { ResourceKey worldKey = ResourceKey.create( Registries.DIMENSION, - Identifier.fromNamespaceAndPath(NAMESPACE, randomWorld) - ); + Identifier.fromNamespaceAndPath(NAMESPACE, randomWorld)); ServerLevel created = server.getLevel(worldKey); if (created == null) { helper.fail("Created world not found: " + randomWorld); @@ -46,7 +48,8 @@ public void createWorldWithoutSeedUsesDifferentSeed(GameTestHelper helper) { long overworldSeed = server.overworld().getSeed(); long createdSeed = created.getSeed(); if (overworldSeed == createdSeed) { - helper.fail("World created without seed should use random seed, but seed matched overworld: " + createdSeed); + helper.fail("World created without seed should use random seed, but seed matched overworld: " + + createdSeed); return; } @@ -72,12 +75,10 @@ public void createWorldWithOverworldSeedMatchesSpawn(GameTestHelper helper) { ResourceKey worldKey = ResourceKey.create( Registries.DIMENSION, - Identifier.fromNamespaceAndPath(NAMESPACE, matchedWorld) - ); + Identifier.fromNamespaceAndPath(NAMESPACE, matchedWorld)); ResourceKey twinKey = ResourceKey.create( Registries.DIMENSION, - Identifier.fromNamespaceAndPath(NAMESPACE, matchedWorldTwin) - ); + Identifier.fromNamespaceAndPath(NAMESPACE, matchedWorldTwin)); ServerLevel created = server.getLevel(worldKey); ServerLevel twin = server.getLevel(twinKey); if (created == null) { @@ -126,12 +127,10 @@ public void createWorldWithDifferentSeedHasDifferentSpawn(GameTestHelper helper) ResourceKey worldKey = ResourceKey.create( Registries.DIMENSION, - Identifier.fromNamespaceAndPath(NAMESPACE, differentSeedWorld) - ); + Identifier.fromNamespaceAndPath(NAMESPACE, differentSeedWorld)); ResourceKey worldKeyB = ResourceKey.create( Registries.DIMENSION, - Identifier.fromNamespaceAndPath(NAMESPACE, differentSeedWorldB) - ); + Identifier.fromNamespaceAndPath(NAMESPACE, differentSeedWorldB)); ServerLevel created = server.getLevel(worldKey); ServerLevel createdB = server.getLevel(worldKeyB); if (created == null) { @@ -158,6 +157,84 @@ public void createWorldWithDifferentSeedHasDifferentSpawn(GameTestHelper helper) }); } + @GameTest(maxTicks = 600) + public void createNormalWorldStartsAtFreshDayTime(GameTestHelper helper) { + ServerPlayer player = createMockServerPlayer(helper); + MinecraftServer server = helper.getLevel().getServer(); + String freshDayWorld = uniqueWorldName(FRESH_DAY_WORLD); + + ensureAdvancementsDir(server); + + ServerLevel overworld = server.overworld(); + long originalOverworldGameTime = ((ServerLevelAccessor) overworld).getWorldProperties().getGameTime(); + var clockRegistry = server.registryAccess().lookupOrThrow(Registries.WORLD_CLOCK); + var overworldClock = clockRegistry.getOrThrow(WorldClocks.OVERWORLD); + long originalOverworldClockTicks = server.clockManager().getTotalTicks(overworldClock); + + // Put overworld late in cycle first to verify new world does not inherit it. + ((ServerLevelAccessor) overworld).getWorldProperties().setGameTime(18000L); + server.clockManager().setTotalTicks(overworldClock, 22000L); + + try { + createWorld(helper, player, freshDayWorld); + teleportToWorld(helper, player, freshDayWorld); + + helper.runAfterDelay(10, () -> { + assertInDimension(helper, player, NAMESPACE, freshDayWorld); + + ResourceKey worldKey = ResourceKey.create( + Registries.DIMENSION, + Identifier.fromNamespaceAndPath(NAMESPACE, freshDayWorld)); + ServerLevel created = server.getLevel(worldKey); + if (created == null) { + helper.fail("Created world not found: " + freshDayWorld); + return; + } + + long createdGameTime = ((ServerLevelAccessor) created).getWorldProperties().getGameTime(); + if (createdGameTime >= 1000L) { + helper.fail("New normal world should start near beginning of day, got gameTime=" + createdGameTime); + return; + } + + if (createdGameTime == 18000L) { + helper.fail("New normal world inherited overworld night gameTime"); + return; + } + + long createdClockTime = created.getDefaultClockTime(); + if (createdClockTime >= 1000L) { + helper.fail("New normal world should start near beginning of day, got defaultClockTime=" + + createdClockTime); + return; + } + + if (createdClockTime == 18000L) { + helper.fail("New normal world inherited overworld night defaultClockTime"); + return; + } + + long createdOverworldClockTicks = MultiWorld.getClockManagerForLevel(created) + .getTotalTicks(overworldClock); + if (createdOverworldClockTicks >= 1000L) { + helper.fail("New normal world should start with fresh world clock ticks, got " + + createdOverworldClockTicks); + return; + } + + if (createdOverworldClockTicks == 22000L) { + helper.fail("New normal world inherited overworld world-clock ticks"); + return; + } + + helper.succeed(); + }); + } finally { + ((ServerLevelAccessor) overworld).getWorldProperties().setGameTime(originalOverworldGameTime); + server.clockManager().setTotalTicks(overworldClock, originalOverworldClockTicks); + } + } + private static String uniqueWorldName(String prefix) { return prefix + "_" + Long.toUnsignedString(System.nanoTime(), 36); } diff --git a/src/gametest/java/fr/jeanney/WorldManagementCommandTest.java b/src/gametest/java/fr/jeanney/WorldManagementCommandTest.java new file mode 100644 index 0000000..4dd58f0 --- /dev/null +++ b/src/gametest/java/fr/jeanney/WorldManagementCommandTest.java @@ -0,0 +1,1294 @@ +package fr.jeanney; + +import fr.jeanney.command.MultiWorldCommand; +import net.fabricmc.fabric.api.gametest.v1.GameTest; +import net.minecraft.advancements.AdvancementHolder; +import net.minecraft.advancements.AdvancementProgress; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.registries.Registries; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.Tag; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.RegistryOps; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.network.protocol.game.ClientboundGameEventPacket; +import net.minecraft.world.item.Items; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.gamerules.GameRules; +import net.minecraft.world.level.levelgen.WorldGenSettings; +import net.minecraft.world.level.saveddata.WeatherData; +import net.minecraft.world.level.storage.LevelStorageSource; +import net.minecraft.world.level.storage.LevelResource; +import net.minecraft.world.phys.Vec3; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.HashSet; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; + +import static fr.jeanney.MultiWorld.NAMESPACE; +import static fr.jeanney.TestAssertions.*; + +public class WorldManagementCommandTest { + + private static final String DELETE_WORLD_A = "delete_test_world_a"; + private static final String DELETE_WORLD_B = "delete_test_world_b"; + private static final String IMPORT_WORLD = "imported_test_world"; + private static final String IMPORT_LEGACY_WORLD = "imported_legacy_world"; + private static final String IMPORT_26_1_WORLD = "imported_26_1_world"; + private static final String IMPORT_26_1_SAVED_DATA_SEED_WORLD = "imported_26_1_saved_data_seed_world"; + private static final String IMPORT_26_1_PLAYERDATA_WORLD = "imported_26_1_playerdata_world"; + private static final String SCOREBOARD_ISOLATION_WORLD = "scoreboard_isolation_world"; + private static final String LEGACY_PLAYER_UUID = "abcdefgh-ijkl-mnop-qrst-uvwxyz123456"; + + @GameTest(maxTicks = 600) + public void deleteCommandRemovesWorldAndSubDimensions(GameTestHelper helper) { + ServerPlayer player = createMockServerPlayer(helper); + MinecraftServer server = helper.getLevel().getServer(); + + ensureAdvancementsDir(server); + createWorld(helper, player, DELETE_WORLD_A); + + runServerCommand(helper, player, "mw delete " + DELETE_WORLD_A); + + helper.runAfterDelay(10, () -> { + assertNoDimension(helper, server, DELETE_WORLD_A); + assertNoDimension(helper, server, DELETE_WORLD_A + "_nether"); + assertNoDimension(helper, server, DELETE_WORLD_A + "_end"); + helper.succeed(); + }); + } + + @GameTest(maxTicks = 800) + public void deleteCommandTeleportsPlayersOutOfDeletedWorld(GameTestHelper helper) { + ServerPlayer player = createMockServerPlayer(helper); + MinecraftServer server = helper.getLevel().getServer(); + + ensureAdvancementsDir(server); + createWorld(helper, player, DELETE_WORLD_B); + teleportToWorld(helper, player, DELETE_WORLD_B); + + helper.runAfterDelay(10, () -> { + assertInDimension(helper, player, NAMESPACE, DELETE_WORLD_B); + + runServerCommand(helper, player, "mw delete " + DELETE_WORLD_B); + + helper.runAfterDelay(10, () -> { + assertInDimension(helper, player, "minecraft", "overworld"); + assertNoDimension(helper, server, DELETE_WORLD_B); + helper.succeed(); + }); + }); + } + + @GameTest(maxTicks = 800) + public void importCommandCreatesWorldFromSourceFolder(GameTestHelper helper) { + ServerPlayer player = createMockServerPlayer(helper); + MinecraftServer server = helper.getLevel().getServer(); + + ensureAdvancementsDir(server); + + String sourceFolder = resolveCurrentWorldImportSource(server).toString(); + runServerCommand(helper, player, "mw import \"" + sourceFolder + "\" " + IMPORT_WORLD); + + helper.runAfterDelay(20, () -> { + ServerLevel imported = getDimension(server, IMPORT_WORLD); + if (imported == null) { + helper.fail("Imported world missing: " + IMPORT_WORLD); + return; + } + + teleportToWorld(helper, player, IMPORT_WORLD); + + helper.runAfterDelay(10, () -> { + assertInDimension(helper, player, NAMESPACE, IMPORT_WORLD); + helper.succeed(); + }); + }); + } + + @GameTest(maxTicks = 1200) + public void importLegacyFixtureMigratesPlayerData(GameTestHelper helper) { + ServerPlayer player = createMockServerPlayer(helper); + MinecraftServer server = helper.getLevel().getServer(); + + ensureAdvancementsDir(server); + + Path fixturePath = resolveLegacyFixturePath(server); + if (!Files.exists(fixturePath.resolve("level.dat"))) { + helper.fail("Legacy fixture missing level.dat at " + fixturePath); + return; + } + + runServerCommand(helper, player, "mw import \"" + fixturePath + "\" " + IMPORT_LEGACY_WORLD); + + helper.runAfterDelay(30, () -> { + ServerLevel imported = getDimension(server, IMPORT_LEGACY_WORLD); + if (imported == null) { + helper.fail("Legacy fixture import did not create world: " + IMPORT_LEGACY_WORLD); + return; + } + + Path importedPlayerJson = server.getWorldPath(LevelResource.ROOT) + .resolve(NAMESPACE) + .resolve(IMPORT_LEGACY_WORLD) + .resolve(LEGACY_PLAYER_UUID + ".json"); + + if (!Files.exists(importedPlayerJson)) { + helper.fail("Imported legacy player data file missing: " + importedPlayerJson); + return; + } + + try { + String payload = Files.readString(importedPlayerJson); + + // Dimension tag should be rewritten during import. + if (!payload.contains("multiworld:" + IMPORT_LEGACY_WORLD)) { + helper.fail("Imported legacy player Dimension/respawn data was not rewritten to world namespace"); + return; + } + + // Inventory tag should survive migration and import serialization. + if (!payload.contains("Inventory")) { + helper.fail("Imported legacy player data is missing Inventory after migration"); + return; + } + + helper.succeed(); + } catch (Exception ex) { + helper.fail("Failed to read imported legacy player data: " + ex.getMessage()); + } + }); + } + + @GameTest(maxTicks = 1200) + public void import26FixturePreservesSeedTimeAndWeather(GameTestHelper helper) { + ServerPlayer player = createMockServerPlayer(helper); + MinecraftServer server = helper.getLevel().getServer(); + final int assertionDelayTicks = 30; + final int maxTickDrift = assertionDelayTicks + 5; + + ensureAdvancementsDir(server); + + Path fixturePath = resolve26FixturePath(server); + if (!Files.exists(fixturePath.resolve("level.dat"))) { + helper.fail("26.1 fixture missing level.dat at " + fixturePath); + return; + } + + FixtureExpectedState expected; + try { + expected = loadFixtureExpectedState(server, fixturePath); + } catch (Exception ex) { + helper.fail("Failed to load expected fixture state: " + ex.getMessage()); + return; + } + + runServerCommand(helper, player, "mw import \"" + fixturePath + "\" " + IMPORT_26_1_WORLD); + + helper.runAfterDelay(assertionDelayTicks, () -> { + ServerLevel imported = getDimension(server, IMPORT_26_1_WORLD); + if (imported == null) { + helper.fail("26.1 fixture import did not create world: " + IMPORT_26_1_WORLD); + return; + } + + BlockPos importedSpawn = imported.getRespawnData().pos(); + if (expected.spawnPos().isPresent()) { + BlockPos explicitExpectedSpawn = expected.spawnPos().get(); + if (!importedSpawn.equals(explicitExpectedSpawn)) { + helper.fail("Imported world spawn mismatch: expected " + explicitExpectedSpawn + " but got " + + importedSpawn); + return; + } + } else { + helper.fail("Fixture expected state did not provide an explicit spawn position"); + return; + } + + if (imported.getSeed() != expected.seed()) { + helper.fail( + "Imported world seed mismatch: expected " + expected.seed() + " but got " + imported.getSeed()); + return; + } + + long importedGameTime = imported.getGameTime(); + if (importedGameTime < expected.gameTime() || importedGameTime > expected.gameTime() + maxTickDrift) { + helper.fail("Imported world gameTime mismatch: expected around " + expected.gameTime() + + " (+0.." + maxTickDrift + " ticks), but got " + importedGameTime); + return; + } + + if (expected.overworldClockTicks().isPresent()) { + Holder overworldClock = imported.registryAccess() + .lookupOrThrow(Registries.WORLD_CLOCK) + .getOrThrow(ResourceKey.create(Registries.WORLD_CLOCK, + Identifier.fromNamespaceAndPath("minecraft", "overworld"))); + long importedOverworldClockTicks = MultiWorld.getClockManagerForLevel(imported) + .getTotalTicks(overworldClock); + long expectedOverworldClockTicks = expected.overworldClockTicks().getAsLong(); + if (importedOverworldClockTicks < expectedOverworldClockTicks + || importedOverworldClockTicks > expectedOverworldClockTicks + maxTickDrift) { + helper.fail("Imported overworld clock mismatch: expected around " + expectedOverworldClockTicks + + " (+0.." + maxTickDrift + " ticks), but got " + importedOverworldClockTicks); + return; + } + } + + WeatherData weather = MultiWorld.getCustomWorldWeatherData(imported); + if (weather == null) { + helper.fail("Imported world weather state not available for custom world"); + return; + } + + int clearWeatherDiff = Math.abs(weather.getClearWeatherTime() - expected.clearWeatherTime()); + if (clearWeatherDiff > maxTickDrift) { + helper.fail("clearWeatherTime mismatch: expected around " + expected.clearWeatherTime() + + " (+/-" + maxTickDrift + "), but got " + weather.getClearWeatherTime()); + return; + } + int rainDiff = Math.abs(weather.getRainTime() - expected.rainTime()); + if (rainDiff > maxTickDrift) { + helper.fail("rainTime mismatch: expected around " + expected.rainTime() + + " (+/-" + maxTickDrift + "), but got " + weather.getRainTime()); + return; + } + int thunderDiff = Math.abs(weather.getThunderTime() - expected.thunderTime()); + if (thunderDiff > maxTickDrift) { + helper.fail("thunderTime mismatch: expected around " + expected.thunderTime() + + " (+/-" + maxTickDrift + "), but got " + weather.getThunderTime()); + return; + } + if (weather.isRaining() != expected.raining()) { + helper.fail("raining mismatch: expected " + expected.raining() + " but got " + weather.isRaining()); + return; + } + if (weather.isThundering() != expected.thundering()) { + helper.fail("thundering mismatch: expected " + expected.thundering() + " but got " + + weather.isThundering()); + return; + } + + helper.succeed(); + }); + } + + @GameTest(maxTicks = 1400) + public void import26FixtureKeepsWeatherPacketsScopedPerWorld(GameTestHelper helper) { + ServerPlayer player = createMockServerPlayer(helper); + MinecraftServer server = helper.getLevel().getServer(); + + ensureAdvancementsDir(server); + + Path fixturePath = resolve26FixturePath(server); + if (!Files.exists(fixturePath.resolve("level.dat"))) { + helper.fail("26.1 fixture missing level.dat at " + fixturePath); + return; + } + + FixtureExpectedState expected; + try { + expected = loadFixtureExpectedState(server, fixturePath); + } catch (Exception ex) { + helper.fail("Failed to load expected fixture weather state: " + ex.getMessage()); + return; + } + + var overworldWeather = server.overworld().getWeatherData(); + overworldWeather.setClearWeatherTime(6000); + overworldWeather.setRaining(false); + overworldWeather.setThundering(false); + overworldWeather.setRainTime(0); + overworldWeather.setThunderTime(0); + + runServerCommand(helper, player, "mw import \"" + fixturePath + "\" " + IMPORT_26_1_WORLD); + + helper.runAfterDelay(40, () -> { + ServerLevel imported = getDimension(server, IMPORT_26_1_WORLD); + if (imported == null) { + helper.fail("26.1 fixture import did not create world: " + IMPORT_26_1_WORLD); + return; + } + + var currentOverworldWeather = server.overworld().getWeatherData(); + if (currentOverworldWeather.isRaining() || currentOverworldWeather.isThundering()) { + helper.fail("Overworld weather state changed after import (expected clear)"); + return; + } + + ClientboundGameEventPacket overworldTogglePacket = MultiWorld.createWeatherSyncPacketForLevel( + server.overworld(), ClientboundGameEventPacket.START_RAINING); + if (overworldTogglePacket.getEvent() != ClientboundGameEventPacket.STOP_RAINING) { + helper.fail("Overworld weather sync packet should be STOP_RAINING when overworld is clear"); + return; + } + + ClientboundGameEventPacket importedTogglePacket = MultiWorld.createWeatherSyncPacketForLevel( + imported, ClientboundGameEventPacket.START_RAINING); + ClientboundGameEventPacket.Type expectedImportedToggle = expected.raining() + ? ClientboundGameEventPacket.START_RAINING + : ClientboundGameEventPacket.STOP_RAINING; + if (importedTogglePacket.getEvent() != expectedImportedToggle) { + helper.fail("Imported weather sync packet mismatch: expected " + expectedImportedToggle + + " but got " + importedTogglePacket.getEvent()); + return; + } + + ClientboundGameEventPacket importedRainLevelPacket = MultiWorld.createWeatherSyncPacketForLevel( + imported, ClientboundGameEventPacket.RAIN_LEVEL_CHANGE); + float expectedImportedRain = imported.getRainLevel(1.0F); + if (Math.abs(importedRainLevelPacket.getParam() - expectedImportedRain) > 0.0001F) { + helper.fail("Imported rain-level packet param mismatch: expected " + expectedImportedRain + + " but got " + importedRainLevelPacket.getParam()); + return; + } + + ClientboundGameEventPacket importedThunderLevelPacket = MultiWorld.createWeatherSyncPacketForLevel( + imported, ClientboundGameEventPacket.THUNDER_LEVEL_CHANGE); + float expectedImportedThunder = imported.getThunderLevel(1.0F); + if (Math.abs(importedThunderLevelPacket.getParam() - expectedImportedThunder) > 0.0001F) { + helper.fail("Imported thunder-level packet param mismatch: expected " + expectedImportedThunder + + " but got " + importedThunderLevelPacket.getParam()); + return; + } + + helper.succeed(); + }); + } + + @GameTest(maxTicks = 1400) + public void import26FixtureUsesSavedDataSeedWhenLevelDatSeedIsWrong(GameTestHelper helper) { + ServerPlayer player = createMockServerPlayer(helper); + MinecraftServer server = helper.getLevel().getServer(); + + ensureAdvancementsDir(server); + + Path fixturePath = resolve26FixturePath(server); + Path fixtureWorldGenDat = fixturePath.resolve("data").resolve("minecraft").resolve("world_gen_settings.dat"); + if (!Files.exists(fixturePath.resolve("level.dat"))) { + helper.fail("26.1 fixture missing level.dat at " + fixturePath); + return; + } + if (!Files.exists(fixtureWorldGenDat)) { + helper.fail("26.1 fixture missing world_gen_settings.dat at " + fixtureWorldGenDat); + return; + } + + OptionalLong expectedSavedDataSeed = readSeedFromWorldGenSettingsDat(fixturePath); + if (expectedSavedDataSeed.isEmpty()) { + helper.fail("Could not resolve expected seed from world_gen_settings.dat in fixture " + fixturePath); + return; + } + + long expectedSeed = expectedSavedDataSeed.getAsLong(); + long wrongLevelDatSeed = expectedSeed == 1L ? 2L : 1L; + + Path tempSource; + try { + Path worldRoot = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); + tempSource = worldRoot.resolve("gametest_tmp_import_26_seed_prefer_saved_data"); + deleteRecursivelyIfExists(tempSource); + copyDirectoryRecursively(fixturePath, tempSource); + overwriteAllSeedLikeLongsInLevelDat(tempSource.resolve("level.dat"), wrongLevelDatSeed); + } catch (Exception ex) { + helper.fail("Failed to prepare mutated import fixture: " + ex.getMessage()); + return; + } + + runServerCommand(helper, player, "mw import \"" + tempSource + "\" " + IMPORT_26_1_SAVED_DATA_SEED_WORLD); + + helper.runAfterDelay(30, () -> { + ServerLevel imported = getDimension(server, IMPORT_26_1_SAVED_DATA_SEED_WORLD); + if (imported == null) { + helper.fail("Mutated 26.1 import did not create world: " + IMPORT_26_1_SAVED_DATA_SEED_WORLD); + return; + } + + if (imported.getSeed() != expectedSeed) { + helper.fail("Imported seed should come from world_gen_settings.dat: expected " + + expectedSeed + " but got " + imported.getSeed()); + return; + } + + if (imported.getSeed() == wrongLevelDatSeed) { + helper.fail("Imported seed incorrectly came from tampered level.dat seed " + wrongLevelDatSeed); + return; + } + + helper.succeed(); + }); + } + + @GameTest(maxTicks = 1600) + public void import26FixtureLoadsMatchingPlayerDataOnTeleport(GameTestHelper helper) { + ServerPlayer player = createMockServerPlayer(helper); + MinecraftServer server = helper.getLevel().getServer(); + + ensureAdvancementsDir(server); + + Path fixturePath = resolve26FixturePath(server); + if (!Files.exists(fixturePath.resolve("level.dat"))) { + helper.fail("26.1 fixture missing level.dat at " + fixturePath); + return; + } + + Path tempSource; + String playerUuid = player.getStringUUID(); + try { + Path worldRoot = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); + tempSource = worldRoot.resolve("gametest_tmp_import_26_player_profile"); + deleteRecursivelyIfExists(tempSource); + copyDirectoryRecursively(fixturePath, tempSource); + prepareFixturePlayerProfileForUuid(tempSource, LEGACY_PLAYER_UUID, playerUuid); + } catch (Exception ex) { + helper.fail("Failed to prepare import fixture with matching player UUID: " + ex.getMessage()); + return; + } + + runServerCommand(helper, player, "mw import \"" + tempSource + "\" " + IMPORT_26_1_PLAYERDATA_WORLD); + + helper.runAfterDelay(30, () -> { + Path importedPlayerJson = server.getWorldPath(LevelResource.ROOT) + .resolve(NAMESPACE) + .resolve(IMPORT_26_1_PLAYERDATA_WORLD) + .resolve(playerUuid + ".json"); + + if (!Files.exists(importedPlayerJson)) { + helper.fail("Imported player profile missing for UUID " + playerUuid + " at " + importedPlayerJson); + return; + } + + teleportToWorld(helper, player, IMPORT_26_1_PLAYERDATA_WORLD); + + helper.runAfterDelay(10, () -> { + assertInDimension(helper, player, NAMESPACE, IMPORT_26_1_PLAYERDATA_WORLD); + assertHasItem(helper, player, Items.OAK_LOG, "after importing matching player data"); + + AdvancementHolder recipeAdvancement = server.getAdvancements() + .get(Identifier.parse("minecraft:recipes/decorations/crafting_table")); + if (recipeAdvancement == null) { + helper.fail("Advancement 'minecraft:recipes/decorations/crafting_table' not found"); + return; + } + + AdvancementProgress progress = player.getAdvancements().getOrStartProgress(recipeAdvancement); + if (!progress.isDone()) { + helper.fail("Expected imported advancement to be completed after teleport"); + return; + } + + helper.succeed(); + }); + }); + } + + @GameTest(maxTicks = 1400) + public void import26FixtureRestoresEntitiesGameRulesAndScoreboard(GameTestHelper helper) { + ServerPlayer player = createMockServerPlayer(helper); + MinecraftServer server = helper.getLevel().getServer(); + + ensureAdvancementsDir(server); + + Path fixturePath = resolve26FixturePath(server); + if (!Files.exists(fixturePath.resolve("level.dat"))) { + helper.fail("26.1 fixture missing level.dat at " + fixturePath); + return; + } + + // Ensure import is the source of this objective. + var existingTestObjective = server.getScoreboard().getObjective("test"); + if (existingTestObjective != null) { + server.getScoreboard().removeObjective(existingTestObjective); + } + + runServerCommand(helper, player, "mw import \"" + fixturePath + "\" " + IMPORT_26_1_WORLD); + + helper.runAfterDelay(40, () -> { + ServerLevel imported = getDimension(server, IMPORT_26_1_WORLD); + if (imported == null) { + helper.fail("26.1 fixture import did not create world: " + IMPORT_26_1_WORLD); + return; + } + + if (!imported.getGameRules().get(GameRules.KEEP_INVENTORY)) { + helper.fail("Imported keepInventory gamerule should be true but was false"); + return; + } + + Path importedScoreboard = server.getWorldPath(LevelResource.ROOT) + .resolve("dimensions") + .resolve(NAMESPACE) + .resolve(IMPORT_26_1_WORLD) + .resolve("data") + .resolve("minecraft") + .resolve("scoreboard.dat"); + + if (!hasObjectiveInScoreboardDat(importedScoreboard, "test")) { + helper.fail("Imported scoreboard.dat should contain objective 'test'"); + return; + } + + Path sourceOverworldEntities = fixturePath + .resolve("dimensions") + .resolve("minecraft") + .resolve("overworld") + .resolve("entities"); + + long sourceEntityRegionCount; + try (var stream = Files.list(sourceOverworldEntities)) { + sourceEntityRegionCount = stream + .filter(Files::isRegularFile) + .map(path -> path.getFileName().toString()) + .filter(name -> name.endsWith(".mca")) + .count(); + } catch (Exception ex) { + helper.fail("Failed to inspect source entities folder: " + ex.getMessage()); + return; + } + + if (sourceEntityRegionCount <= 0) { + helper.fail("Fixture should contain at least one overworld entities .mca file"); + return; + } + + Path importedEntities = server.getWorldPath(LevelResource.ROOT) + .resolve("dimensions") + .resolve(NAMESPACE) + .resolve(IMPORT_26_1_WORLD) + .resolve("entities"); + + long importedEntityRegionCount; + try (var stream = Files.list(importedEntities)) { + importedEntityRegionCount = stream + .filter(Files::isRegularFile) + .map(path -> path.getFileName().toString()) + .filter(name -> name.endsWith(".mca")) + .count(); + } catch (Exception ex) { + helper.fail("Failed to inspect imported entities folder: " + ex.getMessage()); + return; + } + + if (importedEntityRegionCount <= 0) { + helper.fail("No entity region files were imported into the target world"); + return; + } + + helper.succeed(); + }); + } + + @GameTest(maxTicks = 1600) + public void import26FixtureDoesNotLeakScoreboardObjectivesToOtherWorlds(GameTestHelper helper) { + ServerPlayer player = createMockServerPlayer(helper); + MinecraftServer server = helper.getLevel().getServer(); + + ensureAdvancementsDir(server); + + Path fixturePath = resolve26FixturePath(server); + if (!Files.exists(fixturePath.resolve("level.dat"))) { + helper.fail("26.1 fixture missing level.dat at " + fixturePath); + return; + } + + var existingTestObjective = server.getScoreboard().getObjective("test"); + if (existingTestObjective != null) { + server.getScoreboard().removeObjective(existingTestObjective); + } + + runServerCommand(helper, player, "mw import \"" + fixturePath + "\" " + IMPORT_26_1_WORLD); + + helper.runAfterDelay(40, () -> { + ServerLevel imported = getDimension(server, IMPORT_26_1_WORLD); + if (imported == null) { + helper.fail("26.1 fixture import did not create world: " + IMPORT_26_1_WORLD); + return; + } + + Path importedScoreboard = server.getWorldPath(LevelResource.ROOT) + .resolve("dimensions") + .resolve(NAMESPACE) + .resolve(IMPORT_26_1_WORLD) + .resolve("data") + .resolve("minecraft") + .resolve("scoreboard.dat"); + if (!hasObjectiveInScoreboardDat(importedScoreboard, "test")) { + helper.fail("Imported scoreboard.dat should contain objective 'test'"); + return; + } + + if (server.getScoreboard().getObjective("test") != null) { + helper.fail("Imported objective 'test' leaked into global scoreboard state"); + return; + } + + createWorld(helper, player, SCOREBOARD_ISOLATION_WORLD); + teleportToWorld(helper, player, SCOREBOARD_ISOLATION_WORLD); + + helper.runAfterDelay(10, () -> { + assertInDimension(helper, player, NAMESPACE, SCOREBOARD_ISOLATION_WORLD); + if (server.getScoreboard().getObjective("test") != null) { + helper.fail("Objective 'test' should not appear after moving to another multiworld"); + return; + } + + teleportToWorld(helper, player, "overworld"); + helper.runAfterDelay(10, () -> { + assertInDimension(helper, player, "minecraft", "overworld"); + if (server.getScoreboard().getObjective("test") != null) { + helper.fail("Objective 'test' should not appear in vanilla overworld scoreboard"); + return; + } + helper.succeed(); + }); + }); + }); + } + + @GameTest(maxTicks = 1200) + public void debugCommandIncludesRuntimeAndSavedDataInfo(GameTestHelper helper) { + ServerPlayer player = createMockServerPlayer(helper); + MinecraftServer server = helper.getLevel().getServer(); + + ensureAdvancementsDir(server); + + Path fixturePath = resolve26FixturePath(server); + if (!Files.exists(fixturePath.resolve("level.dat"))) { + helper.fail("26.1 fixture missing level.dat at " + fixturePath); + return; + } + + runServerCommand(helper, player, "mw import \"" + fixturePath + "\" " + IMPORT_26_1_WORLD); + + helper.runAfterDelay(30, () -> { + ServerLevel imported = getDimension(server, IMPORT_26_1_WORLD); + if (imported == null) { + helper.fail("Imported world missing: " + IMPORT_26_1_WORLD); + return; + } + + server.getCommands().performPrefixedCommand( + player.createCommandSourceStack(), + "mw debug " + IMPORT_26_1_WORLD); + + java.util.List debugLines = MultiWorldCommand.collectWorldDebugInfo(server, imported); + + assertLineContains(helper, debugLines, "spawn:"); + assertLineContains(helper, debugLines, "time: game="); + assertLineContains(helper, debugLines, "weather:"); + assertLineContains(helper, debugLines, "gamerules: total="); + assertLineContains(helper, debugLines, "saved-data files("); + assertLineContains(helper, debugLines, "world_gen_settings.dat"); + assertLineContains(helper, debugLines, "weather.dat"); + assertLineContains(helper, debugLines, "world_clocks.dat"); + + helper.succeed(); + }); + } + + @GameTest(maxTicks = 1600) + public void importedLegacyWorldRespawnStaysInExpectedSpawnZone(GameTestHelper helper) { + ServerPlayer player = createMockServerPlayer(helper); + MinecraftServer server = helper.getLevel().getServer(); + + ensureAdvancementsDir(server); + + Path fixturePath = resolveLegacyFixturePath(server); + if (!Files.exists(fixturePath.resolve("level.dat"))) { + helper.fail("Legacy fixture missing level.dat at " + fixturePath); + return; + } + + runServerCommand(helper, player, "mw import \"" + fixturePath + "\" " + IMPORT_LEGACY_WORLD); + + helper.runAfterDelay(30, () -> { + ServerLevel imported = getDimension(server, IMPORT_LEGACY_WORLD); + if (imported == null) { + helper.fail("Legacy fixture import did not create world: " + IMPORT_LEGACY_WORLD); + return; + } + + teleportToWorld(helper, player, IMPORT_LEGACY_WORLD); + + helper.runAfterDelay(10, () -> { + assertInDimension(helper, player, NAMESPACE, IMPORT_LEGACY_WORLD); + + ServerPlayer respawned = killAndRespawn(player, server); + assertInDimension(helper, respawned, NAMESPACE, IMPORT_LEGACY_WORLD); + + BlockPos importedSpawn = imported.getRespawnData().pos(); + assertNearPosition(helper, respawned, importedSpawn.getX(), importedSpawn.getY(), importedSpawn.getZ(), + 40.0, "legacy import respawn zone"); + + if (Math.abs(importedSpawn.getX()) <= 8 && Math.abs(importedSpawn.getZ()) <= 8) { + helper.fail("Imported legacy world spawn should not be near 0,0 but was " + importedSpawn); + return; + } + helper.succeed(); + }); + }); + } + + @GameTest(maxTicks = 1800) + public void overworldRespawnUsesZoneNotFixedBlock(GameTestHelper helper) { + ServerPlayer player = createMockServerPlayer(helper); + MinecraftServer server = helper.getLevel().getServer(); + + ensureAdvancementsDir(server); + runServerCommand(helper, player, "gamerule keepInventory false"); + + helper.runAfterDelay(10, () -> { + Set uniqueRespawnBlocks = new HashSet<>(); + ServerPlayer current = player; + + for (int i = 0; i < 6; i++) { + current = killAndRespawn(current, server); + assertInDimension(helper, current, "minecraft", "overworld"); + uniqueRespawnBlocks.add(blockKey(current.position())); + } + + if (uniqueRespawnBlocks.size() < 2) { + helper.fail( + "Overworld respawn should vary within spawn zone, but got fixed block: " + uniqueRespawnBlocks); + return; + } + + helper.succeed(); + }); + } + + private static void assertNoDimension(GameTestHelper helper, MinecraftServer server, String worldName) { + if (getDimension(server, worldName) != null) { + helper.fail("Expected dimension to be deleted: " + worldName); + } + } + + private static ServerLevel getDimension(MinecraftServer server, String worldName) { + ResourceKey worldKey = ResourceKey.create( + Registries.DIMENSION, + Identifier.fromNamespaceAndPath(NAMESPACE, worldName)); + return server.getLevel(worldKey); + } + + private static Path resolveCurrentWorldImportSource(MinecraftServer server) { + Path root = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); + if (Files.exists(root.resolve("level.dat"))) { + return root; + } + + Path parent = root.getParent(); + if (parent != null && Files.exists(parent.resolve("level.dat"))) { + return parent; + } + + Path nestedWorld = root.resolve("world"); + if (Files.exists(nestedWorld.resolve("level.dat"))) { + return nestedWorld; + } + + return resolveLegacyFixturePath(server); + } + + private static Path resolveLegacyFixturePath(MinecraftServer server) { + try { + var resource = WorldManagementCommandTest.class.getClassLoader() + .getResource("fixtures/import_legacy_1_17_1/level.dat"); + if (resource != null) { + Path fromResources = Path.of(resource.toURI()).getParent(); + if (Files.exists(fromResources.resolve("level.dat"))) { + return fromResources; + } + } + } catch (Exception ignored) { + // Fall back to filesystem probing below. + } + + Path cwd = Path.of("").toAbsolutePath().normalize(); + Path current = cwd; + for (int i = 0; i < 8 && current != null; i++) { + Path candidate = current.resolve("src").resolve("gametest").resolve("resources") + .resolve("fixtures").resolve("import_legacy_1_17_1"); + if (Files.exists(candidate.resolve("level.dat"))) { + return candidate; + } + current = current.getParent(); + } + + return cwd.resolve("src").resolve("gametest").resolve("resources") + .resolve("fixtures").resolve("import_legacy_1_17_1"); + } + + private static Path resolve26FixturePath(MinecraftServer server) { + try { + var resource = WorldManagementCommandTest.class.getClassLoader() + .getResource("fixtures/import_26_1/level.dat"); + if (resource != null) { + Path fromResources = Path.of(resource.toURI()).getParent(); + if (Files.exists(fromResources.resolve("level.dat"))) { + return fromResources; + } + } + } catch (Exception ignored) { + // Fall back to filesystem probing below. + } + + Path cwd = Path.of("").toAbsolutePath().normalize(); + Path current = cwd; + for (int i = 0; i < 8 && current != null; i++) { + Path candidate = current.resolve("src").resolve("gametest").resolve("resources") + .resolve("fixtures").resolve("import_26_1"); + if (Files.exists(candidate.resolve("level.dat"))) { + return candidate; + } + current = current.getParent(); + } + + return cwd.resolve("src").resolve("gametest").resolve("resources") + .resolve("fixtures").resolve("import_26_1"); + } + + private static FixtureExpectedState loadFixtureExpectedState(MinecraftServer server, Path fixturePath) + throws Exception { + CompoundTag levelData = NbtIo.readCompressed(fixturePath.resolve("level.dat"), NbtAccounter.unlimitedHeap()); + CompoundTag dataTag = levelData.getCompound("Data").orElse(levelData); + + long gameTime = dataTag.getLong("Time") + .or(() -> dataTag.getLong("DayTime")) + .orElse(0L); + + Optional spawnPos = readExplicitSpawnPos(dataTag); + + OptionalLong overworldClockTicks = readOverworldClockTicksFromDat(fixturePath); + + WeatherData expectedWeather = readWeatherFromSavedData(server, fixturePath).orElse( + new WeatherData( + dataTag.getInt("clearWeatherTime").orElse(0), + dataTag.getInt("rainTime").orElse(0), + dataTag.getInt("thunderTime").orElse(0), + dataTag.getBooleanOr("raining", false), + dataTag.getBooleanOr("thundering", false))); + + long seed = extractFixtureSeed(server, fixturePath, dataTag); + + return new FixtureExpectedState( + seed, + gameTime, + expectedWeather.getClearWeatherTime(), + expectedWeather.getRainTime(), + expectedWeather.getThunderTime(), + expectedWeather.isRaining(), + expectedWeather.isThundering(), + spawnPos, + overworldClockTicks); + } + + private static long extractFixtureSeed(MinecraftServer server, Path fixturePath, CompoundTag dataTag) { + OptionalLong seedFromDatFile = readSeedFromWorldGenSettingsDat(fixturePath); + if (seedFromDatFile.isPresent()) { + return seedFromDatFile.getAsLong(); + } + + OptionalLong seedFromSavedData = readSeedFromSavedData(server, fixturePath); + if (seedFromSavedData.isPresent()) { + return seedFromSavedData.getAsLong(); + } + + try { + RegistryOps ops = RegistryOps.create(net.minecraft.nbt.NbtOps.INSTANCE, + server.registryAccess()); + Optional fromCodec = dataTag.read("WorldGenSettings", WorldGenSettings.CODEC, ops); + if (fromCodec.isPresent()) { + return fromCodec.get().options().seed(); + } + } catch (Exception ignored) { + } + + return dataTag.getCompound("WorldGenSettings") + .flatMap(tag -> tag.getLong("seed")) + .or(() -> dataTag.getLong("RandomSeed")) + .or(() -> dataTag.getLong("Seed")) + .orElse(server.overworld().getSeed()); + } + + private static Optional readExplicitSpawnPos(CompoundTag dataTag) { + Optional spawnCompoundOpt = dataTag.getCompound("spawn"); + if (spawnCompoundOpt.isPresent()) { + CompoundTag spawnCompound = spawnCompoundOpt.get(); + + Optional spawnPosArray = spawnCompound.getIntArray("pos"); + if (spawnPosArray.isPresent() && spawnPosArray.get().length >= 3) { + int[] pos = spawnPosArray.get(); + return Optional.of(new BlockPos(pos[0], pos[1], pos[2])); + } + + Optional x = spawnCompound.getInt("x"); + Optional y = spawnCompound.getInt("y"); + Optional z = spawnCompound.getInt("z"); + if (x.isPresent() && y.isPresent() && z.isPresent()) { + return Optional.of(new BlockPos(x.get(), y.get(), z.get())); + } + } + + if (!dataTag.contains("SpawnX") || !dataTag.contains("SpawnY") || !dataTag.contains("SpawnZ")) { + return Optional.empty(); + } + + return Optional.of(new BlockPos( + dataTag.getInt("SpawnX").orElse(0), + dataTag.getInt("SpawnY").orElse(63), + dataTag.getInt("SpawnZ").orElse(0))); + } + + private static OptionalLong readSeedFromSavedData(MinecraftServer server, Path fixturePath) { + Path parent = fixturePath.getParent(); + if (parent == null) { + return OptionalLong.empty(); + } + + try { + LevelStorageSource storageSource = LevelStorageSource.createDefault(parent); + try (LevelStorageSource.LevelStorageAccess storageAccess = storageSource + .createAccess(fixturePath.getFileName().toString())) { + var result = LevelStorageSource.readExistingSavedData( + storageAccess, + server.registryAccess(), + WorldGenSettings.TYPE); + Optional worldGenSettings = result.result(); + if (worldGenSettings.isPresent()) { + return OptionalLong.of(worldGenSettings.get().options().seed()); + } + } + } catch (Exception ignored) { + } + + return OptionalLong.empty(); + } + + private static OptionalLong readSeedFromWorldGenSettingsDat(Path fixturePath) { + Path worldGenDat = fixturePath.resolve("data").resolve("minecraft").resolve("world_gen_settings.dat"); + if (!Files.exists(worldGenDat)) { + return OptionalLong.empty(); + } + + try { + CompoundTag root = NbtIo.readCompressed(worldGenDat, NbtAccounter.unlimitedHeap()); + CompoundTag dataTag = root.getCompound("data").orElse(root); + + Optional seed = dataTag.getLong("seed") + .or(() -> dataTag.getLong("RandomSeed")) + .or(() -> dataTag.getLong("Seed")); + if (seed.isPresent()) { + return OptionalLong.of(seed.get()); + } + + Optional recursive = findSeedRecursively(dataTag, 0); + return recursive.isPresent() ? OptionalLong.of(recursive.get()) : OptionalLong.empty(); + } catch (Exception ignored) { + return OptionalLong.empty(); + } + } + + private static Optional readWeatherFromSavedData(MinecraftServer server, Path fixturePath) { + Optional direct = readWeatherFromDatFile(fixturePath); + if (direct.isPresent()) { + return direct; + } + + Path parent = fixturePath.getParent(); + if (parent == null) { + return Optional.empty(); + } + + try { + LevelStorageSource storageSource = LevelStorageSource.createDefault(parent); + try (LevelStorageSource.LevelStorageAccess storageAccess = storageSource + .createAccess(fixturePath.getFileName().toString())) { + var result = LevelStorageSource.readExistingSavedData( + storageAccess, + server.registryAccess(), + WeatherData.TYPE); + return result.result(); + } + } catch (Exception ignored) { + } + + return Optional.empty(); + } + + private static Optional readWeatherFromDatFile(Path fixturePath) { + Path weatherDat = fixturePath.resolve("data").resolve("minecraft").resolve("weather.dat"); + if (!Files.exists(weatherDat)) { + return Optional.empty(); + } + + try { + CompoundTag root = NbtIo.readCompressed(weatherDat, NbtAccounter.unlimitedHeap()); + CompoundTag dataTag = root.getCompound("data").orElse(root); + + return Optional.of(new WeatherData( + dataTag.getInt("clear_weather_time").or(() -> dataTag.getInt("clearWeatherTime")).orElse(0), + dataTag.getInt("rain_time").or(() -> dataTag.getInt("rainTime")).orElse(0), + dataTag.getInt("thunder_time").or(() -> dataTag.getInt("thunderTime")).orElse(0), + dataTag.getBooleanOr("raining", false), + dataTag.getBooleanOr("thundering", false))); + } catch (Exception ignored) { + return Optional.empty(); + } + } + + private static OptionalLong readOverworldClockTicksFromDat(Path fixturePath) { + Path clocksDat = fixturePath.resolve("data").resolve("minecraft").resolve("world_clocks.dat"); + if (!Files.exists(clocksDat)) { + return OptionalLong.empty(); + } + + try { + CompoundTag root = NbtIo.readCompressed(clocksDat, NbtAccounter.unlimitedHeap()); + CompoundTag dataTag = root.getCompound("data").orElse(root); + + OptionalLong direct = dataTag.getCompound("minecraft:overworld") + .flatMap(clockTag -> clockTag.getLong("total_ticks")) + .map(OptionalLong::of) + .orElse(OptionalLong.empty()); + if (direct.isPresent()) { + return direct; + } + + return findClockTicksRecursively(dataTag, "minecraft:overworld", 0); + } catch (Exception ignored) { + return OptionalLong.empty(); + } + } + + private static OptionalLong findClockTicksRecursively(CompoundTag tag, String keyName, int depth) { + if (depth > 16) { + return OptionalLong.empty(); + } + + for (var entry : tag.entrySet()) { + String key = entry.getKey(); + var value = entry.getValue(); + if (!(value instanceof CompoundTag nested)) { + continue; + } + + if (keyName.equals(key)) { + Optional ticks = nested.getLong("total_ticks"); + if (ticks.isPresent()) { + return OptionalLong.of(ticks.get()); + } + } + + OptionalLong deeper = findClockTicksRecursively(nested, keyName, depth + 1); + if (deeper.isPresent()) { + return deeper; + } + } + + return OptionalLong.empty(); + } + + private static void overwriteAllSeedLikeLongsInLevelDat(Path levelDatPath, long seedValue) throws Exception { + CompoundTag root = NbtIo.readCompressed(levelDatPath, NbtAccounter.unlimitedHeap()); + overwriteAllSeedLikeLongs(root, seedValue, 0); + NbtIo.writeCompressed(root, levelDatPath); + } + + private static void overwriteAllSeedLikeLongs(CompoundTag tag, long seedValue, int depth) { + if (depth > 24) { + return; + } + + for (var entry : tag.entrySet()) { + String key = entry.getKey(); + var value = entry.getValue(); + + if (value instanceof net.minecraft.nbt.LongTag + && ("seed".equalsIgnoreCase(key) || "randomseed".equalsIgnoreCase(key))) { + tag.putLong(key, seedValue); + continue; + } + + if (value instanceof CompoundTag nested) { + overwriteAllSeedLikeLongs(nested, seedValue, depth + 1); + tag.put(key, nested); + } + } + } + + private static Optional findSeedRecursively(CompoundTag tag, int depth) { + if (depth > 16) { + return Optional.empty(); + } + + for (var entry : tag.entrySet()) { + String key = entry.getKey(); + var value = entry.getValue(); + + if (value instanceof net.minecraft.nbt.LongTag longTag + && ("seed".equalsIgnoreCase(key) || "randomseed".equalsIgnoreCase(key))) { + return Optional.of(longTag.value()); + } + + if (value instanceof CompoundTag nested) { + Optional nestedSeed = findSeedRecursively(nested, depth + 1); + if (nestedSeed.isPresent()) { + return nestedSeed; + } + } + } + + return Optional.empty(); + } + + private static void prepareFixturePlayerProfileForUuid(Path fixtureRoot, String sourceUuid, String targetUuid) + throws Exception { + copyIfExists( + fixtureRoot.resolve("playerdata").resolve(sourceUuid + ".dat"), + fixtureRoot.resolve("playerdata").resolve(targetUuid + ".dat")); + copyIfExists( + fixtureRoot.resolve("stats").resolve(sourceUuid + ".json"), + fixtureRoot.resolve("stats").resolve(targetUuid + ".json")); + copyIfExists( + fixtureRoot.resolve("advancements").resolve(sourceUuid + ".json"), + fixtureRoot.resolve("advancements").resolve(targetUuid + ".json")); + + copyIfExists( + fixtureRoot.resolve("players").resolve("data").resolve(sourceUuid + ".dat"), + fixtureRoot.resolve("players").resolve("data").resolve(targetUuid + ".dat")); + copyIfExists( + fixtureRoot.resolve("players").resolve("stats").resolve(sourceUuid + ".json"), + fixtureRoot.resolve("players").resolve("stats").resolve(targetUuid + ".json")); + copyIfExists( + fixtureRoot.resolve("players").resolve("advancements").resolve(sourceUuid + ".json"), + fixtureRoot.resolve("players").resolve("advancements").resolve(targetUuid + ".json")); + } + + private static void copyIfExists(Path source, Path destination) throws Exception { + if (!Files.exists(source)) { + return; + } + + Files.createDirectories(destination.getParent()); + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES); + } + + private static void copyDirectoryRecursively(Path source, Path target) throws Exception { + try (var stream = Files.walk(source)) { + stream.forEach(path -> { + try { + Path relative = source.relativize(path); + Path destination = target.resolve(relative); + if (Files.isDirectory(path)) { + Files.createDirectories(destination); + } else { + Files.createDirectories(destination.getParent()); + Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.COPY_ATTRIBUTES); + } + } catch (Exception ex) { + throw new RuntimeException(ex); + } + }); + } catch (RuntimeException wrapped) { + if (wrapped.getCause() instanceof Exception ex) { + throw ex; + } + throw wrapped; + } + } + + private static void deleteRecursivelyIfExists(Path path) throws Exception { + if (!Files.exists(path)) { + return; + } + + try (var stream = Files.walk(path)) { + stream.sorted(Comparator.reverseOrder()).forEach(current -> { + try { + Files.deleteIfExists(current); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + }); + } catch (RuntimeException wrapped) { + if (wrapped.getCause() instanceof Exception ex) { + throw ex; + } + throw wrapped; + } + } + + private record FixtureExpectedState( + long seed, + long gameTime, + int clearWeatherTime, + int rainTime, + int thunderTime, + boolean raining, + boolean thundering, + Optional spawnPos, + OptionalLong overworldClockTicks) { + } + + private static ServerPlayer killAndRespawn(ServerPlayer player, MinecraftServer server) { + player.kill((ServerLevel) player.level()); + ServerPlayer respawned = server.getPlayerList().respawn(player, false, Entity.RemovalReason.KILLED); + ServerPlayer listed = server.getPlayerList().getPlayer(respawned.getUUID()); + return listed != null ? listed : respawned; + } + + private static String blockKey(Vec3 pos) { + return ((int) Math.floor(pos.x)) + "," + ((int) Math.floor(pos.y)) + "," + ((int) Math.floor(pos.z)); + } + + private static void assertLineContains(GameTestHelper helper, java.util.List lines, String needle) { + boolean found = lines.stream().anyMatch(line -> line.contains(needle)); + if (!found) { + helper.fail("Expected debug output to contain '" + needle + "' but got: " + lines); + } + } + + private static boolean hasObjectiveInScoreboardDat(Path scoreboardDat, String objectiveName) { + if (!Files.exists(scoreboardDat)) { + return false; + } + + try { + CompoundTag root = NbtIo.readCompressed(scoreboardDat, NbtAccounter.unlimitedHeap()); + CompoundTag dataTag = root.getCompound("data").orElse(root); + Optional objectivesOpt = dataTag.getList("Objectives"); + if (objectivesOpt.isEmpty()) { + return false; + } + + for (Tag objectiveTag : objectivesOpt.get()) { + if (!(objectiveTag instanceof CompoundTag objectiveCompound)) { + continue; + } + + Optional nameOpt = objectiveCompound.getString("Name"); + if (nameOpt.isPresent() && objectiveName.equals(nameOpt.get())) { + return true; + } + } + } catch (Exception ignored) { + return false; + } + + return false; + } +} diff --git a/src/gametest/java/com/gmail/anthony17j/multiworld/test/WorldPropertiesIsolationTest.java b/src/gametest/java/fr/jeanney/WorldPropertiesIsolationTest.java similarity index 61% rename from src/gametest/java/com/gmail/anthony17j/multiworld/test/WorldPropertiesIsolationTest.java rename to src/gametest/java/fr/jeanney/WorldPropertiesIsolationTest.java index 9d99d4c..cfd76e4 100644 --- a/src/gametest/java/com/gmail/anthony17j/multiworld/test/WorldPropertiesIsolationTest.java +++ b/src/gametest/java/fr/jeanney/WorldPropertiesIsolationTest.java @@ -1,6 +1,6 @@ -package com.gmail.anthony17j.multiworld.test; +package fr.jeanney; -import com.gmail.anthony17j.multiworld.mixin.ServerLevelAccessor; +import fr.jeanney.mixin.ServerLevelAccessor; import net.fabricmc.fabric.api.gametest.v1.GameTest; import net.minecraft.core.registries.Registries; import net.minecraft.gametest.framework.GameTestHelper; @@ -20,8 +20,8 @@ import net.minecraft.world.level.gamerules.GameRules; import net.minecraft.world.level.storage.ServerLevelData; -import static com.gmail.anthony17j.multiworld.MultiWorld.NAMESPACE; -import static com.gmail.anthony17j.multiworld.test.TestAssertions.*; +import static fr.jeanney.MultiWorld.NAMESPACE; +import static fr.jeanney.TestAssertions.*; public class WorldPropertiesIsolationTest { @@ -48,35 +48,34 @@ public void gameRulesAndScheduledEventsAreIsolated(GameTestHelper helper) { ServerLevel overworld = server.overworld(); ServerLevelData customProps = ((ServerLevelAccessor) custom).getWorldProperties(); ServerLevelData overworldProps = ((ServerLevelAccessor) overworld).getWorldProperties(); + GameRules customRules = custom.getGameRules(); + GameRules overworldRules = overworld.getGameRules(); - if (customProps.getGameRules() == overworldProps.getGameRules()) { + if (customRules == overworldRules) { helper.fail("Expected isolated GameRules instance for custom world"); return; } - if (customProps.getScheduledEvents() == overworldProps.getScheduledEvents()) { - helper.fail("Expected isolated scheduled events queue for custom world"); - return; - } + // Scheduled events are no longer exposed on ServerLevelData in 26.1. - int previousOverworldTickSpeed = overworldProps.getGameRules().get(GameRules.RANDOM_TICK_SPEED); - int previousCustomTickSpeed = customProps.getGameRules().get(GameRules.RANDOM_TICK_SPEED); + int previousOverworldTickSpeed = overworldRules.get(GameRules.RANDOM_TICK_SPEED); + int previousCustomTickSpeed = customRules.get(GameRules.RANDOM_TICK_SPEED); try { - overworldProps.getGameRules().set(GameRules.RANDOM_TICK_SPEED, 11, server); - if (customProps.getGameRules().get(GameRules.RANDOM_TICK_SPEED) != previousCustomTickSpeed) { + overworldRules.set(GameRules.RANDOM_TICK_SPEED, 11, server); + if (customRules.get(GameRules.RANDOM_TICK_SPEED) != previousCustomTickSpeed) { helper.fail("Custom world game rules changed after overworld mutation"); return; } - customProps.getGameRules().set(GameRules.RANDOM_TICK_SPEED, 17, server); - if (overworldProps.getGameRules().get(GameRules.RANDOM_TICK_SPEED) != 11) { + customRules.set(GameRules.RANDOM_TICK_SPEED, 17, server); + if (overworldRules.get(GameRules.RANDOM_TICK_SPEED) != 11) { helper.fail("Overworld game rules changed after custom-world mutation"); return; } } finally { - overworldProps.getGameRules().set(GameRules.RANDOM_TICK_SPEED, previousOverworldTickSpeed, server); - customProps.getGameRules().set(GameRules.RANDOM_TICK_SPEED, previousCustomTickSpeed, server); + overworldRules.set(GameRules.RANDOM_TICK_SPEED, previousOverworldTickSpeed, server); + customRules.set(GameRules.RANDOM_TICK_SPEED, previousCustomTickSpeed, server); } helper.succeed(); @@ -146,85 +145,78 @@ public void timeAndWeatherAreIsolated(GameTestHelper helper) { ServerLevel overworld = server.overworld(); ServerLevelData customProps = ((ServerLevelAccessor) custom).getWorldProperties(); ServerLevelData overworldProps = ((ServerLevelAccessor) overworld).getWorldProperties(); + var customWeather = custom.getWeatherData(); + var overworldWeather = overworld.getWeatherData(); long initialOverworldGameTime = overworldProps.getGameTime(); - long initialOverworldDayTime = overworldProps.getDayTime(); - int initialOverworldClearWeather = overworldProps.getClearWeatherTime(); - boolean initialOverworldRaining = overworldProps.isRaining(); - int initialOverworldRainTime = overworldProps.getRainTime(); - boolean initialOverworldThundering = overworldProps.isThundering(); - int initialOverworldThunderTime = overworldProps.getThunderTime(); + int initialOverworldClearWeather = overworldWeather.getClearWeatherTime(); + boolean initialOverworldRaining = overworldWeather.isRaining(); + int initialOverworldRainTime = overworldWeather.getRainTime(); + boolean initialOverworldThundering = overworldWeather.isThundering(); + int initialOverworldThunderTime = overworldWeather.getThunderTime(); long initialCustomGameTime = customProps.getGameTime(); - long initialCustomDayTime = customProps.getDayTime(); - int initialCustomClearWeather = customProps.getClearWeatherTime(); - boolean initialCustomRaining = customProps.isRaining(); - int initialCustomRainTime = customProps.getRainTime(); - boolean initialCustomThundering = customProps.isThundering(); - int initialCustomThunderTime = customProps.getThunderTime(); + int initialCustomClearWeather = customWeather.getClearWeatherTime(); + boolean initialCustomRaining = customWeather.isRaining(); + int initialCustomRainTime = customWeather.getRainTime(); + boolean initialCustomThundering = customWeather.isThundering(); + int initialCustomThunderTime = customWeather.getThunderTime(); try { overworldProps.setGameTime(initialOverworldGameTime + 2000L); - overworldProps.setDayTime(initialOverworldDayTime + 4000L); - overworldProps.setClearWeatherTime(0); - overworldProps.setRaining(!initialOverworldRaining); - overworldProps.setRainTime(initialOverworldRainTime + 600); - overworldProps.setThundering(!initialOverworldThundering); - overworldProps.setThunderTime(initialOverworldThunderTime + 800); + overworldWeather.setClearWeatherTime(0); + overworldWeather.setRaining(!initialOverworldRaining); + overworldWeather.setRainTime(initialOverworldRainTime + 600); + overworldWeather.setThundering(!initialOverworldThundering); + overworldWeather.setThunderTime(initialOverworldThunderTime + 800); if (customProps.getGameTime() != initialCustomGameTime - || customProps.getDayTime() != initialCustomDayTime - || customProps.getClearWeatherTime() != initialCustomClearWeather - || customProps.isRaining() != initialCustomRaining - || customProps.getRainTime() != initialCustomRainTime - || customProps.isThundering() != initialCustomThundering - || customProps.getThunderTime() != initialCustomThunderTime) { + || customWeather.getClearWeatherTime() != initialCustomClearWeather + || customWeather.isRaining() != initialCustomRaining + || customWeather.getRainTime() != initialCustomRainTime + || customWeather.isThundering() != initialCustomThundering + || customWeather.getThunderTime() != initialCustomThunderTime) { helper.fail("Custom world time/weather changed after overworld mutation"); return; } long expectedOverworldGameTime = overworldProps.getGameTime(); - long expectedOverworldDayTime = overworldProps.getDayTime(); - int expectedOverworldClearWeather = overworldProps.getClearWeatherTime(); - boolean expectedOverworldRaining = overworldProps.isRaining(); - int expectedOverworldRainTime = overworldProps.getRainTime(); - boolean expectedOverworldThundering = overworldProps.isThundering(); - int expectedOverworldThunderTime = overworldProps.getThunderTime(); + int expectedOverworldClearWeather = overworldWeather.getClearWeatherTime(); + boolean expectedOverworldRaining = overworldWeather.isRaining(); + int expectedOverworldRainTime = overworldWeather.getRainTime(); + boolean expectedOverworldThundering = overworldWeather.isThundering(); + int expectedOverworldThunderTime = overworldWeather.getThunderTime(); customProps.setGameTime(initialCustomGameTime + 1234L); - customProps.setDayTime(initialCustomDayTime + 5678L); - customProps.setClearWeatherTime(initialCustomClearWeather + 90); - customProps.setRaining(!initialCustomRaining); - customProps.setRainTime(initialCustomRainTime + 91); - customProps.setThundering(!initialCustomThundering); - customProps.setThunderTime(initialCustomThunderTime + 92); + customWeather.setClearWeatherTime(initialCustomClearWeather + 90); + customWeather.setRaining(!initialCustomRaining); + customWeather.setRainTime(initialCustomRainTime + 91); + customWeather.setThundering(!initialCustomThundering); + customWeather.setThunderTime(initialCustomThunderTime + 92); if (overworldProps.getGameTime() != expectedOverworldGameTime - || overworldProps.getDayTime() != expectedOverworldDayTime - || overworldProps.getClearWeatherTime() != expectedOverworldClearWeather - || overworldProps.isRaining() != expectedOverworldRaining - || overworldProps.getRainTime() != expectedOverworldRainTime - || overworldProps.isThundering() != expectedOverworldThundering - || overworldProps.getThunderTime() != expectedOverworldThunderTime) { + || overworldWeather.getClearWeatherTime() != expectedOverworldClearWeather + || overworldWeather.isRaining() != expectedOverworldRaining + || overworldWeather.getRainTime() != expectedOverworldRainTime + || overworldWeather.isThundering() != expectedOverworldThundering + || overworldWeather.getThunderTime() != expectedOverworldThunderTime) { helper.fail("Overworld time/weather changed after custom-world mutation"); return; } } finally { overworldProps.setGameTime(initialOverworldGameTime); - overworldProps.setDayTime(initialOverworldDayTime); - overworldProps.setClearWeatherTime(initialOverworldClearWeather); - overworldProps.setRaining(initialOverworldRaining); - overworldProps.setRainTime(initialOverworldRainTime); - overworldProps.setThundering(initialOverworldThundering); - overworldProps.setThunderTime(initialOverworldThunderTime); + overworldWeather.setClearWeatherTime(initialOverworldClearWeather); + overworldWeather.setRaining(initialOverworldRaining); + overworldWeather.setRainTime(initialOverworldRainTime); + overworldWeather.setThundering(initialOverworldThundering); + overworldWeather.setThunderTime(initialOverworldThunderTime); customProps.setGameTime(initialCustomGameTime); - customProps.setDayTime(initialCustomDayTime); - customProps.setClearWeatherTime(initialCustomClearWeather); - customProps.setRaining(initialCustomRaining); - customProps.setRainTime(initialCustomRainTime); - customProps.setThundering(initialCustomThundering); - customProps.setThunderTime(initialCustomThunderTime); + customWeather.setClearWeatherTime(initialCustomClearWeather); + customWeather.setRaining(initialCustomRaining); + customWeather.setRainTime(initialCustomRainTime); + customWeather.setThundering(initialCustomThundering); + customWeather.setThunderTime(initialCustomThunderTime); } helper.succeed(); @@ -250,7 +242,8 @@ public void keepInventoryCommandIsScopedPerWorld(GameTestHelper helper) { boolean initialCustomKeepInventory = custom.getGameRules().get(GameRules.KEEP_INVENTORY); if (!setKeepInventoryViaCommand(server, server.overworld(), true)) { - // Some GameTest runtimes expose /gamerule in read-only form. Fall back to API mutation + // Some GameTest runtimes expose /gamerule in read-only form. Fall back to API + // mutation // so we can still verify per-world keepInventory behavior across death/respawn. server.overworld().getGameRules().set(GameRules.KEEP_INVENTORY, true, server); } @@ -285,7 +278,8 @@ public void keepInventoryCommandIsScopedPerWorld(GameTestHelper helper) { } respawned.getInventory().add(new ItemStack(Items.EMERALD, 1)); - assertHasItem(helper, respawned, Items.EMERALD, "in custom world before death with keepInventory=false"); + assertHasItem(helper, respawned, Items.EMERALD, + "in custom world before death with keepInventory=false"); ServerPlayer customRespawn = killAndRespawn(respawned, server); assertInDimension(helper, customRespawn, NAMESPACE, ISOLATION_KEEP_INVENTORY_WORLD); @@ -301,8 +295,7 @@ public void keepInventoryCommandIsScopedPerWorld(GameTestHelper helper) { private static ServerLevel getCustomWorld(MinecraftServer server, String worldName) { ResourceKey worldKey = ResourceKey.create( Registries.DIMENSION, - Identifier.fromNamespaceAndPath(NAMESPACE, worldName) - ); + Identifier.fromNamespaceAndPath(NAMESPACE, worldName)); return server.getLevel(worldKey); } @@ -322,33 +315,8 @@ private static void runCommandInWorld(MinecraftServer server, ServerLevel level, private static boolean setKeepInventoryViaCommand(MinecraftServer server, ServerLevel level, boolean value) { String boolValue = value ? "true" : "false"; - String altBoolValue = value ? "on" : "off"; - String[] candidates = { - "gamerule keepInventory " + boolValue, - "gamerule keepInventory set " + boolValue, - "gamerule set keepInventory " + boolValue, - "gamerule keepinventory " + boolValue, - "gamerule keepinventory set " + boolValue, - "gamerule set keepinventory " + boolValue, - "gamerules keepInventory " + boolValue, - "gamerules keepInventory set " + boolValue, - "gamerules set keepInventory " + boolValue, - "gamerules keepinventory " + boolValue, - "gamerules keepinventory set " + boolValue, - "gamerules set keepinventory " + boolValue, - "gamerule keepInventory " + altBoolValue, - "gamerule keepinventory " + altBoolValue, - "gamerules keepInventory " + altBoolValue, - "gamerules keepinventory " + altBoolValue - }; - - for (String candidate : candidates) { - runCommandInWorld(server, level, candidate); - if (level.getGameRules().get(GameRules.KEEP_INVENTORY) == value) { - return true; - } - } - return false; + runCommandInWorld(server, level, "gamerule keep_inventory " + boolValue); + return level.getGameRules().get(GameRules.KEEP_INVENTORY) == value; } } \ No newline at end of file diff --git a/src/gametest/resources/fabric.mod.json b/src/gametest/resources/fabric.mod.json index 553a16f..68cf679 100644 --- a/src/gametest/resources/fabric.mod.json +++ b/src/gametest/resources/fabric.mod.json @@ -6,20 +6,20 @@ "environment": "*", "entrypoints": { "fabric-gametest": [ - "com.gmail.anthony17j.multiworld.test.InventorySwitchTest", - "com.gmail.anthony17j.multiworld.test.RoundTripTest", - "com.gmail.anthony17j.multiworld.test.AdvancementsAndStatsTest", - "com.gmail.anthony17j.multiworld.test.CreativeBlocksAdvancementsTest", - "com.gmail.anthony17j.multiworld.test.PlayerStateSeparationTest", - "com.gmail.anthony17j.multiworld.test.MountedEntityTest", - "com.gmail.anthony17j.multiworld.test.EnderPearlSeparationTest", - "com.gmail.anthony17j.multiworld.test.PortalDimensionTest", - "com.gmail.anthony17j.multiworld.test.EntityPortalTest", - "com.gmail.anthony17j.multiworld.test.RespawnTest", - "com.gmail.anthony17j.multiworld.test.EnderDragonEndTest", - "com.gmail.anthony17j.multiworld.test.WorldCreationTest", - "com.gmail.anthony17j.multiworld.test.WorldManagementCommandTest", - "com.gmail.anthony17j.multiworld.test.WorldPropertiesIsolationTest" + "fr.jeanney.InventorySwitchTest", + "fr.jeanney.RoundTripTest", + "fr.jeanney.AdvancementsAndStatsTest", + "fr.jeanney.CreativeBlocksAdvancementsTest", + "fr.jeanney.PlayerStateSeparationTest", + "fr.jeanney.MountedEntityTest", + "fr.jeanney.EnderPearlSeparationTest", + "fr.jeanney.PortalDimensionTest", + "fr.jeanney.EntityPortalTest", + "fr.jeanney.RespawnTest", + "fr.jeanney.EnderDragonEndTest", + "fr.jeanney.WorldCreationTest", + "fr.jeanney.WorldManagementCommandTest", + "fr.jeanney.WorldPropertiesIsolationTest" ] }, "depends": { diff --git a/src/gametest/resources/fixtures/import_26_1/data/minecraft/custom_boss_events.dat b/src/gametest/resources/fixtures/import_26_1/data/minecraft/custom_boss_events.dat new file mode 100644 index 0000000..7b815f9 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/data/minecraft/custom_boss_events.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/data/minecraft/game_rules.dat b/src/gametest/resources/fixtures/import_26_1/data/minecraft/game_rules.dat new file mode 100644 index 0000000..5828254 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/data/minecraft/game_rules.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/data/minecraft/random_sequences.dat b/src/gametest/resources/fixtures/import_26_1/data/minecraft/random_sequences.dat new file mode 100644 index 0000000..11b66a2 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/data/minecraft/random_sequences.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/data/minecraft/scheduled_events.dat b/src/gametest/resources/fixtures/import_26_1/data/minecraft/scheduled_events.dat new file mode 100644 index 0000000..74964ae Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/data/minecraft/scheduled_events.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/data/minecraft/scoreboard.dat b/src/gametest/resources/fixtures/import_26_1/data/minecraft/scoreboard.dat new file mode 100644 index 0000000..f1d0415 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/data/minecraft/scoreboard.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/data/minecraft/stopwatches.dat b/src/gametest/resources/fixtures/import_26_1/data/minecraft/stopwatches.dat new file mode 100644 index 0000000..b5425c8 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/data/minecraft/stopwatches.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/data/minecraft/wandering_trader.dat b/src/gametest/resources/fixtures/import_26_1/data/minecraft/wandering_trader.dat new file mode 100644 index 0000000..500289b Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/data/minecraft/wandering_trader.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/data/minecraft/weather.dat b/src/gametest/resources/fixtures/import_26_1/data/minecraft/weather.dat new file mode 100644 index 0000000..dcc9a6d Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/data/minecraft/weather.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/data/minecraft/world_clocks.dat b/src/gametest/resources/fixtures/import_26_1/data/minecraft/world_clocks.dat new file mode 100644 index 0000000..9453136 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/data/minecraft/world_clocks.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/data/minecraft/world_gen_settings.dat b/src/gametest/resources/fixtures/import_26_1/data/minecraft/world_gen_settings.dat new file mode 100644 index 0000000..944cd21 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/data/minecraft/world_gen_settings.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/data/minecraft/chunk_tickets.dat b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/data/minecraft/chunk_tickets.dat new file mode 100644 index 0000000..7b815f9 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/data/minecraft/chunk_tickets.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/data/minecraft/raids.dat b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/data/minecraft/raids.dat new file mode 100644 index 0000000..fcb37b8 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/data/minecraft/raids.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/data/minecraft/world_border.dat b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/data/minecraft/world_border.dat new file mode 100644 index 0000000..ede7593 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/data/minecraft/world_border.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/entities/r.-1.-1.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/entities/r.-1.-1.mca new file mode 100644 index 0000000..57d92a8 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/entities/r.-1.-1.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/entities/r.-1.0.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/entities/r.-1.0.mca new file mode 100644 index 0000000..8c021ce Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/entities/r.-1.0.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/entities/r.0.-1.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/entities/r.0.-1.mca new file mode 100644 index 0000000..204b37d Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/entities/r.0.-1.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/entities/r.0.0.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/entities/r.0.0.mca new file mode 100644 index 0000000..c255457 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/entities/r.0.0.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/poi/r.-1.-1.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/poi/r.-1.-1.mca new file mode 100644 index 0000000..e69de29 diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/poi/r.-1.0.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/poi/r.-1.0.mca new file mode 100644 index 0000000..19ac3a3 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/poi/r.-1.0.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/poi/r.0.-1.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/poi/r.0.-1.mca new file mode 100644 index 0000000..b2be27a Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/poi/r.0.-1.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/poi/r.0.0.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/poi/r.0.0.mca new file mode 100644 index 0000000..a294616 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/poi/r.0.0.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/region/r.-1.-1.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/region/r.-1.-1.mca new file mode 100644 index 0000000..fdb8f6d Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/region/r.-1.-1.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/region/r.-1.0.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/region/r.-1.0.mca new file mode 100644 index 0000000..e92de1e Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/region/r.-1.0.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/region/r.0.-1.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/region/r.0.-1.mca new file mode 100644 index 0000000..19565d4 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/region/r.0.-1.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/region/r.0.0.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/region/r.0.0.mca new file mode 100644 index 0000000..6ce6311 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/overworld/region/r.0.0.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/data/minecraft/chunk_tickets.dat b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/data/minecraft/chunk_tickets.dat new file mode 100644 index 0000000..7b815f9 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/data/minecraft/chunk_tickets.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/data/minecraft/ender_dragon_fight.dat b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/data/minecraft/ender_dragon_fight.dat new file mode 100644 index 0000000..202dab7 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/data/minecraft/ender_dragon_fight.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/data/minecraft/raids.dat b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/data/minecraft/raids.dat new file mode 100644 index 0000000..fcb37b8 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/data/minecraft/raids.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/data/minecraft/world_border.dat b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/data/minecraft/world_border.dat new file mode 100644 index 0000000..ede7593 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/data/minecraft/world_border.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/entities/r.-1.-1.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/entities/r.-1.-1.mca new file mode 100644 index 0000000..8721b8d Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/entities/r.-1.-1.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/entities/r.-1.0.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/entities/r.-1.0.mca new file mode 100644 index 0000000..6e0e7df Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/entities/r.-1.0.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/entities/r.0.-1.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/entities/r.0.-1.mca new file mode 100644 index 0000000..f3c168e Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/entities/r.0.-1.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/entities/r.0.0.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/entities/r.0.0.mca new file mode 100644 index 0000000..de02413 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/entities/r.0.0.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/poi/r.-1.-1.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/poi/r.-1.-1.mca new file mode 100644 index 0000000..e69de29 diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/poi/r.-1.0.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/poi/r.-1.0.mca new file mode 100644 index 0000000..e69de29 diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/poi/r.0.-1.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/poi/r.0.-1.mca new file mode 100644 index 0000000..e69de29 diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/poi/r.0.0.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/poi/r.0.0.mca new file mode 100644 index 0000000..e69de29 diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/region/r.-1.-1.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/region/r.-1.-1.mca new file mode 100644 index 0000000..67a4a2e Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/region/r.-1.-1.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/region/r.-1.0.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/region/r.-1.0.mca new file mode 100644 index 0000000..12f2b11 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/region/r.-1.0.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/region/r.0.-1.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/region/r.0.-1.mca new file mode 100644 index 0000000..9c693ae Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/region/r.0.-1.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/region/r.0.0.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/region/r.0.0.mca new file mode 100644 index 0000000..2e1e2bc Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_end/region/r.0.0.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/data/minecraft/chunk_tickets.dat b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/data/minecraft/chunk_tickets.dat new file mode 100644 index 0000000..7b815f9 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/data/minecraft/chunk_tickets.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/data/minecraft/raids.dat b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/data/minecraft/raids.dat new file mode 100644 index 0000000..fcb37b8 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/data/minecraft/raids.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/data/minecraft/world_border.dat b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/data/minecraft/world_border.dat new file mode 100644 index 0000000..ede7593 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/data/minecraft/world_border.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/entities/r.-1.-1.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/entities/r.-1.-1.mca new file mode 100644 index 0000000..d887831 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/entities/r.-1.-1.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/entities/r.-1.0.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/entities/r.-1.0.mca new file mode 100644 index 0000000..e69de29 diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/entities/r.0.-1.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/entities/r.0.-1.mca new file mode 100644 index 0000000..f47f83b Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/entities/r.0.-1.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/entities/r.0.0.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/entities/r.0.0.mca new file mode 100644 index 0000000..11dee0a Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/entities/r.0.0.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/poi/r.-1.-1.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/poi/r.-1.-1.mca new file mode 100644 index 0000000..e69de29 diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/poi/r.-1.0.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/poi/r.-1.0.mca new file mode 100644 index 0000000..e69de29 diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/poi/r.0.-1.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/poi/r.0.-1.mca new file mode 100644 index 0000000..af06b1c Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/poi/r.0.-1.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/poi/r.0.0.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/poi/r.0.0.mca new file mode 100644 index 0000000..e69de29 diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/region/r.-1.-1.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/region/r.-1.-1.mca new file mode 100644 index 0000000..cf4aa33 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/region/r.-1.-1.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/region/r.-1.0.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/region/r.-1.0.mca new file mode 100644 index 0000000..8a0ab12 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/region/r.-1.0.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/region/r.0.-1.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/region/r.0.-1.mca new file mode 100644 index 0000000..5a521e4 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/region/r.0.-1.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/region/r.0.0.mca b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/region/r.0.0.mca new file mode 100644 index 0000000..475783a Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/dimensions/minecraft/the_nether/region/r.0.0.mca differ diff --git a/src/gametest/resources/fixtures/import_26_1/level.dat b/src/gametest/resources/fixtures/import_26_1/level.dat new file mode 100644 index 0000000..aaa1176 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/level.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/players/advancements/abcdefgh-ijkl-mnop-qrst-uvwxyz123456.json b/src/gametest/resources/fixtures/import_26_1/players/advancements/abcdefgh-ijkl-mnop-qrst-uvwxyz123456.json new file mode 100644 index 0000000..74ecf4a --- /dev/null +++ b/src/gametest/resources/fixtures/import_26_1/players/advancements/abcdefgh-ijkl-mnop-qrst-uvwxyz123456.json @@ -0,0 +1,69 @@ +{ + "minecraft:recipes/decorations/crafting_table": { + "criteria": { + "unlock_right_away": "2026-03-24 16:05:57 +0100" + }, + "done": true + }, + "minecraft:adventure/adventuring_time": { + "criteria": { + "minecraft:dark_forest": "2026-03-24 16:05:58 +0100" + }, + "done": false + }, + "minecraft:recipes/misc/charcoal": { + "criteria": { + "has_log": "2026-03-25 19:19:19 +0100" + }, + "done": true + }, + "minecraft:recipes/building_blocks/oak_planks": { + "criteria": { + "has_logs": "2026-03-25 19:19:19 +0100" + }, + "done": true + }, + "minecraft:recipes/building_blocks/oak_wood": { + "criteria": { + "has_log": "2026-03-25 19:19:19 +0100" + }, + "done": true + }, + "minecraft:nether/explore_nether": { + "criteria": { + "minecraft:basalt_deltas": "2026-03-25 21:59:31 +0100" + }, + "done": false + }, + "minecraft:story/enter_the_nether": { + "criteria": { + "entered_nether": "2026-03-25 21:59:31 +0100" + }, + "done": true + }, + "minecraft:recipes/misc/leaf_litter": { + "criteria": { + "has_leaves": "2026-03-25 21:58:59 +0100" + }, + "done": true + }, + "minecraft:nether/root": { + "criteria": { + "entered_nether": "2026-03-25 21:59:31 +0100" + }, + "done": true + }, + "minecraft:end/root": { + "criteria": { + "entered_end": "2026-03-25 21:59:42 +0100" + }, + "done": true + }, + "minecraft:story/enter_the_end": { + "criteria": { + "entered_end": "2026-03-25 21:59:42 +0100" + }, + "done": true + }, + "DataVersion": 4784 +} \ No newline at end of file diff --git a/src/gametest/resources/fixtures/import_26_1/players/data/abcdefgh-ijkl-mnop-qrst-uvwxyz123456.dat b/src/gametest/resources/fixtures/import_26_1/players/data/abcdefgh-ijkl-mnop-qrst-uvwxyz123456.dat new file mode 100644 index 0000000..10f2f69 Binary files /dev/null and b/src/gametest/resources/fixtures/import_26_1/players/data/abcdefgh-ijkl-mnop-qrst-uvwxyz123456.dat differ diff --git a/src/gametest/resources/fixtures/import_26_1/players/stats/abcdefgh-ijkl-mnop-qrst-uvwxyz123456.json b/src/gametest/resources/fixtures/import_26_1/players/stats/abcdefgh-ijkl-mnop-qrst-uvwxyz123456.json new file mode 100644 index 0000000..98bf1e3 --- /dev/null +++ b/src/gametest/resources/fixtures/import_26_1/players/stats/abcdefgh-ijkl-mnop-qrst-uvwxyz123456.json @@ -0,0 +1,22 @@ +{ + "stats": { + "minecraft:custom": { + "minecraft:time_since_rest": 4915, + "minecraft:walk_one_cm": 2513, + "minecraft:total_world_time": 11656, + "minecraft:leave_game": 6, + "minecraft:fly_one_cm": 16393, + "minecraft:crouch_one_cm": 153, + "minecraft:sneak_time": 149, + "minecraft:play_time": 9453, + "minecraft:deaths": 8, + "minecraft:jump": 14, + "minecraft:time_since_death": 4857 + }, + "minecraft:used": { + "minecraft:dark_oak_leaves": 2, + "minecraft:armor_stand": 2 + } + }, + "DataVersion": 4784 +} \ No newline at end of file diff --git a/src/main/java/com/gmail/anthony17j/multiworld/CustomServerWorldProperties.java b/src/main/java/com/gmail/anthony17j/multiworld/CustomServerWorldProperties.java deleted file mode 100644 index 62add25..0000000 --- a/src/main/java/com/gmail/anthony17j/multiworld/CustomServerWorldProperties.java +++ /dev/null @@ -1,223 +0,0 @@ -package com.gmail.anthony17j.multiworld; - -import com.mojang.serialization.Dynamic; -import net.minecraft.nbt.NbtOps; -import net.minecraft.server.MinecraftServer; -import net.minecraft.world.Difficulty; -import net.minecraft.world.level.GameType; -import net.minecraft.world.level.gamerules.GameRules; -import net.minecraft.world.level.storage.DerivedLevelData; -import net.minecraft.world.level.storage.LevelData; -import net.minecraft.world.level.storage.ServerLevelData; -import net.minecraft.world.level.storage.WorldData; -import net.minecraft.world.level.timers.TimerCallbacks; -import net.minecraft.world.level.timers.TimerQueue; -import org.jspecify.annotations.NonNull; - -import java.util.UUID; - -public final class CustomServerWorldProperties extends DerivedLevelData { - public final long seed; - - private LevelData.RespawnData respawnData; - private long gameTime; - private long dayTime; - private int clearWeatherTime; - private boolean raining; - private int rainTime; - private boolean thundering; - private int thunderTime; - private boolean initialized; - private GameType gameType; - private final boolean allowCommands; - private final Difficulty difficulty; - private final boolean difficultyLocked; - private final GameRules gameRules; - private final TimerQueue scheduledEvents; - private int wanderingTraderSpawnDelay; - private int wanderingTraderSpawnChance; - private UUID wanderingTraderId; - - public CustomServerWorldProperties(WorldData worldData, ServerLevelData levelProperties, long seed) { - super(worldData, levelProperties); - - this.seed = seed; - - this.respawnData = null; - this.gameTime = levelProperties.getGameTime(); - this.dayTime = levelProperties.getDayTime(); - this.clearWeatherTime = levelProperties.getClearWeatherTime(); - this.raining = levelProperties.isRaining(); - this.rainTime = levelProperties.getRainTime(); - this.thundering = levelProperties.isThundering(); - this.thunderTime = levelProperties.getThunderTime(); - this.initialized = false; - this.gameType = levelProperties.getGameType(); - this.allowCommands = levelProperties.isAllowCommands(); - this.difficulty = levelProperties.getDifficulty(); - this.difficultyLocked = levelProperties.isDifficultyLocked(); - this.gameRules = worldData.getGameRules().copy(worldData.enabledFeatures()); - this.scheduledEvents = new TimerQueue<>( - TimerCallbacks.SERVER_CALLBACKS, - levelProperties.getScheduledEvents().store().stream().map(tag -> new Dynamic<>(NbtOps.INSTANCE, tag)) - ); - this.wanderingTraderSpawnDelay = levelProperties.getWanderingTraderSpawnDelay(); - this.wanderingTraderSpawnChance = levelProperties.getWanderingTraderSpawnChance(); - this.wanderingTraderId = levelProperties.getWanderingTraderId(); - } - - @Override - public LevelData.@NonNull RespawnData getRespawnData() { - return this.respawnData; - } - - @Override - public void setSpawn(LevelData.@NonNull RespawnData respawnData) { - this.respawnData = respawnData; - } - - @Override - public long getGameTime() { - return this.gameTime; - } - - @Override - public void setGameTime(long time) { - this.gameTime = time; - } - - @Override - public long getDayTime() { - return this.dayTime; - } - - @Override - public void setDayTime(long time) { - this.dayTime = time; - } - - @Override - public int getClearWeatherTime() { - return this.clearWeatherTime; - } - - @Override - public void setClearWeatherTime(int time) { - this.clearWeatherTime = time; - } - - @Override - public boolean isRaining() { - return this.raining; - } - - @Override - public void setRaining(boolean raining) { - this.raining = raining; - } - - @Override - public int getRainTime() { - return this.rainTime; - } - - @Override - public void setRainTime(int time) { - this.rainTime = time; - } - - @Override - public boolean isThundering() { - return this.thundering; - } - - @Override - public void setThundering(boolean thundering) { - this.thundering = thundering; - } - - @Override - public int getThunderTime() { - return this.thunderTime; - } - - @Override - public void setThunderTime(int time) { - this.thunderTime = time; - } - - @Override - public boolean isInitialized() { - return this.initialized; - } - - @Override - public void setInitialized(boolean initialized) { - this.initialized = initialized; - } - - @Override - public @NonNull GameType getGameType() { - return this.gameType; - } - - @Override - public void setGameType(@NonNull GameType gameType) { - this.gameType = gameType; - } - - @Override - public boolean isAllowCommands() { - return this.allowCommands; - } - - @Override - public @NonNull Difficulty getDifficulty() { - return this.difficulty; - } - - @Override - public boolean isDifficultyLocked() { - return this.difficultyLocked; - } - - @Override - public @NonNull GameRules getGameRules() { - return this.gameRules; - } - - @Override - public @NonNull TimerQueue getScheduledEvents() { - return this.scheduledEvents; - } - - @Override - public int getWanderingTraderSpawnDelay() { - return this.wanderingTraderSpawnDelay; - } - - @Override - public void setWanderingTraderSpawnDelay(int delay) { - this.wanderingTraderSpawnDelay = delay; - } - - @Override - public int getWanderingTraderSpawnChance() { - return this.wanderingTraderSpawnChance; - } - - @Override - public void setWanderingTraderSpawnChance(int chance) { - this.wanderingTraderSpawnChance = chance; - } - - @Override - public @NonNull UUID getWanderingTraderId() { - return this.wanderingTraderId; - } - - @Override - public void setWanderingTraderId(@NonNull UUID uuid) { - this.wanderingTraderId = uuid; - } -} diff --git a/src/main/java/com/gmail/anthony17j/multiworld/MultiWorld.java b/src/main/java/com/gmail/anthony17j/multiworld/MultiWorld.java deleted file mode 100644 index f9d6c0e..0000000 --- a/src/main/java/com/gmail/anthony17j/multiworld/MultiWorld.java +++ /dev/null @@ -1,973 +0,0 @@ -package com.gmail.anthony17j.multiworld; - -import com.gmail.anthony17j.multiworld.command.CreativeCommand; -import com.gmail.anthony17j.multiworld.command.MultiWorldCommand; -import com.gmail.anthony17j.multiworld.mixin.IMinecraftServerMixin; -import com.gmail.anthony17j.multiworld.mixin.ServerLevelAccessor; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import net.fabricmc.api.ModInitializer; -import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; -import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; -import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; -import net.fabricmc.fabric.api.event.lifecycle.v1.ServerWorldEvents; -import net.minecraft.core.BlockPos; -import net.minecraft.core.GlobalPos; -import net.minecraft.core.Holder; -import net.minecraft.core.MappedRegistry; -import net.minecraft.core.RegistrationInfo; -import net.minecraft.core.RegistryAccess; -import net.minecraft.core.registries.BuiltInRegistries; -import net.minecraft.core.registries.Registries; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.NbtAccounter; -import net.minecraft.nbt.NbtIo; -import net.minecraft.nbt.NbtOps; -import net.minecraft.nbt.NbtUtils; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; -import net.minecraft.resources.ResourceKey; -import net.minecraft.server.MinecraftServer; -import net.minecraft.server.level.ServerLevel; -import net.minecraft.server.level.ServerPlayer; -import net.minecraft.util.ProgressListener; -import net.minecraft.util.datafix.DataFixTypes; -import net.minecraft.world.level.GameType; -import net.minecraft.world.level.Level; -import net.minecraft.world.level.biome.Biome; -import net.minecraft.world.level.biome.Biomes; -import net.minecraft.world.level.biome.MultiNoiseBiomeSource; -import net.minecraft.world.level.biome.MultiNoiseBiomeSourceParameterLists; -import net.minecraft.world.level.biome.TheEndBiomeSource; -import net.minecraft.world.level.chunk.ChunkGenerator; -import net.minecraft.world.level.dimension.BuiltinDimensionTypes; -import net.minecraft.world.level.dimension.DimensionType; -import net.minecraft.world.level.dimension.LevelStem; -import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; -import net.minecraft.world.level.levelgen.NoiseGeneratorSettings; -import net.minecraft.world.level.levelgen.Heightmap; -import net.minecraft.world.level.levelgen.flat.FlatLayerInfo; -import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings; -import net.minecraft.world.level.levelgen.FlatLevelSource; -import net.minecraft.world.level.storage.LevelData; -import net.minecraft.world.level.block.Block; -import net.minecraft.world.level.block.Blocks; -import net.minecraft.world.level.gamerules.GameRules; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileReader; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.stream.StreamSupport; - -import static com.gmail.anthony17j.multiworld.CustomServerWorld.getBaseWorldName; - -public class MultiWorld implements ModInitializer { - - public static final Logger LOGGER = LoggerFactory.getLogger(MultiWorld.class); - - public static final String NAMESPACE = "multiworld"; - public static final ResourceKey DEFAULT_DIM_TYPE = ResourceKey.create(Registries.DIMENSION_TYPE, Identifier.fromNamespaceAndPath(NAMESPACE, "default")); - - private static final String CONFIG_FILE_NAME = "multiworld.dat"; - private static final int GAME_RULE_AUTOSAVE_INTERVAL_TICKS = 100; - private static int gameRuleAutosaveCooldown = GAME_RULE_AUTOSAVE_INTERVAL_TICKS; - private static final Map, String> LAST_SAVED_GAMERULE_SNAPSHOTS = new HashMap<>(); - private static final Set CREATIVE_MODE_BASE_WORLDS = new HashSet<>(); - - public enum WorldMode { - NORMAL, - CREATIVE; - - public static WorldMode fromString(String value) { - if ("creative".equalsIgnoreCase(value)) { - return CREATIVE; - } - return NORMAL; - } - - public String toConfigValue() { - return this == CREATIVE ? "creative" : "normal"; - } - } - - @Override - public void onInitialize() { - CommandRegistrationCallback.EVENT.register(CreativeCommand::register); - CommandRegistrationCallback.EVENT.register(MultiWorldCommand::register); - - ServerLifecycleEvents.SERVER_STARTED.register((MinecraftServer server) -> { - CREATIVE_MODE_BASE_WORLDS.clear(); - loadCustomWorlds(server); - }); - - ServerTickEvents.END_SERVER_TICK.register(MultiWorld::autosaveCustomWorldGameRules); - ServerLifecycleEvents.SERVER_STOPPING.register(server -> { - persistCustomWorldGameRules(server, true); - LAST_SAVED_GAMERULE_SNAPSHOTS.clear(); - CREATIVE_MODE_BASE_WORLDS.clear(); - gameRuleAutosaveCooldown = GAME_RULE_AUTOSAVE_INTERVAL_TICKS; - }); - } - - public static boolean isCreativeModeWorld(ResourceKey key) { - if (!NAMESPACE.equals(key.identifier().getNamespace())) { - return false; - } - return CREATIVE_MODE_BASE_WORLDS.contains(getBaseWorldName(key.identifier().getPath())); - } - - public static Optional getCreativeWorldName(MinecraftServer server) { - return StreamSupport.stream(server.getAllLevels().spliterator(), false) - .filter(level -> NAMESPACE.equals(level.dimension().identifier().getNamespace())) - .map(level -> getBaseWorldName(level.dimension().identifier().getPath())) - .distinct() - .filter(CREATIVE_MODE_BASE_WORLDS::contains) - .sorted(Comparator.naturalOrder()) - .findFirst(); - } - - private static void registerWorldMode(String worldName, WorldMode mode) { - String baseWorldName = getBaseWorldName(worldName); - if (mode == WorldMode.CREATIVE) { - CREATIVE_MODE_BASE_WORLDS.add(baseWorldName); - } else { - CREATIVE_MODE_BASE_WORLDS.remove(baseWorldName); - } - } - - private static void loadCustomWorlds(MinecraftServer server) { - String serverWorldFolder = server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).toString(); - File dimensionsFolder = new File(serverWorldFolder + "/dimensions/" + NAMESPACE); - - if (!dimensionsFolder.exists() || !dimensionsFolder.isDirectory()) { - LOGGER.info("No custom dimensions found."); - return; - } - - File[] worldFolders = dimensionsFolder.listFiles(File::isDirectory); - - if (worldFolders == null || worldFolders.length == 0) { - LOGGER.info("No custom worlds found in dimensions folder."); - return; - } - - for (File worldFolder : worldFolders) { - String worldName = worldFolder.getName(); - - ResourceKey worldKey = ResourceKey.create(Registries.DIMENSION, Identifier.fromNamespaceAndPath(NAMESPACE, worldName)); - ServerLevel existingWorld = server.getLevel(worldKey); - - if (existingWorld instanceof CustomServerWorld) { - LOGGER.info("World '{}' already loaded correctly as CustomServerWorld", worldName); - continue; - } - - if (existingWorld != null) { - unload(server, existingWorld); - } - - File dataFolder = new File(worldFolder, "data"); - File configFile = new File(dataFolder, CONFIG_FILE_NAME); - - if (!configFile.exists()) { - LOGGER.warn("Config file not found for world '{}', skipping", worldName); - continue; - } - - try { - CompoundTag configNbt = NbtIo.readCompressed(configFile.toPath(), NbtAccounter.unlimitedHeap()); - - long seed = configNbt.getLong("seed").orElseThrow(); - ResourceKey dimensionType = getDimensionTypeFromString(configNbt.getString("dimensionType").orElseThrow()); - String generatorType = configNbt.getString("generatorType").orElse("overworld"); - - ChunkGenerator generator = createGeneratorFromType(server, generatorType, configNbt); - - BlockPos spawnPos = null; - if (configNbt.contains("spawnPos")) { - int[] spawnPosArray = configNbt.getIntArray("spawnPos").orElse(new int[]{0, 64, 0}); - spawnPos = new BlockPos(spawnPosArray[0], spawnPosArray[1], spawnPosArray[2]); - } - - GameType gameMode = null; - if (configNbt.contains("gameMode")) { - int gameModeIndex = configNbt.getInt("gameMode").orElse(0); - gameMode = GameType.byId(gameModeIndex); - } - - WorldMode worldMode = configNbt.contains("worldMode") - ? WorldMode.fromString(configNbt.getString("worldMode").orElse("normal")) - : (gameMode == GameType.CREATIVE ? WorldMode.CREATIVE : WorldMode.NORMAL); - - GameRules loadedGameRules = configNbt.contains("gameRules") - ? decodeGameRules(server, configNbt.getCompound("gameRules").orElse(new CompoundTag())) - : new GameRules(server.getWorldData().enabledFeatures()); - - createWorld(server, worldName, dimensionType, generator, seed, spawnPos, gameMode, worldMode); - registerWorldMode(worldName, worldMode); - - ServerLevel loadedWorld = server.getLevel(worldKey); - if (loadedWorld != null) { - ((ServerLevelAccessor) loadedWorld) - .getWorldProperties() - .getGameRules() - .setAll(loadedGameRules, server); - LAST_SAVED_GAMERULE_SNAPSHOTS.put(worldKey, encodeGameRules(server, loadedGameRules).toString()); - } - - } catch (Exception e) { - LOGGER.error("Failed to load world '{}'", worldName, e); - } - } - } - - private static ResourceKey getDimensionTypeFromString(String dimensionTypeString) { - if (dimensionTypeString == null || dimensionTypeString.isEmpty()) { - return BuiltinDimensionTypes.OVERWORLD; - } - - String[] parts = dimensionTypeString.split(":"); - if (parts.length != 2) { - return BuiltinDimensionTypes.OVERWORLD; - } - - return ResourceKey.create(Registries.DIMENSION_TYPE, Identifier.fromNamespaceAndPath(parts[0], parts[1])); - } - - private static void saveWorldConfig( - MinecraftServer server, - String worldString, - ResourceKey dimensionType, - ChunkGenerator generator, - String generatorType, - long seed, - BlockPos spawnPos, - GameType gameMode, - WorldMode worldMode, - GameRules gameRules - ) { - String serverWorldFolder = server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).toString(); - String dimensionPath = serverWorldFolder + "/dimensions/" + NAMESPACE + "/" + worldString + "/data"; - File dimensionDataDir = new File(dimensionPath); - - if (!dimensionDataDir.exists() && !dimensionDataDir.mkdirs()) { - LOGGER.error("Could not create data directory for world '{}'", worldString); - return; - } - - File configFile = new File(dimensionDataDir, CONFIG_FILE_NAME); - - try { - CompoundTag configNbt = new CompoundTag(); - configNbt.putString("dimensionType", dimensionType.identifier().toString()); - configNbt.putLong("seed", seed); - configNbt.putString("generatorType", generatorType); - - if (generatorType.equals("flat") && generator instanceof FlatLevelSource flatGenerator) { - FlatLevelGeneratorSettings flatConfig = flatGenerator.settings(); - - CompoundTag flatConfigNbt = new CompoundTag(); - List layers = flatConfig.getLayersInfo(); - CompoundTag[] layersNbt = new CompoundTag[layers.size()]; - - for (int i = 0; i < layers.size(); i++) { - FlatLayerInfo layer = layers.get(i); - CompoundTag layerNbt = new CompoundTag(); - layerNbt.putInt("height", layer.getHeight()); - layerNbt.putString("block", BuiltInRegistries.BLOCK.getKey(layer.getBlockState().getBlock()).toString()); - layersNbt[i] = layerNbt; - } - - CompoundTag layersCompound = new CompoundTag(); - for (int i = 0; i < layersNbt.length; i++) { - layersCompound.put("layer" + i, layersNbt[i]); - } - layersCompound.putInt("count", layersNbt.length); - flatConfigNbt.put("layers", layersCompound); - flatConfigNbt.putString("biome", flatConfig.getBiome().unwrapKey().map(k -> k.identifier().toString()).orElseThrow()); - configNbt.put("flatConfig", flatConfigNbt); - } - - if (spawnPos != null) { - configNbt.putIntArray("spawnPos", new int[]{spawnPos.getX(), spawnPos.getY(), spawnPos.getZ()}); - } - - if (gameMode != null) { - configNbt.putInt("gameMode", gameMode.getId()); - } - - if (worldMode != null) { - configNbt.putString("worldMode", worldMode.toConfigValue()); - } - - if (gameRules != null) { - configNbt.put("gameRules", encodeGameRules(server, gameRules)); - } - - NbtIo.writeCompressed(configNbt, configFile.toPath()); - } catch (IOException e) { - LOGGER.error("Failed to save world configuration for '{}': {}", worldString, e.getMessage()); - } - } - - private static ChunkGenerator createGeneratorFromType(MinecraftServer server, String generatorType, CompoundTag configNbt) { - var biomeLookup = server.registryAccess().lookupOrThrow(Registries.BIOME); - - return switch (generatorType) { - case "nether" -> createVanillaNetherGenerator(server); - case "end" -> createVanillaEndGenerator(server); - case "flat" -> { - if (configNbt.contains("flatConfig")) { - try { - CompoundTag flatConfigNbt = configNbt.getCompound("flatConfig").orElseThrow(); - CompoundTag layersCompound = flatConfigNbt.getCompound("layers").orElseThrow(); - int layerCount = layersCompound.getInt("count").orElse(0); - - List layers = new ArrayList<>(); - for (int i = 0; i < layerCount; i++) { - CompoundTag layerNbt = layersCompound.getCompound("layer" + i).orElseThrow(); - int thickness = layerNbt.getInt("height").orElseThrow(); - String blockId = layerNbt.getString("block").orElseThrow(); - Identifier blockIdentifier = Identifier.tryParse(blockId); - if (blockIdentifier == null) { - throw new IllegalArgumentException("Invalid block identifier: " + blockId); - } - - Block block = server.registryAccess().lookupOrThrow(Registries.BLOCK).getOrThrow(ResourceKey.create(Registries.BLOCK, blockIdentifier)).value(); - layers.add(new FlatLayerInfo(thickness, block)); - } - - String biomeId = flatConfigNbt.getString("biome").orElse("minecraft:plains"); - Identifier biomeIdentifier = Identifier.tryParse(biomeId); - Holder biome = biomeLookup.getOrThrow(ResourceKey.create(Registries.BIOME, biomeIdentifier)); - - yield new FlatLevelSource( - new FlatLevelGeneratorSettings(Optional.empty(), biome, List.of()) - .withBiomeAndLayers(layers, Optional.empty(), biome) - ); - } catch (Exception e) { - LOGGER.error("Error reconstructing flat generator", e); - } - } - Holder plainsBiome = biomeLookup.getOrThrow(Biomes.PLAINS); - yield new FlatLevelSource( - new FlatLevelGeneratorSettings(Optional.empty(), plainsBiome, List.of()) - .withBiomeAndLayers( - List.of( - new FlatLayerInfo(1, Blocks.BEDROCK), - new FlatLayerInfo(61, Blocks.DIRT), - new FlatLayerInfo(1, Blocks.GRASS_BLOCK) - ), - Optional.empty(), - plainsBiome - ) - ); - } - default -> createVanillaOverworldGenerator(server); - }; - } - - private static ChunkGenerator createVanillaOverworldGenerator(MinecraftServer server) { - var multiNoiseLookup = server.registryAccess().lookupOrThrow(Registries.MULTI_NOISE_BIOME_SOURCE_PARAMETER_LIST); - var noiseSettingsLookup = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS); - return new NoiseBasedChunkGenerator( - MultiNoiseBiomeSource.createFromPreset(multiNoiseLookup.getOrThrow(MultiNoiseBiomeSourceParameterLists.OVERWORLD)), - noiseSettingsLookup.getOrThrow(NoiseGeneratorSettings.OVERWORLD) - ); - } - - private static ChunkGenerator createVanillaNetherGenerator(MinecraftServer server) { - var multiNoiseLookup = server.registryAccess().lookupOrThrow(Registries.MULTI_NOISE_BIOME_SOURCE_PARAMETER_LIST); - var noiseSettingsLookup = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS); - return new NoiseBasedChunkGenerator( - MultiNoiseBiomeSource.createFromPreset(multiNoiseLookup.getOrThrow(MultiNoiseBiomeSourceParameterLists.NETHER)), - noiseSettingsLookup.getOrThrow(NoiseGeneratorSettings.NETHER) - ); - } - - private static ChunkGenerator createVanillaEndGenerator(MinecraftServer server) { - var biomeLookup = server.registryAccess().lookupOrThrow(Registries.BIOME); - var noiseSettingsLookup = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS); - return new NoiseBasedChunkGenerator( - TheEndBiomeSource.create(biomeLookup), - noiseSettingsLookup.getOrThrow(NoiseGeneratorSettings.END) - ); - } - - public static boolean importWorld(MinecraftServer server, String worldFolderPath, String worldName) { - LOGGER.info("Importing world from {} to multiworld:{}", worldFolderPath, worldName); - - File sourceWorldFolder = new File(worldFolderPath); - if (!sourceWorldFolder.exists() || !sourceWorldFolder.isDirectory()) { - LOGGER.error("Source world folder {} does not exist or is not a directory", worldFolderPath); - return false; - } - - try { - sourceWorldFolder = resolveImportSourceWorldFolder(sourceWorldFolder); - File levelDatFile = new File(sourceWorldFolder, "level.dat"); - if (!levelDatFile.exists()) { - LOGGER.error("level.dat not found in {}", sourceWorldFolder.getAbsolutePath()); - return false; - } - - CompoundTag levelData = NbtIo.readCompressed(levelDatFile.toPath(), NbtAccounter.unlimitedHeap()); - levelData = DataFixTypes.LEVEL.updateToCurrentVersion( - server.getFixerUpper(), - levelData, - NbtUtils.getDataVersion(levelData, 0) - ); - NbtUtils.addCurrentDataVersion(levelData); - CompoundTag dataTag = levelData.getCompound("Data").orElseThrow(); - CompoundTag worldGenSettings = dataTag.getCompound("WorldGenSettings").orElseThrow(); - long seed = worldGenSettings.getLong("seed").orElseThrow(); - BlockPos importedSpawnPos = new BlockPos( - dataTag.getInt("SpawnX").orElse(0), - dataTag.getInt("SpawnY").orElse(63), - dataTag.getInt("SpawnZ").orElse(0) - ); - LOGGER.info("Seed from world: {}", seed); - - String serverWorldFolder = server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).toString(); - - ResourceKey overWorldKey = ResourceKey.create(Registries.DIMENSION, Identifier.fromNamespaceAndPath(NAMESPACE, worldName)); - ResourceKey netherWorldKey = ResourceKey.create(Registries.DIMENSION, Identifier.fromNamespaceAndPath(NAMESPACE, worldName + "_nether")); - ResourceKey endWorldKey = ResourceKey.create(Registries.DIMENSION, Identifier.fromNamespaceAndPath(NAMESPACE, worldName + "_end")); - - copyWorldFiles(sourceWorldFolder, serverWorldFolder, overWorldKey); - - File netherFolder = new File(sourceWorldFolder, "DIM-1"); - if (netherFolder.exists()) { - copyWorldFiles(netherFolder, serverWorldFolder, netherWorldKey); - } - - File endFolder = new File(sourceWorldFolder, "DIM1"); - if (endFolder.exists()) { - copyWorldFiles(endFolder, serverWorldFolder, endWorldKey); - } - - createWorldWithDimensions(server, worldName, seed, importedSpawnPos); - - ServerLevel overworld = server.getLevel(overWorldKey); - - if (overworld == null) { - LOGGER.error("Failed to create overworld dimension"); - return false; - } - - File multiworldFolder = new File(serverWorldFolder, NAMESPACE); - if (!multiworldFolder.exists() && !multiworldFolder.mkdirs()) { - LOGGER.error("Could not create multiworld directory"); - return false; - } - - File worldNameFolder = new File(multiworldFolder, worldName); - if (!worldNameFolder.exists() && !worldNameFolder.mkdirs()) { - LOGGER.error("Could not create world directory"); - return false; - } - - File playerDataFolder = new File(sourceWorldFolder, "playerdata"); - if (playerDataFolder.exists() && playerDataFolder.isDirectory()) { - File[] playerDataFiles = playerDataFolder.listFiles((dir, name) -> name.endsWith(".dat")); - if (playerDataFiles != null) { - for (File playerDataFile : playerDataFiles) { - try { - CompoundTag playerData = NbtIo.readCompressed(playerDataFile.toPath(), NbtAccounter.unlimitedHeap()); - - String uuid = playerDataFile.getName().replace(".dat", ""); - - playerData = Utils.migratePlayerDataToCurrentVersion( - server, - playerData, - "import/" + worldName + "/" + uuid - ); - - updateDimensionInTag(playerData, "respawn", worldName); - - String dimensionTagString = playerData.getString("Dimension").orElse(Level.OVERWORLD.identifier().toString()); - playerData.putString("Dimension", convertDimensionName(dimensionTagString, worldName)); - - updateDimensionInTag(playerData, "LastDeathLocation", worldName); - - JsonObject playerJson = new JsonObject(); - playerJson.addProperty("player", playerData.toString()); - - File statsFile = new File(sourceWorldFolder, "stats/" + uuid + ".json"); - if (statsFile.exists()) { - try (FileReader statsReader = new FileReader(statsFile)) { - JsonElement parsed = JsonParser.parseReader(statsReader); - if (parsed != null && parsed.isJsonObject()) { - playerJson.add("stats", parsed.getAsJsonObject()); - } - } - } - - File advancementsFile = new File(sourceWorldFolder, "advancements/" + uuid + ".json"); - if (advancementsFile.exists()) { - try (FileReader advancementsReader = new FileReader(advancementsFile)) { - JsonElement parsed = JsonParser.parseReader(advancementsReader); - if (parsed != null && parsed.isJsonObject()) { - playerJson.add("advancements", parsed.getAsJsonObject()); - } - } - } - - try (BufferedWriter writer = Files.newBufferedWriter( - Paths.get(worldNameFolder.getPath(), uuid + ".json"))) { - writer.write(playerJson.toString()); - } - - } catch (Exception e) { - LOGGER.error("Error processing player data: {}", e.getMessage()); - } - } - } - } - - LOGGER.info("World import completed successfully!"); - return true; - - } catch (Exception e) { - LOGGER.error("Error importing world", e); - return false; - } - } - - private static File resolveImportSourceWorldFolder(File sourceWorldFolder) { - if (new File(sourceWorldFolder, "level.dat").exists()) { - return sourceWorldFolder; - } - - File parent = sourceWorldFolder.getParentFile(); - if (parent != null && new File(parent, "level.dat").exists()) { - return parent; - } - - File nestedWorld = new File(sourceWorldFolder, "world"); - if (nestedWorld.isDirectory() && new File(nestedWorld, "level.dat").exists()) { - return nestedWorld; - } - - return sourceWorldFolder; - } - - private static void updateDimensionInTag(CompoundTag parentTag, String tagName, String worldName) { - Optional tagOpt = parentTag.getCompound(tagName); - if (tagOpt.isPresent()) { - CompoundTag tag = tagOpt.get(); - String dimension = tag.getString("dimension").orElse(Level.OVERWORLD.identifier().toString()); - tag.putString("dimension", convertDimensionName(dimension, worldName)); - parentTag.put(tagName, tag); - } - } - - private static String convertDimensionName(String originalDimension, String worldName) { - String newDimension; - if (originalDimension.endsWith("_nether")) { - newDimension = worldName + "_nether"; - } else if (originalDimension.endsWith("_end")) { - newDimension = worldName + "_end"; - } else { - newDimension = worldName; - } - return NAMESPACE + ":" + newDimension; - } - - private static void copyWorldFiles(File sourceFolder, String serverWorldFolder, ResourceKey worldKey) { - String worldDirectory = serverWorldFolder + "/dimensions/" + - worldKey.identifier().getNamespace() + "/" + worldKey.identifier().getPath(); - - copyFilesBeforeWorldCreation(sourceFolder, "region", worldDirectory); - copyFilesBeforeWorldCreation(sourceFolder, "poi", worldDirectory); - copyFilesBeforeWorldCreation(sourceFolder, "entities", worldDirectory); - copyFilesBeforeWorldCreation(sourceFolder, "data", worldDirectory); - } - - private static void copyFilesBeforeWorldCreation(File sourceFolder, String type, String destinationPath) { - File sourceTypeFolder = new File(sourceFolder, type); - if (!sourceTypeFolder.exists() || !sourceTypeFolder.isDirectory()) { - return; - } - - String fileExtension = type.equals("data") ? ".dat" : ".mca"; - File[] files = sourceTypeFolder.listFiles((dir, name) -> name.endsWith(fileExtension)); - if (files == null || files.length == 0) { - return; - } - - File destinationFolder = new File(destinationPath + "/" + type); - if (!destinationFolder.exists() && !destinationFolder.mkdirs()) { - LOGGER.error("Could not create destination folder: {}", destinationFolder); - return; - } - - for (File file : files) { - try { - Files.copy( - file.toPath(), - new File(destinationFolder, file.getName()).toPath(), - StandardCopyOption.REPLACE_EXISTING - ); - } catch (IOException e) { - LOGGER.error("Error copying file {}: {}", file.getName(), e.getMessage()); - } - } - - LOGGER.info("Copied {} files from {} to {}", files.length, type, destinationPath + "/" + type); - } - - public static void createWorldWithDimensions(MinecraftServer server, String worldString, long seed) { - createWorldWithDimensions(server, worldString, seed, null, "normal", WorldMode.NORMAL); - } - - public static void createWorldWithDimensions(MinecraftServer server, String worldString, long seed, BlockPos spawnPos) { - createWorldWithDimensions(server, worldString, seed, spawnPos, "normal", WorldMode.NORMAL); - } - - public static void createWorldWithDimensions( - MinecraftServer server, - String worldString, - long seed, - BlockPos spawnPos, - String preset, - WorldMode worldMode - ) { - ChunkGenerator overworldGenerator; - ChunkGenerator netherGenerator; - ChunkGenerator endGenerator; - - boolean flatPreset = "flat".equalsIgnoreCase(preset); - if (flatPreset) { - var biomeLookup = server.registryAccess().lookupOrThrow(Registries.BIOME); - Holder voidBiome = biomeLookup.getOrThrow(Biomes.THE_VOID); - - overworldGenerator = new FlatLevelSource( - new FlatLevelGeneratorSettings(Optional.empty(), voidBiome, List.of()) - .withBiomeAndLayers( - List.of( - new FlatLayerInfo(1, Blocks.BEDROCK), - new FlatLayerInfo(125, Blocks.DIRT), - new FlatLayerInfo(1, Blocks.GRASS_BLOCK) - ), - Optional.empty(), - voidBiome - ) - ); - netherGenerator = new FlatLevelSource( - new FlatLevelGeneratorSettings(Optional.empty(), voidBiome, List.of()) - .withBiomeAndLayers(List.of(), Optional.empty(), voidBiome) - ); - endGenerator = new FlatLevelSource( - new FlatLevelGeneratorSettings(Optional.empty(), voidBiome, List.of()) - .withBiomeAndLayers(List.of(), Optional.empty(), voidBiome) - ); - } else { - overworldGenerator = createVanillaOverworldGenerator(server); - netherGenerator = createVanillaNetherGenerator(server); - endGenerator = createVanillaEndGenerator(server); - } - - GameType gameMode = worldMode == WorldMode.CREATIVE ? GameType.CREATIVE : null; - BlockPos effectiveSpawnPos = worldMode == WorldMode.CREATIVE ? BlockPos.ZERO : spawnPos; - - createWorld(server, worldString, BuiltinDimensionTypes.OVERWORLD, overworldGenerator, seed, effectiveSpawnPos, gameMode, worldMode); - createWorld(server, worldString + "_nether", BuiltinDimensionTypes.NETHER, netherGenerator, seed, BlockPos.ZERO, gameMode, worldMode); - createWorld(server, worldString + "_end", BuiltinDimensionTypes.END, endGenerator, seed, BlockPos.ZERO, gameMode, worldMode); - registerWorldMode(worldString, worldMode); - } - - private static void createWorld(MinecraftServer server, String worldString, ResourceKey dimensionType, ChunkGenerator generator, BlockPos spawnPos, GameType gameMode) { - createWorld(server, worldString, dimensionType, generator, server.overworld().getSeed(), spawnPos, gameMode, gameMode == GameType.CREATIVE ? WorldMode.CREATIVE : WorldMode.NORMAL); - } - - private static void createWorld( - MinecraftServer server, - String worldString, - ResourceKey dimensionType, - ChunkGenerator generator, - long seed, - BlockPos spawnPos, - GameType gameMode, - WorldMode worldMode - ) { - ResourceKey worldKey = ResourceKey.create( - Registries.DIMENSION, - Identifier.fromNamespaceAndPath(NAMESPACE, worldString) - ); - - if (server.getLevel(worldKey) != null) { - ServerLevel world = server.getLevel(worldKey); - if (world instanceof CustomServerWorld) { - return; - } else { - for (ServerPlayer player : world.players()) { - Utils.saveInv(player, getBaseWorldName(worldKey.identifier().getPath())); - Utils.loadInv(player, "overworld"); - } - unload(server, world); - } - } - - Holder.Reference dimensionTypeEntry = server.registryAccess().lookupOrThrow(Registries.DIMENSION_TYPE).getOrThrow(dimensionType); - - LevelStem options = new LevelStem(dimensionTypeEntry, generator); - - RegistryAccess.Frozen registryManager = server.registries().compositeAccess(); - MappedRegistry dimensionsRegistry = (MappedRegistry) registryManager.lookupOrThrow(Registries.LEVEL_STEM); - - boolean isFrozen = ((CustomSimpleRegistry) dimensionsRegistry).multiWorld$isFrozen(); - ((CustomSimpleRegistry) dimensionsRegistry).multiWorld$setFrozen(false); - - ResourceKey dimensionKey = ResourceKey.create(Registries.LEVEL_STEM, worldKey.identifier()); - if (!dimensionsRegistry.containsKey(dimensionKey)) { - dimensionsRegistry.register(dimensionKey, options, RegistrationInfo.BUILT_IN); - } - - ((CustomSimpleRegistry) dimensionsRegistry).multiWorld$setFrozen(isFrozen); - - var levelProperties = server.getWorldData().overworldData(); - CustomServerWorldProperties newLevelProperties = new CustomServerWorldProperties(server.getWorldData(), levelProperties, seed); - - CustomServerWorld.Constructor worldConstructor = CustomServerWorld::new; - CustomServerWorld addedWorld = worldConstructor.create(server, worldKey, options, newLevelProperties, seed); - - if (gameMode != null) { - ((ServerLevelAccessor) addedWorld).getWorldProperties().setGameType(gameMode); - } - - // Set up dragon fight for custom end worlds - if (dimensionType == BuiltinDimensionTypes.END) { - ((ServerLevelAccessor) addedWorld).setDragonFightAccessor(new net.minecraft.world.level.dimension.end.EndDragonFight( - addedWorld, seed, net.minecraft.world.level.dimension.end.EndDragonFight.Data.DEFAULT)); - } - - ((IMinecraftServerMixin) server).getWorlds().put(addedWorld.dimension(), addedWorld); - - BlockPos resolvedSpawn; - if (spawnPos != null) { - // Keep configured X/Z but place Y on a valid surface for reliable respawn. - int surfaceY = addedWorld.getHeight(Heightmap.Types.MOTION_BLOCKING, spawnPos.getX(), spawnPos.getZ()); - int safeY = surfaceY > addedWorld.getMinY() ? surfaceY : 63; - resolvedSpawn = new BlockPos(spawnPos.getX(), safeY, spawnPos.getZ()); - } else { - // Use the exact vanilla initial spawn pipeline. - IMinecraftServerMixin.invokeSetupSpawn( - addedWorld, - newLevelProperties, - false, - false, - ((IMinecraftServerMixin) server).multiWorld$getLevelLoadListener() - ); - LevelData.RespawnData respawnData = newLevelProperties.getRespawnData(); - if (respawnData != null) { - resolvedSpawn = respawnData.pos(); - } else { - resolvedSpawn = BlockPos.ZERO.above(80); - } - } - ServerWorldEvents.LOAD.invoker().onWorldLoad(server, addedWorld); - - newLevelProperties.setSpawn(new LevelData.RespawnData(new GlobalPos(worldKey, resolvedSpawn), 0.0F, 0.0F)); - - saveWorldConfig( - server, - worldString, - dimensionType, - generator, - generator instanceof FlatLevelSource ? "flat" : worldString.endsWith("_nether") ? "nether" : worldString.endsWith("_end") ? "end" : "overworld", - seed, - spawnPos, - gameMode, - worldMode, - newLevelProperties.getGameRules() - ); - - LOGGER.info("Successfully loaded world '{}'", worldString); - } - - private static void unload(MinecraftServer server, ServerLevel world) { - ResourceKey dimensionKey = world.dimension(); - - if (((IMinecraftServerMixin) server).getWorlds().remove(dimensionKey, world)) { - world.save(new ProgressListener() { - @Override - public void progressStartNoAbort(Component title) {} - - @Override - public void progressStart(Component title) {} - - @Override - public void progressStage(Component task) {} - - @Override - public void progressStagePercentage(int percentage) {} - - @Override - public void stop() {} - }, true, false); - - ServerWorldEvents.UNLOAD.invoker().onWorldUnload(server, world); - - RegistryAccess.Frozen registryManager = server.registries().compositeAccess(); - MappedRegistry dimensionsRegistry = (MappedRegistry) registryManager.lookupOrThrow(Registries.LEVEL_STEM); - CustomSimpleRegistry.remove(dimensionsRegistry, dimensionKey.identifier()); - } - } - - public static void deleteWorld(MinecraftServer server, String worldName) { - LOGGER.info("Deleting world: {}", worldName); - registerWorldMode(worldName, WorldMode.NORMAL); - - ResourceKey overWorldKey = ResourceKey.create(Registries.DIMENSION, Identifier.fromNamespaceAndPath(NAMESPACE, worldName)); - ResourceKey netherWorldKey = ResourceKey.create(Registries.DIMENSION, Identifier.fromNamespaceAndPath(NAMESPACE, worldName + "_nether")); - ResourceKey endWorldKey = ResourceKey.create(Registries.DIMENSION, Identifier.fromNamespaceAndPath(NAMESPACE, worldName + "_end")); - - ServerLevel overworld = server.getLevel(overWorldKey); - ServerLevel nether = server.getLevel(netherWorldKey); - ServerLevel end = server.getLevel(endWorldKey); - - if (overworld != null) { - ServerWorldEvents.UNLOAD.invoker().onWorldUnload(server, overworld); - } - if (nether != null) { - ServerWorldEvents.UNLOAD.invoker().onWorldUnload(server, nether); - } - if (end != null) { - ServerWorldEvents.UNLOAD.invoker().onWorldUnload(server, end); - } - - RegistryAccess.Frozen registryManager = server.registries().compositeAccess(); - MappedRegistry dimensionsRegistry = (MappedRegistry) registryManager.lookupOrThrow(Registries.LEVEL_STEM); - - if (overworld != null) { - CustomSimpleRegistry.remove(dimensionsRegistry, overWorldKey.identifier()); - } - if (nether != null) { - CustomSimpleRegistry.remove(dimensionsRegistry, netherWorldKey.identifier()); - } - if (end != null) { - CustomSimpleRegistry.remove(dimensionsRegistry, endWorldKey.identifier()); - } - - String serverWorldFolder = server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).toString(); - - if (overworld != null) { - deleteWorldFiles(serverWorldFolder, overWorldKey); - ((IMinecraftServerMixin) server).getWorlds().remove(overWorldKey); - } - if (nether != null) { - deleteWorldFiles(serverWorldFolder, netherWorldKey); - ((IMinecraftServerMixin) server).getWorlds().remove(netherWorldKey); - } - if (end != null) { - deleteWorldFiles(serverWorldFolder, endWorldKey); - ((IMinecraftServerMixin) server).getWorlds().remove(endWorldKey); - } - - File multiworldFolder = new File(serverWorldFolder, NAMESPACE); - File worldNameFolder = new File(multiworldFolder, worldName); - if (worldNameFolder.exists()) { - deleteFolder(worldNameFolder); - } - - LOGGER.info("World deletion completed: {}", worldName); - } - - private static void deleteWorldFiles(String serverWorldFolder, ResourceKey worldKey) { - String worldPath = serverWorldFolder + "/dimensions/" + - worldKey.identifier().getNamespace() + "/" + worldKey.identifier().getPath(); - File worldFolder = new File(worldPath); - - if (worldFolder.exists()) { - deleteFolder(worldFolder); - } - } - - private static void deleteFolder(File folder) { - File[] files = folder.listFiles(); - if (files != null) { - for (File file : files) { - if (file.isDirectory()) { - deleteFolder(file); - } else { - file.delete(); - } - } - } - folder.delete(); - } - - private static CompoundTag encodeGameRules(MinecraftServer server, GameRules gameRules) { - Object encoded = GameRules.codec(server.getWorldData().enabledFeatures()) - .encodeStart(NbtOps.INSTANCE, gameRules) - .result() - .orElse(null); - if (encoded instanceof CompoundTag compoundTag) { - return compoundTag; - } - return new CompoundTag(); - } - - private static GameRules decodeGameRules(MinecraftServer server, CompoundTag gameRulesTag) { - return GameRules.codec(server.getWorldData().enabledFeatures()) - .parse(NbtOps.INSTANCE, gameRulesTag) - .result() - .orElseGet(() -> new GameRules(server.getWorldData().enabledFeatures())); - } - - private static void autosaveCustomWorldGameRules(MinecraftServer server) { - if (--gameRuleAutosaveCooldown > 0) { - return; - } - gameRuleAutosaveCooldown = GAME_RULE_AUTOSAVE_INTERVAL_TICKS; - persistCustomWorldGameRules(server, false); - } - - private static void persistCustomWorldGameRules(MinecraftServer server, boolean force) { - for (ServerLevel world : server.getAllLevels()) { - if (!NAMESPACE.equals(world.dimension().identifier().getNamespace())) { - continue; - } - - ResourceKey worldKey = world.dimension(); - CompoundTag encodedRules = encodeGameRules(server, ((ServerLevelAccessor) world).getWorldProperties().getGameRules()); - String currentSnapshot = encodedRules.toString(); - String previousSnapshot = LAST_SAVED_GAMERULE_SNAPSHOTS.get(worldKey); - if (!force && currentSnapshot.equals(previousSnapshot)) { - continue; - } - - String worldName = world.dimension().identifier().getPath(); - String serverWorldFolder = server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).toString(); - File configFile = new File(serverWorldFolder + "/dimensions/" + NAMESPACE + "/" + worldName + "/data/" + CONFIG_FILE_NAME); - if (!configFile.exists()) { - continue; - } - - try { - CompoundTag configNbt = NbtIo.readCompressed(configFile.toPath(), NbtAccounter.unlimitedHeap()); - configNbt.put("gameRules", encodedRules); - NbtIo.writeCompressed(configNbt, configFile.toPath()); - LAST_SAVED_GAMERULE_SNAPSHOTS.put(worldKey, currentSnapshot); - } catch (Exception e) { - LOGGER.warn("Failed to persist gamerules for world '{}': {}", worldName, e.getMessage()); - } - } - } -} diff --git a/src/main/java/com/gmail/anthony17j/multiworld/Utils.java b/src/main/java/com/gmail/anthony17j/multiworld/Utils.java deleted file mode 100644 index 248b191..0000000 --- a/src/main/java/com/gmail/anthony17j/multiworld/Utils.java +++ /dev/null @@ -1,300 +0,0 @@ -package com.gmail.anthony17j.multiworld; - -import com.gmail.anthony17j.multiworld.mixin.ServerLevelAccessor; -import com.gmail.anthony17j.multiworld.mixin.StatsCounterAccessor; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import net.minecraft.SharedConstants; -import net.minecraft.core.BlockPos; -import net.minecraft.core.GlobalPos; -import net.minecraft.core.registries.Registries; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.NbtUtils; -import net.minecraft.nbt.TagParser; -import net.minecraft.resources.Identifier; -import net.minecraft.resources.ResourceKey; -import net.minecraft.server.MinecraftServer; -import net.minecraft.server.level.ServerLevel; -import net.minecraft.server.level.ServerPlayer; -import net.minecraft.stats.ServerStatsCounter; -import net.minecraft.stats.Stat; -import net.minecraft.util.ProblemReporter; -import net.minecraft.util.datafix.DataFixTypes; -import net.minecraft.world.entity.Entity; -import net.minecraft.world.level.GameType; -import net.minecraft.world.level.Level; -import net.minecraft.world.level.portal.TeleportTransition; -import net.minecraft.world.level.storage.LevelData; -import net.minecraft.world.level.storage.LevelResource; -import net.minecraft.world.level.storage.TagValueInput; -import net.minecraft.world.level.storage.TagValueOutput; -import net.minecraft.world.level.storage.ValueInput; -import net.minecraft.world.phys.Vec3; - -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileReader; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.Optional; - -import static com.gmail.anthony17j.multiworld.MultiWorld.LOGGER; -import static com.gmail.anthony17j.multiworld.MultiWorld.NAMESPACE; - -public class Utils { - private static String getWorldFolder(MinecraftServer server) { - return server.getWorldPath(LevelResource.ROOT).toString(); - } - - public static CompoundTag migratePlayerDataToCurrentVersion(MinecraftServer server, CompoundTag playerNbt, String context) { - int sourceVersion = NbtUtils.getDataVersion(playerNbt, 0); - int currentVersion = SharedConstants.getCurrentVersion().dataVersion().version(); - if (sourceVersion >= currentVersion) { - return playerNbt; - } - - try { - CompoundTag fixed = DataFixTypes.PLAYER.updateToCurrentVersion(server.getFixerUpper(), playerNbt, sourceVersion); - NbtUtils.addCurrentDataVersion(fixed); - LOGGER.info("Migrated player data '{}' from DataVersion {} to {}", context, sourceVersion, currentVersion); - return fixed; - } catch (Exception ex) { - LOGGER.error("Error migrating player data '{}' from DataVersion {}: {}", context, sourceVersion, ex.getMessage()); - return playerNbt; - } - } - - public static void saveInv(ServerPlayer player, String world) { - try { - String worldFolder = getWorldFolder(player.level().getServer()); - - if (new File(worldFolder + "/" + NAMESPACE + "/" + world).mkdirs()) { - LOGGER.info("Directory created: {}/{}/{}", worldFolder, NAMESPACE, world); - } - BufferedWriter writer = Files.newBufferedWriter(Paths.get(worldFolder + "/" + NAMESPACE + "/" + world + "/" + player.getStringUUID() + ".json")); - - TagValueOutput output = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, player.registryAccess()); - player.saveWithoutId(output); - - // Remove ender pearls the player had launched (they will be put back on load) - player.getEnderPearls().forEach((enderPearl) -> enderPearl.remove(Entity.RemovalReason.DISCARDED)); - - // Remove player vehicle (will be put back on load) - if (player.getRootVehicle() != player) { - player.getRootVehicle().remove(Entity.RemovalReason.DISCARDED); - } - - CompoundTag tag = output.buildResult(); - - // add respawn position to the tag - CompoundTag respawnTag = new CompoundTag(); - ServerPlayer.RespawnConfig respawn = player.getRespawnConfig(); - if (respawn != null) { - respawnTag.putString("dimension", respawn.respawnData().dimension().identifier().toString()); - respawnTag.putBoolean("forced", respawn.forced()); - respawnTag.putFloat("angle", respawn.respawnData().yaw()); - respawnTag.putFloat("pitch", respawn.respawnData().pitch()); - respawnTag.putIntArray("pos", new int[]{respawn.respawnData().pos().getX(), respawn.respawnData().pos().getY(), respawn.respawnData().pos().getZ()}); - tag.put("respawn", respawnTag); - } - - // Save player stats - player.getStats().save(); - JsonObject statsJson = new JsonObject(); - File statsFolder = new File(worldFolder + "/stats"); - File[] statsFiles = statsFolder.listFiles(); - if (statsFiles != null) { - for (File statsFile : statsFiles) { - if (statsFile.getName().equals(player.getStringUUID() + ".json")) { - try (FileReader statsReader = new FileReader(statsFile)) { - JsonElement parsed = JsonParser.parseReader(statsReader); - if (parsed != null && parsed.isJsonObject()) { - statsJson = parsed.getAsJsonObject(); - } - } - } - } - } - - // Save player advancements - player.getAdvancements().save(); - JsonObject advancementsJson = new JsonObject(); - File advancementsFolder = new File(worldFolder + "/advancements"); - File[] advancementsFiles = advancementsFolder.listFiles(); - if (advancementsFiles != null) { - for (File advancementsFile : advancementsFiles) { - if (advancementsFile.getName().equals(player.getStringUUID() + ".json")) { - try (FileReader advancementsReader = new FileReader(advancementsFile)) { - JsonElement parsed = JsonParser.parseReader(advancementsReader); - if (parsed != null && parsed.isJsonObject()) { - advancementsJson = parsed.getAsJsonObject(); - } - } - } - } - } - - JsonObject playerJson = new JsonObject(); - playerJson.addProperty("player", tag.toString()); - playerJson.add("stats", statsJson); - playerJson.add("advancements", advancementsJson); - writer.write(playerJson.toString()); - writer.close(); - - } catch (Exception ex) { - LOGGER.error("Error saving player data: {}", ex.getMessage()); - } - } - - public static void loadInv(ServerPlayer player, String world) { - String worldFolder = getWorldFolder(player.level().getServer()); - - try (FileReader fileReader = new FileReader(worldFolder + "/" + NAMESPACE + "/" + world + "/" + player.getStringUUID() + ".json")) { - JsonElement parsedRoot = JsonParser.parseReader(fileReader); - JsonObject file = parsedRoot != null && parsedRoot.isJsonObject() ? parsedRoot.getAsJsonObject() : new JsonObject(); - - String playerString = file.has("player") ? file.get("player").getAsString() : "{}"; - CompoundTag playerNbt = TagParser.parseCompoundFully(playerString); - playerNbt = migratePlayerDataToCurrentVersion( - player.level().getServer(), - playerNbt, - "loadInv/" + world + "/" + player.getStringUUID() - ); - - ValueInput nbtReadView = TagValueInput.create(ProblemReporter.DISCARDING, player.registryAccess(), playerNbt); - player.load(nbtReadView); - player.loadAndSpawnEnderPearls(nbtReadView); - - String[] strArr = playerNbt.getString("Dimension").orElse(Level.OVERWORLD.identifier().toString()).split(":"); - ResourceKey key = ResourceKey.create(Registries.DIMENSION, Identifier.fromNamespaceAndPath(strArr[0], strArr[1])); - ServerLevel dimension = player.level().getServer().getLevel(key); - - // Load respawn position - Optional respawnTag = playerNbt.getCompound("respawn"); - if (respawnTag.isPresent()) { - CompoundTag respawnData = respawnTag.get(); - int[] respawnPos = respawnData.getIntArray("pos").orElseThrow(); - String respawnDimensionStr = respawnData.getString("dimension").orElse(Level.OVERWORLD.identifier().toString()); - String[] dimParts = respawnDimensionStr.split(":"); - ResourceKey respawnDimension = ResourceKey.create(Registries.DIMENSION, Identifier.fromNamespaceAndPath(dimParts[0], dimParts[1])); - LevelData.RespawnData respawnDataObj = new LevelData.RespawnData( - new GlobalPos(respawnDimension, new BlockPos(respawnPos[0], respawnPos[1], respawnPos[2])), - respawnData.getFloat("angle").orElse(0F), - respawnData.getFloat("pitch").orElse(0F) - ); - player.setRespawnPosition(new ServerPlayer.RespawnConfig(respawnDataObj, respawnData.getBooleanOr("forced", false)), false); - } - - // Load player stats - if (file.has("stats") && file.get("stats").isJsonObject()) { - JsonObject statsData = file.getAsJsonObject("stats"); - - ServerStatsCounter statHandler = player.getStats(); - for (Stat stat : ((StatsCounterAccessor) statHandler).getStatMap().keySet()) { - player.resetStat(stat); - } - - statHandler.parse(player.level().getServer().getFixerUpper(), statsData); - statHandler.markAllDirty(); - } - - // Load player advancements - if (file.has("advancements") && file.get("advancements").isJsonObject()) { - JsonObject advancementsData = file.getAsJsonObject("advancements"); - Files.write( - Paths.get(worldFolder + "/advancements/" + player.getStringUUID() + ".json"), - advancementsData.toString().getBytes() - ); - } - player.getAdvancements().reload(player.level().getServer().getAdvancements()); - - if (dimension != null) { - player.teleport(new TeleportTransition(dimension, new Vec3(player.getX(), player.getY(), player.getZ()), Vec3.ZERO, player.getYRot(), player.getXRot(), TeleportTransition.DO_NOTHING)); - } - - player.loadAndSpawnParentVehicle(nbtReadView); - } catch (Exception ex) { - CompoundTag playerNbt = new CompoundTag(); - - ValueInput nbtReadView = TagValueInput.create(ProblemReporter.DISCARDING, player.registryAccess(), playerNbt); - player.load(nbtReadView); - player.loadAndSpawnEnderPearls(nbtReadView); - - player.setRespawnPosition(null, false); - - // Reset stats - ServerStatsCounter statHandler = player.getStats(); - for (Stat stat : ((StatsCounterAccessor) statHandler).getStatMap().keySet()) { - player.resetStat(stat); - } - statHandler.markAllDirty(); - - // Reset advancements - JsonObject clearedAdvancementsData = new JsonObject(); - JsonObject advancementsData = new JsonObject(); - try (FileReader advancementsReader = new FileReader(worldFolder + "/advancements/" + player.getStringUUID() + ".json")) { - JsonElement parsed = JsonParser.parseReader(advancementsReader); - if (parsed != null && parsed.isJsonObject()) { - advancementsData = parsed.getAsJsonObject(); - } - } catch (Exception e) { - LOGGER.error("Error loading player advancements: {}", e.getMessage()); - } - if (advancementsData.has("DataVersion")) { - clearedAdvancementsData.add("DataVersion", advancementsData.get("DataVersion")); - } - try (BufferedWriter advancementsWriter = Files.newBufferedWriter(Paths.get(worldFolder + "/advancements/" + player.getStringUUID() + ".json"))) { - advancementsWriter.write(clearedAdvancementsData.toString()); - } catch (Exception e) { - LOGGER.error("Error writing cleared advancements: {}", e.getMessage()); - } - player.getAdvancements().reload(player.level().getServer().getAdvancements()); - - // Teleport to world spawn - String dimensionStr = world.equals("overworld") ? "minecraft:overworld" : NAMESPACE + ":" + world; - String[] strArr = dimensionStr.split(":"); - ResourceKey key = ResourceKey.create(Registries.DIMENSION, Identifier.fromNamespaceAndPath(strArr[0], strArr[1])); - - MinecraftServer server = player.level().getServer(); - ServerLevel serverWorld = server.getLevel(key); - - if (serverWorld != null) { - BlockPos spawnPos = serverWorld.getRespawnData().pos(); - Vec3 worldSpawn = Vec3.atBottomCenterOf(spawnPos); - player.teleport(new TeleportTransition(serverWorld, worldSpawn, Vec3.ZERO, 0.0F, 0.0F, TeleportTransition.DO_NOTHING)); - - // Do it twice to prevent a bug where the player must log out and back in to change gamemode - GameType gameMode = ((ServerLevelAccessor) serverWorld).getWorldProperties().getGameType(); - player.setGameMode(gameMode == GameType.CREATIVE ? GameType.SURVIVAL : GameType.CREATIVE); - player.setGameMode(gameMode); - } - } - } - - public static String getMostRecentWorldSaved(ServerPlayer player, String sourceWorld) { - String worldFolder = getWorldFolder(player.level().getServer()); - File folder = new File(worldFolder + "/" + NAMESPACE); - File[] listOfFiles = folder.listFiles(); - if (listOfFiles != null) { - String mostRecentWorld = ""; - long mostRecentTime = 0; - for (File file : listOfFiles) { - if (file.isDirectory()) { - File playerFile = new File(file.getPath() + "/" + player.getUUID() + ".json"); - if (playerFile.exists()) { - long lastModified = playerFile.lastModified(); - if (lastModified > mostRecentTime && !file.getName().equals(sourceWorld)) { - mostRecentTime = lastModified; - mostRecentWorld = file.getName(); - } - } - } - } - return mostRecentWorld; - } else { - LOGGER.error("No worlds found in namespace folder"); - return "overworld"; - } - } -} diff --git a/src/main/java/com/gmail/anthony17j/multiworld/command/MultiWorldCommand.java b/src/main/java/com/gmail/anthony17j/multiworld/command/MultiWorldCommand.java deleted file mode 100644 index 8e60ec4..0000000 --- a/src/main/java/com/gmail/anthony17j/multiworld/command/MultiWorldCommand.java +++ /dev/null @@ -1,295 +0,0 @@ -package com.gmail.anthony17j.multiworld.command; - -import com.gmail.anthony17j.multiworld.*; -import com.mojang.brigadier.CommandDispatcher; -import com.mojang.brigadier.arguments.LongArgumentType; -import com.mojang.brigadier.arguments.StringArgumentType; -import com.mojang.brigadier.builder.LiteralArgumentBuilder; -import com.mojang.brigadier.suggestion.Suggestions; -import com.mojang.brigadier.suggestion.SuggestionsBuilder; -import net.minecraft.commands.CommandBuildContext; -import net.minecraft.commands.CommandSourceStack; -import net.minecraft.commands.Commands; -import net.minecraft.commands.SharedSuggestionProvider; -import net.minecraft.core.registries.Registries; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; -import net.minecraft.resources.ResourceKey; -import net.minecraft.server.level.ServerLevel; -import net.minecraft.server.level.ServerPlayer; -import net.minecraft.world.level.Level; -import org.jetbrains.annotations.NotNull; - -import java.util.Random; -import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; - -import static com.gmail.anthony17j.multiworld.CustomServerWorld.getBaseWorldName; -import static com.gmail.anthony17j.multiworld.MultiWorld.*; -import static net.minecraft.commands.Commands.literal; - -public final class MultiWorldCommand { - public static void register(CommandDispatcher dispatcher, CommandBuildContext registryAccess, Commands.CommandSelection environment) { - LiteralArgumentBuilder rootCommand = literal("mw") - .executes(ctx -> { - showHelp(ctx.getSource()); - return 1; - }); - - rootCommand.then(literal("tp") - .then(Commands.argument("world", StringArgumentType.word()) - .suggests((ctx, builder) -> SharedSuggestionProvider.suggest(getAvailableWorlds(ctx.getSource()), builder)) - .executes(ctx -> { - String worldName = StringArgumentType.getString(ctx, "world"); - return teleportCommand(ctx.getSource(), worldName); - }) - ) - ); - - rootCommand.then(literal("import") - .requires(Commands.hasPermission(Commands.LEVEL_OWNERS)) - .then(Commands.argument("folderPath", StringArgumentType.string()) - .then(Commands.argument("worldName", StringArgumentType.word()) - .executes(ctx -> { - String folderPath = StringArgumentType.getString(ctx, "folderPath"); - String worldName = StringArgumentType.getString(ctx, "worldName"); - return importCommand(ctx.getSource(), folderPath, worldName); - }) - ) - ) - ); - - rootCommand.then(literal("create") - .requires(Commands.hasPermission(Commands.LEVEL_OWNERS)) - .then(Commands.argument("worldName", StringArgumentType.word()) - .executes(ctx -> { - String worldName = StringArgumentType.getString(ctx, "worldName"); - long seed = new Random().nextLong(); - return createCommand(ctx.getSource(), worldName, "normal", "normal", seed); - }) - .then(Commands.argument("preset", StringArgumentType.word()) - .suggests((ctx, builder) -> suggestPresets(builder)) - .executes(ctx -> { - String worldName = StringArgumentType.getString(ctx, "worldName"); - String preset = StringArgumentType.getString(ctx, "preset"); - long seed = new Random().nextLong(); - return createCommand(ctx.getSource(), worldName, preset, "normal", seed); - }) - .then(Commands.argument("mode", StringArgumentType.word()) - .suggests((ctx, builder) -> suggestModes(builder)) - .executes(ctx -> { - String worldName = StringArgumentType.getString(ctx, "worldName"); - String preset = StringArgumentType.getString(ctx, "preset"); - String mode = StringArgumentType.getString(ctx, "mode"); - long seed = new Random().nextLong(); - return createCommand(ctx.getSource(), worldName, preset, mode, seed); - }) - .then(Commands.argument("seed", LongArgumentType.longArg()) - .executes(ctx -> { - String worldName = StringArgumentType.getString(ctx, "worldName"); - String preset = StringArgumentType.getString(ctx, "preset"); - String mode = StringArgumentType.getString(ctx, "mode"); - long seed = LongArgumentType.getLong(ctx, "seed"); - return createCommand(ctx.getSource(), worldName, preset, mode, seed); - }) - ) - .then(Commands.argument("seedText", StringArgumentType.word()) - .executes(ctx -> { - String worldName = StringArgumentType.getString(ctx, "worldName"); - String preset = StringArgumentType.getString(ctx, "preset"); - String mode = StringArgumentType.getString(ctx, "mode"); - long seed = parseSeed(StringArgumentType.getString(ctx, "seedText")); - return createCommand(ctx.getSource(), worldName, preset, mode, seed); - }) - ) - ) - ) - ) - ); - - rootCommand.then(literal("delete") - .requires(Commands.hasPermission(Commands.LEVEL_OWNERS)) - .then(Commands.argument("worldName", StringArgumentType.word()) - .suggests((ctx, builder) -> SharedSuggestionProvider.suggest( - getAvailableCustomWorlds(ctx.getSource()), - builder - )) - .executes(ctx -> { - String worldName = StringArgumentType.getString(ctx, "worldName"); - return deleteCommand(ctx.getSource(), worldName); - }) - ) - ); - - dispatcher.register(rootCommand); - } - - private static void showHelp(CommandSourceStack source) { - boolean isAdmin = Commands.hasPermission(Commands.LEVEL_OWNERS).test(source); - - source.sendSystemMessage(Component.literal("§6MultiWorld Commands:")); - source.sendSystemMessage(Component.literal("§7/mw tp §8- §7Teleport to a world")); - - if (isAdmin) { - source.sendSystemMessage(Component.literal("§7/mw import §8- §7Import world from folder")); - source.sendSystemMessage(Component.literal("§7/mw create [preset] [mode] [seed] §8- §7Create a new world")); - source.sendSystemMessage(Component.literal("§7/mw delete §8- §7Delete a world")); - } - } - - private static java.util.List getAvailableWorlds(CommandSourceStack source) { - return source.levels().stream() - .map(ResourceKey::identifier) - .filter(id -> id.getNamespace().equals(NAMESPACE) || id.getNamespace().equals("minecraft")) - .filter(id -> !id.getPath().endsWith("_nether") && !id.getPath().endsWith("_end")) - .map(Identifier::getPath) - .filter(path -> !path.equals(getBaseWorldName(getSourceWorldName(source)))) - .collect(Collectors.toList()); - } - - private static java.util.List getAvailableCustomWorlds(CommandSourceStack source) { - return source.levels().stream() - .map(ResourceKey::identifier) - .filter(id -> id.getNamespace().equals(NAMESPACE)) - .filter(id -> !id.getPath().endsWith("_nether") && !id.getPath().endsWith("_end")) - .map(Identifier::getPath) - .collect(Collectors.toList()); - } - - private static CompletableFuture suggestPresets(SuggestionsBuilder builder) { - return SharedSuggestionProvider.suggest(new String[]{"normal", "flat"}, builder); - } - - private static CompletableFuture suggestModes(SuggestionsBuilder builder) { - return SharedSuggestionProvider.suggest(new String[]{"normal", "creative"}, builder); - } - - private static long parseSeed(String value) { - try { - return Long.parseLong(value); - } catch (NumberFormatException ignored) { - return (long) value.hashCode(); - } - } - - private static int teleportCommand(CommandSourceStack source, String worldString) { - worldString = getBaseWorldName(worldString); - ServerLevel worldTarget = source.getServer().overworld(); - if (!worldString.equals("overworld")) { - ResourceKey worldKey = ResourceKey.create( - Registries.DIMENSION, - Identifier.fromNamespaceAndPath(NAMESPACE, worldString) - ); - worldTarget = source.getServer().getLevel(worldKey); - } - - if (worldTarget == null) { - source.sendFailure(Component.literal("World " + worldString + " not found!")); - return 0; - } - - String sourceWorldName = getSourceWorldName(source); - String targetWorldName = worldTarget.dimension().identifier().getPath(); - - if (sourceWorldName.equals(targetWorldName)) { - source.sendFailure(Component.literal("You are already in this world!")); - return 0; - } - - ServerPlayer player = source.getPlayer(); - - Utils.saveInv(player, sourceWorldName); - Utils.loadInv(player, targetWorldName); - - player.sendSystemMessage(Component.literal("Teleported to " + worldTarget.dimension().identifier() + " !")); - - return 1; - } - - private static int importCommand(CommandSourceStack source, String folderPath, String worldName) { - source.sendSystemMessage(Component.literal("Importing world from " + folderPath + " to " + worldName + "...")); - - try { - if (!importWorld(source.getServer(), folderPath, worldName)) { - source.sendFailure(Component.literal("Error importing world: invalid source folder or missing level.dat")); - return 0; - } - source.sendSuccess(() -> Component.literal("World imported successfully!"), false); - return 1; - } catch (Exception e) { - source.sendFailure(Component.literal("Error importing world: " + e.getMessage())); - LOGGER.error("Error importing world", e); - return 0; - } - } - - private static int createCommand(CommandSourceStack source, String worldName, String preset, String mode, long seed) { - ResourceKey worldKey = ResourceKey.create(Registries.DIMENSION, Identifier.fromNamespaceAndPath(NAMESPACE, worldName)); - if (source.getServer().getLevel(worldKey) != null) { - source.sendFailure(Component.literal("§cA world with this name already exists!")); - return 0; - } - - String normalizedPreset = preset.toLowerCase(); - if (!normalizedPreset.equals("normal") && !normalizedPreset.equals("flat")) { - source.sendFailure(Component.literal("§cInvalid preset. Allowed values: normal, flat")); - return 0; - } - - String normalizedMode = mode.toLowerCase(); - if (!normalizedMode.equals("normal") && !normalizedMode.equals("creative")) { - source.sendFailure(Component.literal("§cInvalid mode. Allowed values: normal, creative")); - return 0; - } - - MultiWorld.WorldMode worldMode = MultiWorld.WorldMode.fromString(normalizedMode); - - source.sendSystemMessage(Component.literal("Creating new world '" + worldName + "' with seed: " + seed + " (preset=" + normalizedPreset + ", mode=" + normalizedMode + ")")); - - try { - createWorldWithDimensions(source.getServer(), worldName, seed, null, normalizedPreset, worldMode); - source.sendSuccess(() -> Component.literal("§aWorld created successfully! Use §6/mw tp " + worldName + "§a to teleport to it."), false); - return 1; - } catch (Exception e) { - source.sendFailure(Component.literal("§cError creating world: " + e.getMessage())); - LOGGER.error("Error creating world", e); - return 0; - } - } - - private static int deleteCommand(CommandSourceStack source, String worldName) { - ResourceKey worldKey = ResourceKey.create(Registries.DIMENSION, Identifier.fromNamespaceAndPath(NAMESPACE, worldName)); - if (source.getServer().getLevel(worldKey) == null) { - source.sendFailure(Component.literal("§cWorld '" + worldName + "' not found!")); - return 0; - } - - for (ServerPlayer player : source.getServer().getPlayerList().getPlayers()) { - String currentWorld = getBaseWorldName(player.level().dimension().identifier().getPath()); - if (currentWorld.equals(worldName)) { - Utils.saveInv(player, currentWorld); - Utils.loadInv(player, "overworld"); - player.sendSystemMessage(Component.literal("§cWorld being deleted! You've been teleported to the overworld.")); - } - } - - source.sendSystemMessage(Component.literal("§eDeleting world '" + worldName + "'...")); - - try { - deleteWorld(source.getServer(), worldName); - source.sendSuccess(() -> Component.literal("§aWorld '" + worldName + "' has been successfully deleted."), false); - return 1; - } catch (Exception e) { - source.sendFailure(Component.literal("§cError deleting world: " + e.getMessage())); - LOGGER.error("Error deleting world", e); - return 0; - } - } - - public static @NotNull String getSourceWorldName(CommandSourceStack source) { - ServerLevel sourceWorld = source.getLevel(); - ResourceKey sourceWorldKey = sourceWorld.dimension() == Level.END || sourceWorld.dimension() == Level.NETHER ? Level.OVERWORLD : sourceWorld.dimension(); - String sourceWorldName = sourceWorldKey.identifier().getPath(); - return getBaseWorldName(sourceWorldName); - } -} diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/LegacyStructureDataHandlerMixin.java b/src/main/java/com/gmail/anthony17j/multiworld/mixin/LegacyStructureDataHandlerMixin.java deleted file mode 100644 index 8d87b72..0000000 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/LegacyStructureDataHandlerMixin.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.gmail.anthony17j.multiworld.mixin; - -import net.minecraft.resources.ResourceKey; -import net.minecraft.world.level.Level; -import net.minecraft.world.level.levelgen.structure.LegacyStructureDataHandler; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.ModifyVariable; - -import static com.gmail.anthony17j.multiworld.CustomServerWorld.getMockRegistryKey; - -@Mixin(LegacyStructureDataHandler.class) -public abstract class LegacyStructureDataHandlerMixin { - - @ModifyVariable( - method = "getLegacyTagFixer", - at = @At("HEAD"), - argsOnly = true - ) - private static ResourceKey modifyWorldKey(ResourceKey world) { - return getMockRegistryKey(world); - } -} diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/ThrownEnderpearlMixin.java b/src/main/java/com/gmail/anthony17j/multiworld/mixin/ThrownEnderpearlMixin.java deleted file mode 100644 index c958f8b..0000000 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/ThrownEnderpearlMixin.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.gmail.anthony17j.multiworld.mixin; - -import net.minecraft.resources.ResourceKey; -import net.minecraft.world.entity.projectile.throwableitemprojectile.ThrownEnderpearl; -import net.minecraft.world.level.Level; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Redirect; - -import static com.gmail.anthony17j.multiworld.CustomServerWorld.getMockRegistryKey; - -@Mixin(ThrownEnderpearl.class) -public abstract class ThrownEnderpearlMixin { - - @Redirect( - method = "canTeleport", - at = @At( - value = "INVOKE", - target = "Lnet/minecraft/world/level/Level;dimension()Lnet/minecraft/resources/ResourceKey;", - ordinal = 0 - ) - ) - private ResourceKey redirectFromGetRegistryKey(Level world) { - return getMockRegistryKey(world.dimension()); - } - - @Redirect( - method = "canTeleport", - at = @At( - value = "INVOKE", - target = "Lnet/minecraft/world/level/Level;dimension()Lnet/minecraft/resources/ResourceKey;", - ordinal = 1 - ) - ) - private ResourceKey redirectToGetRegistryKey(Level world) { - return getMockRegistryKey(world.dimension()); - } -} diff --git a/src/main/java/com/gmail/anthony17j/multiworld/CustomServerWorld.java b/src/main/java/fr/jeanney/CustomServerWorld.java similarity index 79% rename from src/main/java/com/gmail/anthony17j/multiworld/CustomServerWorld.java rename to src/main/java/fr/jeanney/CustomServerWorld.java index 5267691..4d111a1 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/CustomServerWorld.java +++ b/src/main/java/fr/jeanney/CustomServerWorld.java @@ -1,6 +1,6 @@ -package com.gmail.anthony17j.multiworld; +package fr.jeanney; -import com.gmail.anthony17j.multiworld.mixin.IMinecraftServerMixin; +import fr.jeanney.mixin.IMinecraftServerMixin; import com.google.common.collect.ImmutableList; import net.minecraft.resources.Identifier; import net.minecraft.resources.ResourceKey; @@ -17,12 +17,13 @@ import java.util.Objects; -import static com.gmail.anthony17j.multiworld.MultiWorld.NAMESPACE; +import static fr.jeanney.MultiWorld.NAMESPACE; import static net.minecraft.core.registries.Registries.DIMENSION; public class CustomServerWorld extends ServerLevel { - public CustomServerWorld(MinecraftServer server, ResourceKey registryKey, LevelStem options, CustomServerWorldProperties levelProperties, long seed) { + public CustomServerWorld(MinecraftServer server, ResourceKey registryKey, LevelStem options, + CustomServerWorldProperties levelProperties, long seed) { super( server, Util.backgroundExecutor(), @@ -33,15 +34,14 @@ public CustomServerWorld(MinecraftServer server, ResourceKey registryKey, false, seed, ImmutableList.of( - new PhantomSpawner(), new PatrolSpawner(), new CatSpawner(), new VillageSiege(), new WanderingTraderSpawner(levelProperties) - ), - true, - null - ); + new PhantomSpawner(), new PatrolSpawner(), new CatSpawner(), new VillageSiege(), + new WanderingTraderSpawner(server.overworld().getDataStorage())), + true); } public interface Constructor { - CustomServerWorld create(MinecraftServer server, ResourceKey registryKey, LevelStem options, CustomServerWorldProperties levelProperties, long seed); + CustomServerWorld create(MinecraftServer server, ResourceKey registryKey, LevelStem options, + CustomServerWorldProperties levelProperties, long seed); } @Override @@ -63,8 +63,7 @@ public ResourceKey getMockRegistryKey() { public static ResourceKey getRegistryKey(String world) { return ResourceKey.create( DIMENSION, - Identifier.fromNamespaceAndPath(NAMESPACE, world) - ); + Identifier.fromNamespaceAndPath(NAMESPACE, world)); } public static ResourceKey getMockRegistryKey(Level world) { @@ -91,18 +90,22 @@ public static ResourceKey getMockRegistryKey(ResourceKey world) { } public static boolean isCustomEndWorld(ResourceKey key) { - return key.identifier().getPath().endsWith("_end") && Objects.equals(key.identifier().getNamespace(), NAMESPACE); + return key.identifier().getPath().endsWith("_end") + && Objects.equals(key.identifier().getNamespace(), NAMESPACE); } public static boolean isCustomNetherWorld(ResourceKey key) { - return key.identifier().getPath().endsWith("_nether") && Objects.equals(key.identifier().getNamespace(), NAMESPACE); + return key.identifier().getPath().endsWith("_nether") + && Objects.equals(key.identifier().getNamespace(), NAMESPACE); } public static boolean isWorldWithNether(ResourceKey key) { if (Objects.equals(key.identifier().getNamespace(), NAMESPACE)) { String path = getBaseWorldName(key.identifier().getPath()); - ResourceKey worldKey = ResourceKey.create(DIMENSION, Identifier.fromNamespaceAndPath(NAMESPACE, path)); - ResourceKey netherKey = ResourceKey.create(DIMENSION, Identifier.fromNamespaceAndPath(NAMESPACE, path + "_nether")); + ResourceKey worldKey = ResourceKey.create(DIMENSION, + Identifier.fromNamespaceAndPath(NAMESPACE, path)); + ResourceKey netherKey = ResourceKey.create(DIMENSION, + Identifier.fromNamespaceAndPath(NAMESPACE, path + "_nether")); return worldKey != null && netherKey != null; } else { return true; @@ -112,8 +115,10 @@ public static boolean isWorldWithNether(ResourceKey key) { public static boolean isWorldWithEnd(ResourceKey key) { if (Objects.equals(key.identifier().getNamespace(), NAMESPACE)) { String path = getBaseWorldName(key.identifier().getPath()); - ResourceKey worldKey = ResourceKey.create(DIMENSION, Identifier.fromNamespaceAndPath(NAMESPACE, path)); - ResourceKey endKey = ResourceKey.create(DIMENSION, Identifier.fromNamespaceAndPath(NAMESPACE, path + "_end")); + ResourceKey worldKey = ResourceKey.create(DIMENSION, + Identifier.fromNamespaceAndPath(NAMESPACE, path)); + ResourceKey endKey = ResourceKey.create(DIMENSION, + Identifier.fromNamespaceAndPath(NAMESPACE, path + "_end")); return worldKey != null && endKey != null; } else { return true; diff --git a/src/main/java/fr/jeanney/CustomServerWorldProperties.java b/src/main/java/fr/jeanney/CustomServerWorldProperties.java new file mode 100644 index 0000000..02fb585 --- /dev/null +++ b/src/main/java/fr/jeanney/CustomServerWorldProperties.java @@ -0,0 +1,89 @@ +package fr.jeanney; + +import net.minecraft.world.Difficulty; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.storage.DerivedLevelData; +import net.minecraft.world.level.storage.LevelData; +import net.minecraft.world.level.storage.ServerLevelData; +import net.minecraft.world.level.storage.WorldData; +import org.jspecify.annotations.NonNull; + +public final class CustomServerWorldProperties extends DerivedLevelData { + public final long seed; + + private LevelData.RespawnData respawnData; + private long gameTime; + private boolean initialized; + private GameType gameType; + private final boolean allowCommands; + private final Difficulty difficulty; + private final boolean difficultyLocked; + + public CustomServerWorldProperties(WorldData worldData, ServerLevelData levelProperties, long seed) { + super(worldData, levelProperties); + + this.seed = seed; + this.respawnData = null; + this.gameTime = levelProperties.getGameTime(); + this.initialized = false; + this.gameType = levelProperties.getGameType(); + this.allowCommands = levelProperties.isAllowCommands(); + this.difficulty = levelProperties.getDifficulty(); + this.difficultyLocked = levelProperties.isDifficultyLocked(); + } + + @Override + public LevelData.@NonNull RespawnData getRespawnData() { + return this.respawnData; + } + + @Override + public void setSpawn(LevelData.@NonNull RespawnData respawnData) { + this.respawnData = respawnData; + } + + @Override + public long getGameTime() { + return this.gameTime; + } + + @Override + public void setGameTime(long time) { + this.gameTime = time; + } + + @Override + public boolean isInitialized() { + return this.initialized; + } + + @Override + public void setInitialized(boolean initialized) { + this.initialized = initialized; + } + + @Override + public @NonNull GameType getGameType() { + return this.gameType; + } + + @Override + public void setGameType(@NonNull GameType gameType) { + this.gameType = gameType; + } + + @Override + public boolean isAllowCommands() { + return this.allowCommands; + } + + @Override + public @NonNull Difficulty getDifficulty() { + return this.difficulty; + } + + @Override + public boolean isDifficultyLocked() { + return this.difficultyLocked; + } +} diff --git a/src/main/java/com/gmail/anthony17j/multiworld/CustomSimpleRegistry.java b/src/main/java/fr/jeanney/CustomSimpleRegistry.java similarity index 91% rename from src/main/java/com/gmail/anthony17j/multiworld/CustomSimpleRegistry.java rename to src/main/java/fr/jeanney/CustomSimpleRegistry.java index 9bf8b6c..ce8fd9f 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/CustomSimpleRegistry.java +++ b/src/main/java/fr/jeanney/CustomSimpleRegistry.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld; +package fr.jeanney; import net.minecraft.core.MappedRegistry; import net.minecraft.resources.Identifier; diff --git a/src/main/java/fr/jeanney/MultiWorld.java b/src/main/java/fr/jeanney/MultiWorld.java new file mode 100644 index 0000000..c969aba --- /dev/null +++ b/src/main/java/fr/jeanney/MultiWorld.java @@ -0,0 +1,508 @@ +package fr.jeanney; + +import fr.jeanney.command.CreativeCommand; +import fr.jeanney.command.MultiWorldCommand; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.registries.Registries; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.network.protocol.game.ClientboundGameEventPacket; +import net.minecraft.network.protocol.game.ClientboundSetTimePacket; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.ServerScoreboard; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.datafix.DataFixTypes; +import net.minecraft.world.level.Level; +import net.minecraft.world.clock.ClockNetworkState; +import net.minecraft.world.clock.ServerClockManager; +import net.minecraft.world.clock.WorldClock; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.saveddata.WeatherData; +import net.minecraft.world.level.gamerules.GameRules; +import net.minecraft.world.scores.Objective; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileReader; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; + +import static fr.jeanney.CustomServerWorld.getBaseWorldName; + +public class MultiWorld implements ModInitializer { + + public static final Logger LOGGER = LoggerFactory.getLogger(MultiWorld.class); + + public static final String NAMESPACE = "multiworld"; + public static final ResourceKey DEFAULT_DIM_TYPE = ResourceKey.create(Registries.DIMENSION_TYPE, + Identifier.fromNamespaceAndPath(NAMESPACE, "default")); + + public enum WorldMode { + NORMAL, + CREATIVE; + + public static WorldMode fromString(String value) { + if ("creative".equalsIgnoreCase(value)) { + return CREATIVE; + } + return NORMAL; + } + + public String toConfigValue() { + return this == CREATIVE ? "creative" : "normal"; + } + } + + @Override + public void onInitialize() { + CommandRegistrationCallback.EVENT.register(CreativeCommand::register); + CommandRegistrationCallback.EVENT.register(MultiWorldCommand::register); + + ServerLifecycleEvents.SERVER_STARTED.register((MinecraftServer server) -> { + WorldStateService.resetOnServerStarted(); + WorldConfigService.loadCustomWorlds(server, WorldLifecycleService::unload, + WorldLifecycleService::createWorld); + }); + + ServerTickEvents.END_SERVER_TICK.register(server -> { + WorldStateService.tickCustomWorldClocks(server); + WorldConfigService.autosaveCustomWorldGameRules(server); + WorldStateService.autosaveCustomWorldScoreboards(server); + }); + ServerLifecycleEvents.SERVER_STOPPING.register(server -> { + WorldConfigService.persistCustomWorldGameRules(server, true); + WorldStateService.autosaveCustomWorldScoreboards(server); + WorldStateService.resetOnServerStopping(); + }); + } + + public static ServerClockManager getClockManagerForLevel(ServerLevel level) { + return WorldStateService.getClockManagerForLevel(level); + } + + public static ServerClockManager getClockManagerForSource(CommandSourceStack source, + MinecraftServer fallbackServer) { + if (source != null && source.getLevel() != null) { + ServerLevel level = source.getLevel(); + if (NAMESPACE.equals(level.dimension().identifier().getNamespace())) { + return getClockManagerForLevel(level); + } + } + return fallbackServer.clockManager(); + } + + public static ClientboundSetTimePacket createTimeSyncPacketForLevel(ServerLevel level) { + ServerClockManager clockManager = NAMESPACE.equals(level.dimension().identifier().getNamespace()) + ? getClockManagerForLevel(level) + : level.getServer().clockManager(); + Map, ClockNetworkState> updates = new HashMap<>(); + var clockRegistry = level.registryAccess().lookupOrThrow(Registries.WORLD_CLOCK); + clockRegistry.listElements().forEach( + clock -> updates.put(clock, new ClockNetworkState(clockManager.getTotalTicks(clock), 0.0F, 1.0F))); + return new ClientboundSetTimePacket(level.getGameTime(), updates); + } + + public static boolean isWeatherGameEvent(ClientboundGameEventPacket.Type eventType) { + return eventType == ClientboundGameEventPacket.START_RAINING + || eventType == ClientboundGameEventPacket.STOP_RAINING + || eventType == ClientboundGameEventPacket.RAIN_LEVEL_CHANGE + || eventType == ClientboundGameEventPacket.THUNDER_LEVEL_CHANGE; + } + + public static ClientboundGameEventPacket createWeatherSyncPacketForLevel(ServerLevel level, + ClientboundGameEventPacket.Type eventType) { + if (eventType == ClientboundGameEventPacket.START_RAINING + || eventType == ClientboundGameEventPacket.STOP_RAINING) { + boolean raining = level.getWeatherData().isRaining(); + return new ClientboundGameEventPacket( + raining ? ClientboundGameEventPacket.START_RAINING : ClientboundGameEventPacket.STOP_RAINING, + 0.0F); + } + + if (eventType == ClientboundGameEventPacket.RAIN_LEVEL_CHANGE) { + return new ClientboundGameEventPacket(eventType, level.getRainLevel(1.0F)); + } + + if (eventType == ClientboundGameEventPacket.THUNDER_LEVEL_CHANGE) { + return new ClientboundGameEventPacket(eventType, level.getThunderLevel(1.0F)); + } + + return new ClientboundGameEventPacket(eventType, 0.0F); + } + + public static ServerScoreboard getScoreboardForSource(CommandSourceStack source, MinecraftServer fallbackServer) { + if (source != null && source.getLevel() != null) { + ServerLevel level = source.getLevel(); + if (NAMESPACE.equals(level.dimension().identifier().getNamespace())) { + return getScoreboardForLevel(level); + } + } + return fallbackServer.getScoreboard(); + } + + public static ServerScoreboard getScoreboardForLevel(ServerLevel level) { + return WorldStateService.getScoreboardForLevel(level); + } + + public static GameRules getCustomWorldGameRules(ServerLevel level) { + return WorldStateService.getCustomWorldGameRules(level); + } + + public static WeatherData getCustomWorldWeatherData(ServerLevel level) { + return WorldStateService.getCustomWorldWeatherData(level); + } + + private static WorldStateService.WorldInitialState createFreshInitialState(WorldMode worldMode) { + return WorldStateService.createFreshInitialState(worldMode); + } + + private static WorldStateService.WorldInitialState createImportedInitialState(CompoundTag dataTag, + Optional savedWeather, + boolean preserveSavedWorldClocks, OptionalLong overworldClockTicks) { + return WorldStateService.createImportedInitialState(dataTag, savedWeather, preserveSavedWorldClocks, + overworldClockTicks); + } + + public static boolean isCreativeModeWorld(ResourceKey key) { + return WorldStateService.isCreativeModeWorld(key); + } + + public static Optional getCreativeWorldName(MinecraftServer server) { + return WorldStateService.getCreativeWorldName(server); + } + + private static void rememberPendingInitialState(String worldName, + WorldStateService.WorldInitialState initialState) { + WorldStateService.rememberPendingInitialState(worldName, initialState); + } + + public static void applyPendingInitialStateIfNeeded(MinecraftServer server, String worldName) { + Optional initialState = WorldStateService + .consumePendingInitialState(worldName); + if (initialState.isPresent()) { + WorldStateService.applyInitialStateToWorldFamily(server, getBaseWorldName(worldName), initialState.get()); + } + } + + public static boolean importWorld(MinecraftServer server, String worldFolderPath, String worldName) { + LOGGER.info("Importing world from {} to multiworld:{}", worldFolderPath, worldName); + + File sourceWorldFolder = new File(worldFolderPath); + if (!sourceWorldFolder.exists() || !sourceWorldFolder.isDirectory()) { + LOGGER.error("Source world folder {} does not exist or is not a directory", worldFolderPath); + return false; + } + + try { + sourceWorldFolder = WorldImportSupport.resolveImportSourceWorldFolder(sourceWorldFolder); + File levelDatFile = new File(sourceWorldFolder, "level.dat"); + if (!levelDatFile.exists()) { + LOGGER.error("level.dat not found in {}", sourceWorldFolder.getAbsolutePath()); + return false; + } + + CompoundTag rawLevelData = NbtIo.readCompressed(levelDatFile.toPath(), NbtAccounter.unlimitedHeap()); + long seed = WorldImportSupport.extractSeedFromLevelDat(rawLevelData, server, sourceWorldFolder); + CompoundTag rawDataTag = rawLevelData.getCompound("Data").orElse(rawLevelData); + BlockPos importedSpawnPos = WorldImportSupport.extractImportedSpawnPos(rawDataTag).orElse(null); + + CompoundTag levelData = rawLevelData.copy(); + levelData = DataFixTypes.LEVEL.updateToCurrentVersion( + server.getFixerUpper(), + levelData, + NbtUtils.getDataVersion(levelData, 0)); + NbtUtils.addCurrentDataVersion(levelData); + CompoundTag dataTag = levelData.getCompound("Data").orElse(levelData); + LOGGER.info("Seed from world: {}", seed); + + String serverWorldFolder = server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT) + .toString(); + + ResourceKey overWorldKey = ResourceKey.create(Registries.DIMENSION, + Identifier.fromNamespaceAndPath(NAMESPACE, worldName)); + ResourceKey netherWorldKey = ResourceKey.create(Registries.DIMENSION, + Identifier.fromNamespaceAndPath(NAMESPACE, worldName + "_nether")); + ResourceKey endWorldKey = ResourceKey.create(Registries.DIMENSION, + Identifier.fromNamespaceAndPath(NAMESPACE, worldName + "_end")); + + if (server.getLevel(overWorldKey) != null) { + LOGGER.info("Import target world '{}' already exists; replacing it", worldName); + deleteWorld(server, worldName); + } + + File sourceOverworldFolder = WorldImportSupport.resolveOverworldImportFolder(sourceWorldFolder); + WorldImportSupport.copyWorldFiles(sourceOverworldFolder, serverWorldFolder, overWorldKey); + + if (!sourceOverworldFolder.equals(sourceWorldFolder)) { + String overworldDestination = WorldImportSupport.buildWorldDirectoryPath(serverWorldFolder, + overWorldKey); + // Modern saves keep shared saved-data (world_gen_settings, weather, + // scoreboard...) at root/data. + // Merge it into imported overworld data after copying the per-dimension folder. + WorldImportSupport.copyFilesBeforeWorldCreation(sourceWorldFolder, "data", overworldDestination); + } + + File sourceNetherFolder = WorldImportSupport.resolveNetherImportFolder(sourceWorldFolder); + if (sourceNetherFolder != null) { + WorldImportSupport.copyWorldFiles(sourceNetherFolder, serverWorldFolder, netherWorldKey); + } + + File sourceEndFolder = WorldImportSupport.resolveEndImportFolder(sourceWorldFolder); + if (sourceEndFolder != null) { + WorldImportSupport.copyWorldFiles(sourceEndFolder, serverWorldFolder, endWorldKey); + } + + Optional importedWeather = WorldImportSupport.extractWeatherFromSavedData(server, + sourceWorldFolder); + boolean preserveSavedWorldClocks = WorldImportSupport.hasWorldClocksDat(sourceWorldFolder); + OptionalLong overworldClockTicks = WorldImportSupport.extractOverworldClockTicksFromDat(sourceWorldFolder); + Set globalObjectivesBeforeImport = getObjectiveNames(server.getScoreboard()); + WorldStateService.WorldInitialState importedInitialState = createImportedInitialState(dataTag, + importedWeather, + preserveSavedWorldClocks, overworldClockTicks); + createWorldWithDimensions(server, worldName, seed, importedSpawnPos, "normal", WorldMode.NORMAL, + importedInitialState); + + ServerLevel overworld = server.getLevel(overWorldKey); + + if (overworld == null) { + LOGGER.error("Failed to create overworld dimension"); + return false; + } + + GameRules importedGameRules = WorldImportSupport.extractImportedGameRules(server, sourceWorldFolder, + rawDataTag); + applyImportedGameRulesToWorldFamily(server, worldName, importedGameRules); + removeLeakedGlobalObjectives(server, globalObjectivesBeforeImport); + + File multiworldFolder = new File(serverWorldFolder, NAMESPACE); + if (!multiworldFolder.exists() && !multiworldFolder.mkdirs()) { + LOGGER.error("Could not create multiworld directory"); + return false; + } + + File worldNameFolder = new File(multiworldFolder, worldName); + if (!worldNameFolder.exists() && !worldNameFolder.mkdirs()) { + LOGGER.error("Could not create world directory"); + return false; + } + + File playerDataFolder = resolvePlayerDataFolder(sourceWorldFolder); + File statsFolder = resolvePlayerStatsFolder(sourceWorldFolder); + File advancementsFolder = resolvePlayerAdvancementsFolder(sourceWorldFolder); + if (playerDataFolder != null) { + File[] playerDataFiles = playerDataFolder.listFiles((dir, name) -> name.endsWith(".dat")); + if (playerDataFiles != null) { + for (File playerDataFile : playerDataFiles) { + try { + CompoundTag playerData = NbtIo.readCompressed(playerDataFile.toPath(), + NbtAccounter.unlimitedHeap()); + + String uuid = playerDataFile.getName().replace(".dat", ""); + + playerData = PlayerProfileManager.migratePlayerDataToCurrentVersion( + server, + playerData, + "import/" + worldName + "/" + uuid); + + WorldImportSupport.updateDimensionInTag(playerData, "respawn", worldName); + + String dimensionTagString = playerData.getString("Dimension") + .orElse(Level.OVERWORLD.identifier().toString()); + playerData.putString("Dimension", + WorldImportSupport.convertDimensionName(dimensionTagString, worldName)); + + WorldImportSupport.updateDimensionInTag(playerData, "LastDeathLocation", worldName); + + JsonObject playerJson = new JsonObject(); + playerJson.addProperty("player", playerData.toString()); + + File statsFile = statsFolder == null + ? null + : new File(statsFolder, uuid + ".json"); + if (statsFile != null && statsFile.exists()) { + try (FileReader statsReader = new FileReader(statsFile)) { + JsonElement parsed = JsonParser.parseReader(statsReader); + if (parsed != null && parsed.isJsonObject()) { + playerJson.add("stats", parsed.getAsJsonObject()); + } + } + } + + File advancementsFile = advancementsFolder == null + ? null + : new File(advancementsFolder, uuid + ".json"); + if (advancementsFile != null && advancementsFile.exists()) { + try (FileReader advancementsReader = new FileReader(advancementsFile)) { + JsonElement parsed = JsonParser.parseReader(advancementsReader); + if (parsed != null && parsed.isJsonObject()) { + playerJson.add("advancements", parsed.getAsJsonObject()); + } + } + } + + try (BufferedWriter writer = Files.newBufferedWriter( + Paths.get(worldNameFolder.getPath(), uuid + ".json"))) { + writer.write(playerJson.toString()); + } + + } catch (Exception e) { + LOGGER.error("Error processing player data: {}", e.getMessage()); + } + } + } + } else { + LOGGER.info("No player data directory found in import source '{}'; skipping player profile import", + sourceWorldFolder.getAbsolutePath()); + } + + LOGGER.info("World import completed successfully!"); + return true; + + } catch (Exception e) { + LOGGER.error("Error importing world", e); + return false; + } + } + + private static File resolvePlayerDataFolder(File sourceWorldFolder) { + File legacy = new File(sourceWorldFolder, "playerdata"); + if (legacy.isDirectory()) { + return legacy; + } + + File modern = new File(sourceWorldFolder, "players/data"); + if (modern.isDirectory()) { + return modern; + } + + return null; + } + + private static File resolvePlayerStatsFolder(File sourceWorldFolder) { + File legacy = new File(sourceWorldFolder, "stats"); + if (legacy.isDirectory()) { + return legacy; + } + + File modern = new File(sourceWorldFolder, "players/stats"); + if (modern.isDirectory()) { + return modern; + } + + return null; + } + + private static File resolvePlayerAdvancementsFolder(File sourceWorldFolder) { + File legacy = new File(sourceWorldFolder, "advancements"); + if (legacy.isDirectory()) { + return legacy; + } + + File modern = new File(sourceWorldFolder, "players/advancements"); + if (modern.isDirectory()) { + return modern; + } + + return null; + } + + private static void applyImportedGameRulesToWorldFamily(MinecraftServer server, String worldName, + GameRules importedGameRules) { + String[] dimensionNames = { worldName, worldName + "_nether", worldName + "_end" }; + for (String name : dimensionNames) { + ResourceKey key = ResourceKey.create(Registries.DIMENSION, + Identifier.fromNamespaceAndPath(NAMESPACE, name)); + ServerLevel level = server.getLevel(key); + if (level == null) { + continue; + } + + level.getGameRules().setAll(importedGameRules, server); + WorldStateService.setLastSavedGameRuleSnapshot( + key, + WorldConfigService.encodeGameRules(server, level.getGameRules()).toString()); + } + } + + private static void removeLeakedGlobalObjectives(MinecraftServer server, + Set globalObjectivesBeforeImport) { + ServerScoreboard globalScoreboard = server.getScoreboard(); + for (String objectiveName : getObjectiveNames(globalScoreboard)) { + if (globalObjectivesBeforeImport.contains(objectiveName)) { + continue; + } + Objective objective = globalScoreboard.getObjective(objectiveName); + if (objective != null) { + globalScoreboard.removeObjective(objective); + } + } + } + + private static Set getObjectiveNames(ServerScoreboard scoreboard) { + Set names = new HashSet<>(); + for (Objective objective : scoreboard.getObjectives()) { + names.add(objective.getName()); + } + return names; + } + + public static void createWorldWithDimensions(MinecraftServer server, String worldString, long seed) { + createWorldWithDimensions(server, worldString, seed, null, "normal", WorldMode.NORMAL); + } + + public static void createWorldWithDimensions(MinecraftServer server, String worldString, long seed, + BlockPos spawnPos) { + createWorldWithDimensions(server, worldString, seed, spawnPos, "normal", WorldMode.NORMAL); + } + + public static void createWorldWithDimensions( + MinecraftServer server, + String worldString, + long seed, + BlockPos spawnPos, + String preset, + WorldMode worldMode) { + WorldStateService.WorldInitialState initialState = createFreshInitialState(worldMode); + rememberPendingInitialState(worldString, initialState); + createWorldWithDimensions(server, worldString, seed, spawnPos, preset, worldMode, initialState); + } + + private static void createWorldWithDimensions( + MinecraftServer server, + String worldString, + long seed, + BlockPos spawnPos, + String preset, + WorldMode worldMode, + WorldStateService.WorldInitialState initialState) { + WorldLifecycleService.createWorldWithDimensions(server, worldString, seed, spawnPos, preset, worldMode, + initialState); + } + + public static void deleteWorld(MinecraftServer server, String worldName) { + WorldLifecycleService.deleteWorld(server, worldName); + } + +} diff --git a/src/main/java/fr/jeanney/PlayerProfileManager.java b/src/main/java/fr/jeanney/PlayerProfileManager.java new file mode 100644 index 0000000..6fc639a --- /dev/null +++ b/src/main/java/fr/jeanney/PlayerProfileManager.java @@ -0,0 +1,390 @@ +package fr.jeanney; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import fr.jeanney.mixin.PlayerAdvancementsAccessor; +import fr.jeanney.mixin.ServerLevelAccessor; +import fr.jeanney.mixin.ServerStatsCounterAccessor; +import fr.jeanney.mixin.StatsCounterAccessor; +import net.minecraft.SharedConstants; +import net.minecraft.advancements.AdvancementProgress; +import net.minecraft.core.BlockPos; +import net.minecraft.core.GlobalPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.nbt.TagParser; +import net.minecraft.network.protocol.game.ClientboundSelectAdvancementsTabPacket; +import net.minecraft.network.protocol.game.ClientboundUpdateAdvancementsPacket; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.TickTask; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.stats.ServerStatsCounter; +import net.minecraft.stats.Stat; +import net.minecraft.util.ProblemReporter; +import net.minecraft.util.datafix.DataFixTypes; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.portal.TeleportTransition; +import net.minecraft.world.level.storage.LevelData; +import net.minecraft.world.level.storage.LevelResource; +import net.minecraft.world.level.storage.TagValueInput; +import net.minecraft.world.level.storage.TagValueOutput; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.phys.Vec3; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static fr.jeanney.MultiWorld.LOGGER; +import static fr.jeanney.MultiWorld.NAMESPACE; + +public final class PlayerProfileManager { + private PlayerProfileManager() { + } + + static CompoundTag migratePlayerDataToCurrentVersion(MinecraftServer server, CompoundTag playerNbt, + String context) { + int sourceVersion = NbtUtils.getDataVersion(playerNbt, 0); + int currentVersion = SharedConstants.getCurrentVersion().dataVersion().version(); + if (sourceVersion >= currentVersion) { + return playerNbt; + } + + try { + CompoundTag fixed = DataFixTypes.PLAYER.updateToCurrentVersion(server.getFixerUpper(), playerNbt, + sourceVersion); + NbtUtils.addCurrentDataVersion(fixed); + LOGGER.info("Migrated player data '{}' from DataVersion {} to {}", context, sourceVersion, currentVersion); + return fixed; + } catch (Exception ex) { + LOGGER.error("Error migrating player data '{}' from DataVersion {}: {}", context, sourceVersion, + ex.getMessage()); + return playerNbt; + } + } + + public static void saveInv(ServerPlayer player, String world) { + try { + String worldFolder = getWorldFolder(player.level().getServer()); + + if (new File(worldFolder + "/" + NAMESPACE + "/" + world).mkdirs()) { + LOGGER.info("Directory created: {}/{}/{}", worldFolder, NAMESPACE, world); + } + BufferedWriter writer = Files.newBufferedWriter( + Paths.get(worldFolder + "/" + NAMESPACE + "/" + world + "/" + player.getStringUUID() + ".json")); + + TagValueOutput output = TagValueOutput.createWithContext(ProblemReporter.DISCARDING, + player.registryAccess()); + player.saveWithoutId(output); + + player.getEnderPearls().forEach((enderPearl) -> enderPearl.remove(Entity.RemovalReason.DISCARDED)); + + if (player.getRootVehicle() != player) { + player.getRootVehicle().remove(Entity.RemovalReason.DISCARDED); + } + + CompoundTag tag = output.buildResult(); + + CompoundTag respawnTag = new CompoundTag(); + ServerPlayer.RespawnConfig respawn = player.getRespawnConfig(); + if (respawn != null) { + respawnTag.putString("dimension", respawn.respawnData().dimension().identifier().toString()); + respawnTag.putBoolean("forced", respawn.forced()); + respawnTag.putFloat("angle", respawn.respawnData().yaw()); + respawnTag.putFloat("pitch", respawn.respawnData().pitch()); + respawnTag.putIntArray("pos", new int[] { respawn.respawnData().pos().getX(), + respawn.respawnData().pos().getY(), respawn.respawnData().pos().getZ() }); + tag.put("respawn", respawnTag); + } + + player.getStats().save(); + JsonObject statsJson = new JsonObject(); + Path vanillaStatsPath = ((ServerStatsCounterAccessor) player.getStats()).getFile(); + if (vanillaStatsPath != null && Files.exists(vanillaStatsPath)) { + try (FileReader statsReader = new FileReader(vanillaStatsPath.toFile())) { + JsonElement parsed = JsonParser.parseReader(statsReader); + if (parsed != null && parsed.isJsonObject()) { + statsJson = parsed.getAsJsonObject(); + } + } + } + + player.getAdvancements().save(); + JsonObject advancementsJson = new JsonObject(); + Path vanillaAdvancementsPath = ((PlayerAdvancementsAccessor) player.getAdvancements()).getPlayerSavePath(); + if (vanillaAdvancementsPath != null && Files.exists(vanillaAdvancementsPath)) { + try (FileReader advancementsReader = new FileReader(vanillaAdvancementsPath.toFile())) { + JsonElement parsed = JsonParser.parseReader(advancementsReader); + if (parsed != null && parsed.isJsonObject()) { + advancementsJson = parsed.getAsJsonObject(); + } + } + } + + JsonObject playerJson = new JsonObject(); + playerJson.addProperty("player", tag.toString()); + playerJson.add("stats", statsJson); + playerJson.add("advancements", advancementsJson); + writer.write(playerJson.toString()); + writer.close(); + + } catch (Exception ex) { + LOGGER.error("Error saving player data: {}", ex.getMessage()); + } + } + + public static void loadInv(ServerPlayer player, String world) { + String worldFolder = getWorldFolder(player.level().getServer()); + Path playerProfilePath = Paths.get(worldFolder, NAMESPACE, world, player.getStringUUID() + ".json"); + Path advancementsPath = ((PlayerAdvancementsAccessor) player.getAdvancements()).getPlayerSavePath(); + if (advancementsPath == null) { + advancementsPath = Paths.get(worldFolder, "advancements", player.getStringUUID() + ".json"); + } + + if (!Files.exists(playerProfilePath)) { + loadMissingWorldProfile(player, world, advancementsPath); + return; + } + + try (FileReader fileReader = new FileReader(playerProfilePath.toFile())) { + JsonElement parsedRoot = JsonParser.parseReader(fileReader); + JsonObject file = parsedRoot != null && parsedRoot.isJsonObject() ? parsedRoot.getAsJsonObject() + : new JsonObject(); + + String playerString = file.has("player") ? file.get("player").getAsString() : "{}"; + CompoundTag playerNbt = TagParser.parseCompoundFully(playerString); + playerNbt = migratePlayerDataToCurrentVersion( + player.level().getServer(), + playerNbt, + "loadInv/" + world + "/" + player.getStringUUID()); + + ValueInput nbtReadView = TagValueInput.create(ProblemReporter.DISCARDING, player.registryAccess(), + playerNbt); + player.load(nbtReadView); + player.loadAndSpawnEnderPearls(nbtReadView); + + String[] strArr = playerNbt.getString("Dimension").orElse(Level.OVERWORLD.identifier().toString()) + .split(":"); + ResourceKey key = ResourceKey.create(Registries.DIMENSION, + Identifier.fromNamespaceAndPath(strArr[0], strArr[1])); + ServerLevel dimension = player.level().getServer().getLevel(key); + + Optional respawnTag = playerNbt.getCompound("respawn"); + if (respawnTag.isPresent()) { + CompoundTag respawnData = respawnTag.get(); + int[] respawnPos = respawnData.getIntArray("pos").orElseThrow(); + String respawnDimensionStr = respawnData.getString("dimension") + .orElse(Level.OVERWORLD.identifier().toString()); + String[] dimParts = respawnDimensionStr.split(":"); + ResourceKey respawnDimension = ResourceKey.create(Registries.DIMENSION, + Identifier.fromNamespaceAndPath(dimParts[0], dimParts[1])); + LevelData.RespawnData respawnDataObj = new LevelData.RespawnData( + new GlobalPos(respawnDimension, new BlockPos(respawnPos[0], respawnPos[1], respawnPos[2])), + respawnData.getFloat("angle").orElse(0F), + respawnData.getFloat("pitch").orElse(0F)); + player.setRespawnPosition( + new ServerPlayer.RespawnConfig(respawnDataObj, respawnData.getBooleanOr("forced", false)), + false); + } + + if (file.has("stats") && file.get("stats").isJsonObject()) { + JsonObject statsData = file.getAsJsonObject("stats"); + + ServerStatsCounter statHandler = player.getStats(); + for (Stat stat : ((StatsCounterAccessor) statHandler).getStatMap().keySet()) { + player.resetStat(stat); + } + + statHandler.parse(player.level().getServer().getFixerUpper(), statsData); + statHandler.markAllDirty(); + } + + if (file.has("advancements") && file.get("advancements").isJsonObject()) { + JsonObject advancementsData = file.getAsJsonObject("advancements"); + Files.createDirectories(advancementsPath.getParent()); + Files.write( + advancementsPath, + advancementsData.toString().getBytes()); + } + player.getAdvancements().reload(player.level().getServer().getAdvancements()); + + if (dimension != null) { + player.teleport(new TeleportTransition(dimension, new Vec3(player.getX(), player.getY(), player.getZ()), + Vec3.ZERO, player.getYRot(), player.getXRot(), TeleportTransition.DO_NOTHING)); + syncCustomWorldTimeToClient(player); + } + + player.loadAndSpawnParentVehicle(nbtReadView); + forceAdvancementClientRefresh(player, false); + } catch (Exception ex) { + LOGGER.warn("Failed to load world profile '{}' for player {}: {}", playerProfilePath, + player.getStringUUID(), ex.getMessage()); + loadMissingWorldProfile(player, world, advancementsPath); + } + } + + public static String getMostRecentWorldSaved(ServerPlayer player, String sourceWorld) { + String worldFolder = getWorldFolder(player.level().getServer()); + File folder = new File(worldFolder + "/" + NAMESPACE); + File[] listOfFiles = folder.listFiles(); + if (listOfFiles != null) { + String mostRecentWorld = ""; + long mostRecentTime = 0; + for (File file : listOfFiles) { + if (file.isDirectory()) { + File playerFile = new File(file.getPath() + "/" + player.getUUID() + ".json"); + if (playerFile.exists()) { + long lastModified = playerFile.lastModified(); + if (lastModified > mostRecentTime && !file.getName().equals(sourceWorld)) { + mostRecentTime = lastModified; + mostRecentWorld = file.getName(); + } + } + } + } + return mostRecentWorld; + } else { + LOGGER.error("No worlds found in namespace folder"); + return "overworld"; + } + } + + private static String getWorldFolder(MinecraftServer server) { + return server.getWorldPath(LevelResource.ROOT).toString(); + } + + private static void loadMissingWorldProfile(ServerPlayer player, String world, Path advancementsPath) { + if (!"overworld".equals(world)) { + MultiWorld.applyPendingInitialStateIfNeeded(player.level().getServer(), world); + } + + CompoundTag playerNbt = new CompoundTag(); + + ValueInput nbtReadView = TagValueInput.create(ProblemReporter.DISCARDING, player.registryAccess(), playerNbt); + player.load(nbtReadView); + player.loadAndSpawnEnderPearls(nbtReadView); + + player.setRespawnPosition(null, false); + + ServerStatsCounter statHandler = player.getStats(); + for (Stat stat : ((StatsCounterAccessor) statHandler).getStatMap().keySet()) { + player.resetStat(stat); + } + statHandler.markAllDirty(); + + JsonObject clearedAdvancementsData = new JsonObject(); + JsonObject advancementsData = new JsonObject(); + try { + Files.createDirectories(advancementsPath.getParent()); + } catch (Exception e) { + LOGGER.error("Error creating advancements directory: {}", e.getMessage()); + } + try (FileReader advancementsReader = new FileReader(advancementsPath.toFile())) { + JsonElement parsed = JsonParser.parseReader(advancementsReader); + if (parsed != null && parsed.isJsonObject()) { + advancementsData = parsed.getAsJsonObject(); + } + } catch (Exception ignored) { + } + if (advancementsData.has("DataVersion")) { + clearedAdvancementsData.add("DataVersion", advancementsData.get("DataVersion")); + } + try (BufferedWriter advancementsWriter = Files.newBufferedWriter(advancementsPath)) { + advancementsWriter.write(clearedAdvancementsData.toString()); + } catch (Exception e) { + LOGGER.error("Error writing cleared advancements: {}", e.getMessage()); + } + player.getAdvancements().reload(player.level().getServer().getAdvancements()); + + String dimensionStr = world.equals("overworld") ? "minecraft:overworld" : NAMESPACE + ":" + world; + String[] strArr = dimensionStr.split(":"); + ResourceKey key = ResourceKey.create(Registries.DIMENSION, + Identifier.fromNamespaceAndPath(strArr[0], strArr[1])); + + MinecraftServer server = player.level().getServer(); + ServerLevel serverWorld = server.getLevel(key); + + if (serverWorld != null) { + BlockPos spawnPos = serverWorld.getRespawnData().pos(); + Vec3 worldSpawn = Vec3.atBottomCenterOf(spawnPos); + player.teleport(new TeleportTransition(serverWorld, worldSpawn, Vec3.ZERO, 0.0F, 0.0F, + TeleportTransition.DO_NOTHING)); + syncCustomWorldTimeToClient(player); + + GameType gameMode = ((ServerLevelAccessor) serverWorld).getWorldProperties().getGameType(); + player.setGameMode(gameMode == GameType.CREATIVE ? GameType.SURVIVAL : GameType.CREATIVE); + player.setGameMode(gameMode); + + forceAdvancementClientRefresh(player, true); + } + } + + private static void forceAdvancementClientRefresh(ServerPlayer player, boolean hardResetClientCache) { + forceAdvancementClientRefreshNow(player, hardResetClientCache); + + MinecraftServer server = player.level().getServer(); + Arrays.asList(1, 5, 20).forEach(offset -> { + int refreshTick = server.getTickCount() + offset; + server.schedule(new TickTask(refreshTick, () -> { + ServerPlayer currentPlayer = server.getPlayerList().getPlayer(player.getUUID()); + if (currentPlayer != null) { + forceAdvancementClientRefreshNow(currentPlayer, false); + } + })); + }); + } + + private static void forceAdvancementClientRefreshNow(ServerPlayer player, boolean hardResetClientCache) { + if (hardResetClientCache) { + player.connection.send(new ClientboundUpdateAdvancementsPacket( + true, + List.of(), + Set.of(), + Map.of(), + true)); + player.connection.send(new ClientboundSelectAdvancementsTabPacket(null)); + } + + PlayerAdvancementsAccessor advancements = (PlayerAdvancementsAccessor) player.getAdvancements(); + player.level().getServer().getAdvancements().getAllAdvancements() + .forEach(advancements::invokeMarkForVisibilityUpdate); + advancements.setFirstPacket(true); + player.getAdvancements().setSelectedTab(null); + player.getAdvancements().flushDirty(player, true); + } + + private static void syncCustomWorldTimeToClient(ServerPlayer player) { + ServerLevel level = (ServerLevel) player.level(); + if (!NAMESPACE.equals(level.dimension().identifier().getNamespace())) { + return; + } + + player.connection.send(MultiWorld.createTimeSyncPacketForLevel(level)); + + MinecraftServer server = level.getServer(); + int syncTick = server.getTickCount() + 1; + server.schedule(new TickTask(syncTick, () -> { + ServerPlayer currentPlayer = server.getPlayerList().getPlayer(player.getUUID()); + if (currentPlayer != null + && NAMESPACE.equals(currentPlayer.level().dimension().identifier().getNamespace())) { + ServerLevel currentLevel = (ServerLevel) currentPlayer.level(); + currentPlayer.connection.send(MultiWorld.createTimeSyncPacketForLevel(currentLevel)); + } + })); + } +} diff --git a/src/main/java/fr/jeanney/WorldConfigService.java b/src/main/java/fr/jeanney/WorldConfigService.java new file mode 100644 index 0000000..8c6aae2 --- /dev/null +++ b/src/main/java/fr/jeanney/WorldConfigService.java @@ -0,0 +1,383 @@ +package fr.jeanney; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.NbtOps; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.Biomes; +import net.minecraft.world.level.biome.MultiNoiseBiomeSource; +import net.minecraft.world.level.biome.MultiNoiseBiomeSourceParameterLists; +import net.minecraft.world.level.biome.TheEndBiomeSource; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.dimension.BuiltinDimensionTypes; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; +import net.minecraft.world.level.levelgen.NoiseGeneratorSettings; +import net.minecraft.world.level.levelgen.flat.FlatLayerInfo; +import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings; +import net.minecraft.world.level.levelgen.FlatLevelSource; +import net.minecraft.world.level.gamerules.GameRules; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +final class WorldConfigService { + static final String CONFIG_FILE_NAME = "multiworld.dat"; + + @FunctionalInterface + interface WorldUnloader { + void unload(MinecraftServer server, ServerLevel world); + } + + @FunctionalInterface + interface WorldCreator { + void create( + MinecraftServer server, + String worldName, + ResourceKey dimensionType, + ChunkGenerator generator, + long seed, + BlockPos spawnPos, + GameType gameMode, + MultiWorld.WorldMode worldMode); + } + + private WorldConfigService() { + } + + static void loadCustomWorlds(MinecraftServer server, WorldUnloader worldUnloader, WorldCreator worldCreator) { + String serverWorldFolder = server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).toString(); + File dimensionsFolder = new File(serverWorldFolder + "/dimensions/" + MultiWorld.NAMESPACE); + + if (!dimensionsFolder.exists() || !dimensionsFolder.isDirectory()) { + MultiWorld.LOGGER.info("No custom dimensions found."); + return; + } + + File[] worldFolders = dimensionsFolder.listFiles(File::isDirectory); + + if (worldFolders == null || worldFolders.length == 0) { + MultiWorld.LOGGER.info("No custom worlds found in dimensions folder."); + return; + } + + for (File worldFolder : worldFolders) { + String worldName = worldFolder.getName(); + + ResourceKey worldKey = ResourceKey.create(Registries.DIMENSION, + Identifier.fromNamespaceAndPath(MultiWorld.NAMESPACE, worldName)); + ServerLevel existingWorld = server.getLevel(worldKey); + + if (existingWorld instanceof CustomServerWorld) { + MultiWorld.LOGGER.info("World '{}' already loaded correctly as CustomServerWorld", worldName); + continue; + } + + if (existingWorld != null) { + worldUnloader.unload(server, existingWorld); + } + + File dataFolder = new File(worldFolder, "data"); + File configFile = new File(dataFolder, CONFIG_FILE_NAME); + + if (!configFile.exists()) { + MultiWorld.LOGGER.warn("Config file not found for world '{}', skipping", worldName); + continue; + } + + try { + CompoundTag configNbt = NbtIo.readCompressed(configFile.toPath(), NbtAccounter.unlimitedHeap()); + + long seed = configNbt.getLong("seed").orElseThrow(); + ResourceKey dimensionType = getDimensionTypeFromString( + configNbt.getString("dimensionType").orElseThrow()); + String generatorType = configNbt.getString("generatorType").orElse("overworld"); + + ChunkGenerator generator = createGeneratorFromType(server, generatorType, configNbt); + + BlockPos spawnPos = null; + if (configNbt.contains("spawnPos")) { + int[] spawnPosArray = configNbt.getIntArray("spawnPos").orElse(new int[] { 0, 64, 0 }); + spawnPos = new BlockPos(spawnPosArray[0], spawnPosArray[1], spawnPosArray[2]); + } + + GameType gameMode = null; + if (configNbt.contains("gameMode")) { + int gameModeIndex = configNbt.getInt("gameMode").orElse(0); + gameMode = GameType.byId(gameModeIndex); + } + + MultiWorld.WorldMode worldMode = configNbt.contains("worldMode") + ? MultiWorld.WorldMode.fromString(configNbt.getString("worldMode").orElse("normal")) + : (gameMode == GameType.CREATIVE ? MultiWorld.WorldMode.CREATIVE : MultiWorld.WorldMode.NORMAL); + + GameRules loadedGameRules = configNbt.contains("gameRules") + ? decodeGameRules(server, configNbt.getCompound("gameRules").orElse(new CompoundTag())) + : new GameRules(server.getWorldData().enabledFeatures()); + + worldCreator.create(server, worldName, dimensionType, generator, seed, spawnPos, gameMode, worldMode); + WorldStateService.registerWorldMode(worldName, worldMode); + + ServerLevel loadedWorld = server.getLevel(worldKey); + if (loadedWorld != null) { + loadedWorld.getGameRules().setAll(loadedGameRules, server); + WorldStateService.setLastSavedGameRuleSnapshot(worldKey, + encodeGameRules(server, loadedGameRules).toString()); + } + + } catch (Exception e) { + MultiWorld.LOGGER.error("Failed to load world '{}'", worldName, e); + } + } + } + + static void saveWorldConfig( + MinecraftServer server, + String worldString, + ResourceKey dimensionType, + ChunkGenerator generator, + String generatorType, + long seed, + BlockPos spawnPos, + GameType gameMode, + MultiWorld.WorldMode worldMode, + GameRules gameRules) { + String serverWorldFolder = server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).toString(); + String dimensionPath = serverWorldFolder + "/dimensions/" + MultiWorld.NAMESPACE + "/" + worldString + "/data"; + File dimensionDataDir = new File(dimensionPath); + + if (!dimensionDataDir.exists() && !dimensionDataDir.mkdirs()) { + MultiWorld.LOGGER.error("Could not create data directory for world '{}'", worldString); + return; + } + + File configFile = new File(dimensionDataDir, CONFIG_FILE_NAME); + + try { + CompoundTag configNbt = new CompoundTag(); + configNbt.putString("dimensionType", dimensionType.identifier().toString()); + configNbt.putLong("seed", seed); + configNbt.putString("generatorType", generatorType); + + if (generatorType.equals("flat") && generator instanceof FlatLevelSource flatGenerator) { + FlatLevelGeneratorSettings flatConfig = flatGenerator.settings(); + + CompoundTag flatConfigNbt = new CompoundTag(); + List layers = flatConfig.getLayersInfo(); + CompoundTag[] layersNbt = new CompoundTag[layers.size()]; + + for (int i = 0; i < layers.size(); i++) { + FlatLayerInfo layer = layers.get(i); + CompoundTag layerNbt = new CompoundTag(); + layerNbt.putInt("height", layer.getHeight()); + layerNbt.putString("block", + BuiltInRegistries.BLOCK.getKey(layer.getBlockState().getBlock()).toString()); + layersNbt[i] = layerNbt; + } + + CompoundTag layersCompound = new CompoundTag(); + for (int i = 0; i < layersNbt.length; i++) { + layersCompound.put("layer" + i, layersNbt[i]); + } + layersCompound.putInt("count", layersNbt.length); + flatConfigNbt.put("layers", layersCompound); + flatConfigNbt.putString("biome", + flatConfig.getBiome().unwrapKey().map(k -> k.identifier().toString()).orElseThrow()); + configNbt.put("flatConfig", flatConfigNbt); + } + + if (spawnPos != null) { + configNbt.putIntArray("spawnPos", new int[] { spawnPos.getX(), spawnPos.getY(), spawnPos.getZ() }); + } + + if (gameMode != null) { + configNbt.putInt("gameMode", gameMode.getId()); + } + + if (worldMode != null) { + configNbt.putString("worldMode", worldMode.toConfigValue()); + } + + if (gameRules != null) { + configNbt.put("gameRules", encodeGameRules(server, gameRules)); + } + + NbtIo.writeCompressed(configNbt, configFile.toPath()); + } catch (IOException e) { + MultiWorld.LOGGER.error("Failed to save world configuration for '{}': {}", worldString, e.getMessage()); + } + } + + static void autosaveCustomWorldGameRules(MinecraftServer server) { + if (!WorldStateService.shouldAutosaveGameRulesThisTick()) { + return; + } + persistCustomWorldGameRules(server, false); + } + + static void persistCustomWorldGameRules(MinecraftServer server, boolean force) { + for (ServerLevel world : server.getAllLevels()) { + if (!MultiWorld.NAMESPACE.equals(world.dimension().identifier().getNamespace())) { + continue; + } + + ResourceKey worldKey = world.dimension(); + CompoundTag encodedRules = encodeGameRules(server, world.getGameRules()); + String currentSnapshot = encodedRules.toString(); + String previousSnapshot = WorldStateService.getLastSavedGameRuleSnapshot(worldKey); + if (!force && currentSnapshot.equals(previousSnapshot)) { + continue; + } + + String worldName = world.dimension().identifier().getPath(); + String serverWorldFolder = server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT) + .toString(); + File configFile = new File( + serverWorldFolder + "/dimensions/" + MultiWorld.NAMESPACE + "/" + worldName + "/data/" + + CONFIG_FILE_NAME); + if (!configFile.exists()) { + continue; + } + + try { + CompoundTag configNbt = NbtIo.readCompressed(configFile.toPath(), NbtAccounter.unlimitedHeap()); + configNbt.put("gameRules", encodedRules); + NbtIo.writeCompressed(configNbt, configFile.toPath()); + WorldStateService.setLastSavedGameRuleSnapshot(worldKey, currentSnapshot); + } catch (Exception e) { + MultiWorld.LOGGER.warn("Failed to persist gamerules for world '{}': {}", worldName, e.getMessage()); + } + } + } + + static CompoundTag encodeGameRules(MinecraftServer server, GameRules gameRules) { + Object encoded = GameRules.codec(server.getWorldData().enabledFeatures()) + .encodeStart(NbtOps.INSTANCE, gameRules) + .result() + .orElse(null); + if (encoded instanceof CompoundTag compoundTag) { + return compoundTag; + } + return new CompoundTag(); + } + + private static GameRules decodeGameRules(MinecraftServer server, CompoundTag gameRulesTag) { + return GameRules.codec(server.getWorldData().enabledFeatures()) + .parse(NbtOps.INSTANCE, gameRulesTag) + .result() + .orElseGet(() -> new GameRules(server.getWorldData().enabledFeatures())); + } + + static ChunkGenerator createVanillaOverworldGenerator(MinecraftServer server) { + var multiNoiseLookup = server.registryAccess() + .lookupOrThrow(Registries.MULTI_NOISE_BIOME_SOURCE_PARAMETER_LIST); + var noiseSettingsLookup = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS); + return new NoiseBasedChunkGenerator( + MultiNoiseBiomeSource + .createFromPreset(multiNoiseLookup.getOrThrow(MultiNoiseBiomeSourceParameterLists.OVERWORLD)), + noiseSettingsLookup.getOrThrow(NoiseGeneratorSettings.OVERWORLD)); + } + + static ChunkGenerator createVanillaNetherGenerator(MinecraftServer server) { + var multiNoiseLookup = server.registryAccess() + .lookupOrThrow(Registries.MULTI_NOISE_BIOME_SOURCE_PARAMETER_LIST); + var noiseSettingsLookup = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS); + return new NoiseBasedChunkGenerator( + MultiNoiseBiomeSource + .createFromPreset(multiNoiseLookup.getOrThrow(MultiNoiseBiomeSourceParameterLists.NETHER)), + noiseSettingsLookup.getOrThrow(NoiseGeneratorSettings.NETHER)); + } + + static ChunkGenerator createVanillaEndGenerator(MinecraftServer server) { + var biomeLookup = server.registryAccess().lookupOrThrow(Registries.BIOME); + var noiseSettingsLookup = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS); + return new NoiseBasedChunkGenerator( + TheEndBiomeSource.create(biomeLookup), + noiseSettingsLookup.getOrThrow(NoiseGeneratorSettings.END)); + } + + private static ResourceKey getDimensionTypeFromString(String dimensionTypeString) { + if (dimensionTypeString == null || dimensionTypeString.isEmpty()) { + return BuiltinDimensionTypes.OVERWORLD; + } + + String[] parts = dimensionTypeString.split(":"); + if (parts.length != 2) { + return BuiltinDimensionTypes.OVERWORLD; + } + + return ResourceKey.create(Registries.DIMENSION_TYPE, Identifier.fromNamespaceAndPath(parts[0], parts[1])); + } + + private static ChunkGenerator createGeneratorFromType(MinecraftServer server, String generatorType, + CompoundTag configNbt) { + var biomeLookup = server.registryAccess().lookupOrThrow(Registries.BIOME); + + return switch (generatorType) { + case "nether" -> createVanillaNetherGenerator(server); + case "end" -> createVanillaEndGenerator(server); + case "flat" -> { + if (configNbt.contains("flatConfig")) { + try { + CompoundTag flatConfigNbt = configNbt.getCompound("flatConfig").orElseThrow(); + CompoundTag layersCompound = flatConfigNbt.getCompound("layers").orElseThrow(); + int layerCount = layersCompound.getInt("count").orElse(0); + + List layers = new ArrayList<>(); + for (int i = 0; i < layerCount; i++) { + CompoundTag layerNbt = layersCompound.getCompound("layer" + i).orElseThrow(); + int thickness = layerNbt.getInt("height").orElseThrow(); + String blockId = layerNbt.getString("block").orElseThrow(); + Identifier blockIdentifier = Identifier.tryParse(blockId); + if (blockIdentifier == null) { + throw new IllegalArgumentException("Invalid block identifier: " + blockId); + } + + Block block = server.registryAccess().lookupOrThrow(Registries.BLOCK) + .getOrThrow(ResourceKey.create(Registries.BLOCK, blockIdentifier)).value(); + layers.add(new FlatLayerInfo(thickness, block)); + } + + String biomeId = flatConfigNbt.getString("biome").orElse("minecraft:plains"); + Identifier biomeIdentifier = Identifier.tryParse(biomeId); + Holder biome = biomeLookup + .getOrThrow(ResourceKey.create(Registries.BIOME, biomeIdentifier)); + + yield new FlatLevelSource( + new FlatLevelGeneratorSettings(Optional.empty(), biome, List.of()) + .withBiomeAndLayers(layers, Optional.empty(), biome)); + } catch (Exception e) { + MultiWorld.LOGGER.error("Error reconstructing flat generator", e); + } + } + Holder plainsBiome = biomeLookup.getOrThrow(Biomes.PLAINS); + yield new FlatLevelSource( + new FlatLevelGeneratorSettings(Optional.empty(), plainsBiome, List.of()) + .withBiomeAndLayers( + List.of( + new FlatLayerInfo(1, Blocks.BEDROCK), + new FlatLayerInfo(61, Blocks.DIRT), + new FlatLayerInfo(1, Blocks.GRASS_BLOCK)), + Optional.empty(), + plainsBiome)); + } + default -> createVanillaOverworldGenerator(server); + }; + } +} diff --git a/src/main/java/fr/jeanney/WorldImportSupport.java b/src/main/java/fr/jeanney/WorldImportSupport.java new file mode 100644 index 0000000..a100d08 --- /dev/null +++ b/src/main/java/fr/jeanney/WorldImportSupport.java @@ -0,0 +1,472 @@ +package fr.jeanney; + +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.LongTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.NbtOps; +import net.minecraft.nbt.Tag; +import net.minecraft.resources.RegistryOps; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.gamerules.GameRules; +import net.minecraft.world.level.levelgen.WorldGenSettings; +import net.minecraft.world.level.saveddata.WeatherData; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; + +final class WorldImportSupport { + + private WorldImportSupport() { + } + + static Optional extractImportedSpawnPos(CompoundTag dataTag) { + Optional spawnCompoundOpt = dataTag.getCompound("spawn"); + if (spawnCompoundOpt.isPresent()) { + CompoundTag spawnCompound = spawnCompoundOpt.get(); + + Optional spawnPosArray = spawnCompound.getIntArray("pos"); + if (spawnPosArray.isPresent() && spawnPosArray.get().length >= 3) { + int[] pos = spawnPosArray.get(); + return Optional.of(new BlockPos(pos[0], pos[1], pos[2])); + } + + Optional spawnX = spawnCompound.getInt("x"); + Optional spawnY = spawnCompound.getInt("y"); + Optional spawnZ = spawnCompound.getInt("z"); + if (spawnX.isPresent() && spawnY.isPresent() && spawnZ.isPresent()) { + return Optional.of(new BlockPos(spawnX.get(), spawnY.get(), spawnZ.get())); + } + } + + if (dataTag.contains("SpawnX") && dataTag.contains("SpawnY") && dataTag.contains("SpawnZ")) { + return Optional.of(new BlockPos( + dataTag.getInt("SpawnX").orElse(0), + dataTag.getInt("SpawnY").orElse(63), + dataTag.getInt("SpawnZ").orElse(0))); + } + + return Optional.empty(); + } + + static File resolveImportSourceWorldFolder(File sourceWorldFolder) { + if (new File(sourceWorldFolder, "level.dat").exists()) { + return sourceWorldFolder; + } + + File parent = sourceWorldFolder.getParentFile(); + if (parent != null && new File(parent, "level.dat").exists()) { + return parent; + } + + File nestedWorld = new File(sourceWorldFolder, "world"); + if (nestedWorld.isDirectory() && new File(nestedWorld, "level.dat").exists()) { + return nestedWorld; + } + + File[] children = sourceWorldFolder.listFiles(File::isDirectory); + if (children != null) { + File singleCandidate = null; + for (File child : children) { + if (new File(child, "level.dat").exists()) { + if (singleCandidate != null) { + singleCandidate = null; + break; + } + singleCandidate = child; + } + } + if (singleCandidate != null) { + return singleCandidate; + } + } + + return sourceWorldFolder; + } + + static File resolveOverworldImportFolder(File sourceWorldFolder) { + File modernOverworld = new File(sourceWorldFolder, "dimensions/minecraft/overworld"); + if (modernOverworld.isDirectory()) { + return modernOverworld; + } + return sourceWorldFolder; + } + + static File resolveNetherImportFolder(File sourceWorldFolder) { + File modernNether = new File(sourceWorldFolder, "dimensions/minecraft/the_nether"); + if (modernNether.isDirectory()) { + return modernNether; + } + + File legacyNether = new File(sourceWorldFolder, "DIM-1"); + return legacyNether.isDirectory() ? legacyNether : null; + } + + static File resolveEndImportFolder(File sourceWorldFolder) { + File modernEnd = new File(sourceWorldFolder, "dimensions/minecraft/the_end"); + if (modernEnd.isDirectory()) { + return modernEnd; + } + + File legacyEnd = new File(sourceWorldFolder, "DIM1"); + return legacyEnd.isDirectory() ? legacyEnd : null; + } + + static GameRules extractImportedGameRules(MinecraftServer server, File sourceWorldFolder, + CompoundTag rawDataTag) { + Optional fromDat = extractGameRulesFromDatFile(server, sourceWorldFolder); + if (fromDat.isPresent()) { + return fromDat.get(); + } + + Optional fromLevelDat = rawDataTag.getCompound("GameRules") + .flatMap(tag -> parseGameRulesTag(server, tag)); + if (fromLevelDat.isPresent()) { + return fromLevelDat.get(); + } + + Optional fromLowerCaseLevelDat = rawDataTag.getCompound("gameRules") + .flatMap(tag -> parseGameRulesTag(server, tag)); + if (fromLowerCaseLevelDat.isPresent()) { + return fromLowerCaseLevelDat.get(); + } + + return new GameRules(server.getWorldData().enabledFeatures()); + } + + private static Optional extractGameRulesFromDatFile(MinecraftServer server, File sourceWorldFolder) { + File gameRulesDat = new File(sourceWorldFolder, "data/minecraft/game_rules.dat"); + if (!gameRulesDat.exists()) { + gameRulesDat = new File(sourceWorldFolder, "data/game_rules.dat"); + if (!gameRulesDat.exists()) { + return Optional.empty(); + } + } + + try { + CompoundTag root = NbtIo.readCompressed(gameRulesDat.toPath(), NbtAccounter.unlimitedHeap()); + CompoundTag dataTag = root.getCompound("data").orElse(root); + + Optional direct = parseGameRulesTag(server, dataTag); + if (direct.isPresent()) { + return direct; + } + + return dataTag.getCompound("rules").flatMap(tag -> parseGameRulesTag(server, tag)); + } catch (Exception ignored) { + return Optional.empty(); + } + } + + private static Optional parseGameRulesTag(MinecraftServer server, CompoundTag gameRulesTag) { + return GameRules.codec(server.getWorldData().enabledFeatures()) + .parse(NbtOps.INSTANCE, gameRulesTag) + .result(); + } + + static long extractSeedFromLevelDat(CompoundTag levelData, MinecraftServer server, File sourceWorldFolder) { + OptionalLong savedDataSeed = extractSeedFromSavedWorldGenData(server, sourceWorldFolder); + if (savedDataSeed.isPresent()) { + return savedDataSeed.getAsLong(); + } + + CompoundTag dataTag = levelData.getCompound("Data").orElse(levelData); + + try { + RegistryOps ops = RegistryOps.create(NbtOps.INSTANCE, server.registryAccess()); + Optional worldGenSettings = dataTag + .read("WorldGenSettings", WorldGenSettings.CODEC, ops); + if (worldGenSettings.isPresent()) { + return worldGenSettings.get().options().seed(); + } + } catch (Exception ignored) { + // Continue with manual fallbacks for legacy or malformed data. + } + + Optional directSeed = dataTag.getCompound("WorldGenSettings") + .flatMap(tag -> tag.getLong("seed")) + .or(() -> dataTag.getLong("RandomSeed")) + .or(() -> dataTag.getLong("Seed")); + if (directSeed.isPresent()) { + return directSeed.get(); + } + + Optional recursiveSeed = findSeedRecursively(dataTag, 0); + if (recursiveSeed.isPresent()) { + return recursiveSeed.get(); + } + + MultiWorld.LOGGER.warn("Could not resolve seed from world data for '{}', using server overworld seed fallback", + sourceWorldFolder.getAbsolutePath()); + return server.overworld().getSeed(); + } + + private static OptionalLong extractSeedFromSavedWorldGenData(MinecraftServer server, File sourceWorldFolder) { + OptionalLong directDatSeed = extractSeedFromWorldGenSettingsDat(sourceWorldFolder); + if (directDatSeed.isPresent()) { + return directDatSeed; + } + + File parent = sourceWorldFolder.getParentFile(); + if (parent == null) { + return OptionalLong.empty(); + } + + try { + var storageSource = net.minecraft.world.level.storage.LevelStorageSource.createDefault(parent.toPath()); + try (var storageAccess = storageSource.createAccess(sourceWorldFolder.getName())) { + var result = net.minecraft.world.level.storage.LevelStorageSource.readExistingSavedData( + storageAccess, + server.registryAccess(), + WorldGenSettings.TYPE); + Optional worldGenSettings = result.result(); + if (worldGenSettings.isPresent()) { + return OptionalLong.of(worldGenSettings.get().options().seed()); + } + } + } catch (Exception ignored) { + return OptionalLong.empty(); + } + + return OptionalLong.empty(); + } + + static Optional extractWeatherFromSavedData(MinecraftServer server, File sourceWorldFolder) { + Optional directDatWeather = extractWeatherFromDatFile(sourceWorldFolder); + if (directDatWeather.isPresent()) { + return directDatWeather; + } + + File parent = sourceWorldFolder.getParentFile(); + if (parent == null) { + return Optional.empty(); + } + + try { + var storageSource = net.minecraft.world.level.storage.LevelStorageSource.createDefault(parent.toPath()); + try (var storageAccess = storageSource.createAccess(sourceWorldFolder.getName())) { + var result = net.minecraft.world.level.storage.LevelStorageSource.readExistingSavedData( + storageAccess, + server.registryAccess(), + WeatherData.TYPE); + return result.result(); + } + } catch (Exception ignored) { + return Optional.empty(); + } + } + + private static OptionalLong extractSeedFromWorldGenSettingsDat(File sourceWorldFolder) { + File worldGenSettingsDat = new File(sourceWorldFolder, "data/minecraft/world_gen_settings.dat"); + if (!worldGenSettingsDat.exists()) { + return OptionalLong.empty(); + } + + try { + CompoundTag root = NbtIo.readCompressed(worldGenSettingsDat.toPath(), NbtAccounter.unlimitedHeap()); + CompoundTag dataTag = root.getCompound("data").orElse(root); + + Optional directSeed = dataTag.getLong("seed") + .or(() -> dataTag.getLong("RandomSeed")) + .or(() -> dataTag.getLong("Seed")); + if (directSeed.isPresent()) { + return OptionalLong.of(directSeed.get()); + } + + Optional recursiveSeed = findSeedRecursively(dataTag, 0); + if (recursiveSeed.isPresent()) { + return OptionalLong.of(recursiveSeed.get()); + } + } catch (Exception ignored) { + return OptionalLong.empty(); + } + + return OptionalLong.empty(); + } + + private static Optional extractWeatherFromDatFile(File sourceWorldFolder) { + File weatherDat = new File(sourceWorldFolder, "data/minecraft/weather.dat"); + if (!weatherDat.exists()) { + return Optional.empty(); + } + + try { + CompoundTag root = NbtIo.readCompressed(weatherDat.toPath(), NbtAccounter.unlimitedHeap()); + CompoundTag dataTag = root.getCompound("data").orElse(root); + + return Optional.of(new WeatherData( + dataTag.getInt("clear_weather_time").or(() -> dataTag.getInt("clearWeatherTime")).orElse(0), + dataTag.getInt("rain_time").or(() -> dataTag.getInt("rainTime")).orElse(0), + dataTag.getInt("thunder_time").or(() -> dataTag.getInt("thunderTime")).orElse(0), + dataTag.getBooleanOr("raining", false), + dataTag.getBooleanOr("thundering", false))); + } catch (Exception ignored) { + return Optional.empty(); + } + } + + static boolean hasWorldClocksDat(File sourceWorldFolder) { + return new File(sourceWorldFolder, "data/minecraft/world_clocks.dat").exists(); + } + + static OptionalLong extractOverworldClockTicksFromDat(File sourceWorldFolder) { + File clocksDat = new File(sourceWorldFolder, "data/minecraft/world_clocks.dat"); + if (!clocksDat.exists()) { + return OptionalLong.empty(); + } + + try { + CompoundTag root = NbtIo.readCompressed(clocksDat.toPath(), NbtAccounter.unlimitedHeap()); + CompoundTag dataTag = root.getCompound("data").orElse(root); + + OptionalLong direct = dataTag.getCompound("minecraft:overworld") + .flatMap(clockTag -> clockTag.getLong("total_ticks")) + .map(OptionalLong::of) + .orElse(OptionalLong.empty()); + if (direct.isPresent()) { + return direct; + } + + return findClockTicksRecursively(dataTag, "minecraft:overworld", 0); + } catch (Exception ignored) { + return OptionalLong.empty(); + } + } + + private static OptionalLong findClockTicksRecursively(CompoundTag tag, String keyName, int depth) { + if (depth > 16) { + return OptionalLong.empty(); + } + + for (Map.Entry entry : tag.entrySet()) { + String key = entry.getKey(); + Tag value = entry.getValue(); + if (!(value instanceof CompoundTag nested)) { + continue; + } + + if (keyName.equals(key)) { + Optional ticks = nested.getLong("total_ticks"); + if (ticks.isPresent()) { + return OptionalLong.of(ticks.get()); + } + } + + OptionalLong nestedResult = findClockTicksRecursively(nested, keyName, depth + 1); + if (nestedResult.isPresent()) { + return nestedResult; + } + } + + return OptionalLong.empty(); + } + + private static Optional findSeedRecursively(CompoundTag tag, int depth) { + if (depth > 16) { + return Optional.empty(); + } + + for (Map.Entry entry : tag.entrySet()) { + String key = entry.getKey(); + Tag value = entry.getValue(); + + if (value instanceof LongTag(long value1) + && ("seed".equalsIgnoreCase(key) || "randomseed".equalsIgnoreCase(key))) { + return Optional.of(value1); + } + + if (value instanceof CompoundTag compoundTag) { + Optional nestedSeed = findSeedRecursively(compoundTag, depth + 1); + if (nestedSeed.isPresent()) { + return nestedSeed; + } + } + } + + return Optional.empty(); + } + + static void updateDimensionInTag(CompoundTag parentTag, String tagName, String worldName) { + Optional tagOpt = parentTag.getCompound(tagName); + if (tagOpt.isPresent()) { + CompoundTag tag = tagOpt.get(); + String dimension = tag.getString("dimension").orElse(Level.OVERWORLD.identifier().toString()); + tag.putString("dimension", convertDimensionName(dimension, worldName)); + parentTag.put(tagName, tag); + } + } + + static String convertDimensionName(String originalDimension, String worldName) { + String newDimension; + if (originalDimension.endsWith("_nether")) { + newDimension = worldName + "_nether"; + } else if (originalDimension.endsWith("_end")) { + newDimension = worldName + "_end"; + } else { + newDimension = worldName; + } + return MultiWorld.NAMESPACE + ":" + newDimension; + } + + static void copyWorldFiles(File sourceFolder, String serverWorldFolder, ResourceKey worldKey) { + String worldDirectory = buildWorldDirectoryPath(serverWorldFolder, worldKey); + + copyFilesBeforeWorldCreation(sourceFolder, "region", worldDirectory); + copyFilesBeforeWorldCreation(sourceFolder, "poi", worldDirectory); + copyFilesBeforeWorldCreation(sourceFolder, "entities", worldDirectory); + copyFilesBeforeWorldCreation(sourceFolder, "data", worldDirectory); + } + + static String buildWorldDirectoryPath(String serverWorldFolder, ResourceKey worldKey) { + return serverWorldFolder + "/dimensions/" + + worldKey.identifier().getNamespace() + "/" + worldKey.identifier().getPath(); + } + + static void copyFilesBeforeWorldCreation(File sourceFolder, String type, String destinationPath) { + File sourceTypeFolder = new File(sourceFolder, type); + if (!sourceTypeFolder.exists() || !sourceTypeFolder.isDirectory()) { + return; + } + + String fileExtension = type.equals("data") ? ".dat" : ".mca"; + File destinationFolder = new File(destinationPath + "/" + type); + if (!destinationFolder.exists() && !destinationFolder.mkdirs()) { + MultiWorld.LOGGER.error("Could not create destination folder: {}", destinationFolder); + return; + } + + int copiedFiles = 0; + try (var walk = Files.walk(sourceTypeFolder.toPath())) { + for (Path sourcePath : walk.filter(Files::isRegularFile).toList()) { + if (!sourcePath.getFileName().toString().endsWith(fileExtension)) { + continue; + } + + Path relativePath = sourceTypeFolder.toPath().relativize(sourcePath); + Path targetPath = destinationFolder.toPath().resolve(relativePath); + if (targetPath.getParent() != null) { + Files.createDirectories(targetPath.getParent()); + } + + Files.copy(sourcePath, targetPath, StandardCopyOption.REPLACE_EXISTING); + copiedFiles++; + } + } catch (IOException e) { + MultiWorld.LOGGER.error("Error copying {} files from {}: {}", type, sourceTypeFolder, e.getMessage()); + return; + } + + if (copiedFiles > 0) { + MultiWorld.LOGGER.info("Copied {} files from {} to {}", copiedFiles, type, destinationPath + "/" + type); + } + } +} diff --git a/src/main/java/fr/jeanney/WorldLifecycleService.java b/src/main/java/fr/jeanney/WorldLifecycleService.java new file mode 100644 index 0000000..09fea50 --- /dev/null +++ b/src/main/java/fr/jeanney/WorldLifecycleService.java @@ -0,0 +1,332 @@ +package fr.jeanney; + +import fr.jeanney.mixin.IMinecraftServerMixin; +import fr.jeanney.mixin.ServerLevelAccessor; +import net.minecraft.core.BlockPos; +import net.minecraft.core.GlobalPos; +import net.minecraft.core.Holder; +import net.minecraft.core.MappedRegistry; +import net.minecraft.core.RegistrationInfo; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.ProgressListener; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.Biomes; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.dimension.BuiltinDimensionTypes; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.dimension.LevelStem; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.flat.FlatLayerInfo; +import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings; +import net.minecraft.world.level.levelgen.FlatLevelSource; +import net.minecraft.world.level.storage.LevelData; +import net.minecraft.world.level.block.Blocks; +import org.jspecify.annotations.NonNull; + +import java.io.File; +import java.util.List; +import java.util.Optional; + +import static fr.jeanney.CustomServerWorld.getBaseWorldName; + +final class WorldLifecycleService { + private WorldLifecycleService() { + } + + static void createWorldWithDimensions( + MinecraftServer server, + String worldString, + long seed, + BlockPos spawnPos, + String preset, + MultiWorld.WorldMode worldMode, + WorldStateService.WorldInitialState initialState) { + ChunkGenerator overworldGenerator; + ChunkGenerator netherGenerator; + ChunkGenerator endGenerator; + + boolean flatPreset = "flat".equalsIgnoreCase(preset); + if (flatPreset) { + var biomeLookup = server.registryAccess().lookupOrThrow(Registries.BIOME); + Holder voidBiome = biomeLookup.getOrThrow(Biomes.THE_VOID); + + overworldGenerator = new FlatLevelSource( + new FlatLevelGeneratorSettings(Optional.empty(), voidBiome, List.of()) + .withBiomeAndLayers( + List.of( + new FlatLayerInfo(1, Blocks.BEDROCK), + new FlatLayerInfo(61, Blocks.DIRT), + new FlatLayerInfo(1, Blocks.GRASS_BLOCK)), + Optional.empty(), + voidBiome)); + netherGenerator = new FlatLevelSource( + new FlatLevelGeneratorSettings(Optional.empty(), voidBiome, List.of()) + .withBiomeAndLayers(List.of(), Optional.empty(), voidBiome)); + endGenerator = new FlatLevelSource( + new FlatLevelGeneratorSettings(Optional.empty(), voidBiome, List.of()) + .withBiomeAndLayers(List.of(), Optional.empty(), voidBiome)); + } else { + overworldGenerator = WorldConfigService.createVanillaOverworldGenerator(server); + netherGenerator = WorldConfigService.createVanillaNetherGenerator(server); + endGenerator = WorldConfigService.createVanillaEndGenerator(server); + } + + GameType gameMode = worldMode == MultiWorld.WorldMode.CREATIVE ? GameType.CREATIVE : null; + BlockPos effectiveSpawnPos = worldMode == MultiWorld.WorldMode.CREATIVE ? BlockPos.ZERO : spawnPos; + + ResourceKey overworldDimensionType = BuiltinDimensionTypes.OVERWORLD; + if (worldMode == MultiWorld.WorldMode.CREATIVE) { + try { + server.registryAccess().lookupOrThrow(Registries.DIMENSION_TYPE) + .getOrThrow(MultiWorld.DEFAULT_DIM_TYPE); + overworldDimensionType = MultiWorld.DEFAULT_DIM_TYPE; + } catch (Exception e) { + MultiWorld.LOGGER.warn("Creative dimension type '{}' not found, falling back to vanilla overworld type", + MultiWorld.DEFAULT_DIM_TYPE.identifier()); + } + } + + createWorld(server, worldString, overworldDimensionType, overworldGenerator, seed, effectiveSpawnPos, gameMode, + worldMode); + createWorld(server, worldString + "_nether", BuiltinDimensionTypes.NETHER, netherGenerator, seed, BlockPos.ZERO, + gameMode, worldMode); + createWorld(server, worldString + "_end", BuiltinDimensionTypes.END, endGenerator, seed, BlockPos.ZERO, + gameMode, worldMode); + WorldStateService.applyInitialStateToWorldFamily(server, worldString, initialState); + WorldStateService.registerWorldMode(worldString, worldMode); + } + + static void createWorld( + MinecraftServer server, + String worldString, + ResourceKey dimensionType, + ChunkGenerator generator, + long seed, + BlockPos spawnPos, + GameType gameMode, + MultiWorld.WorldMode worldMode) { + ResourceKey worldKey = ResourceKey.create( + Registries.DIMENSION, + Identifier.fromNamespaceAndPath(MultiWorld.NAMESPACE, worldString)); + + if (server.getLevel(worldKey) != null) { + ServerLevel world = server.getLevel(worldKey); + if (world instanceof CustomServerWorld) { + return; + } else { + for (ServerPlayer player : world.players()) { + PlayerProfileManager.saveInv(player, getBaseWorldName(worldKey.identifier().getPath())); + PlayerProfileManager.loadInv(player, "overworld"); + } + unload(server, world); + } + } + + Holder.Reference dimensionTypeEntry = server.registryAccess() + .lookupOrThrow(Registries.DIMENSION_TYPE).getOrThrow(dimensionType); + + LevelStem options = new LevelStem(dimensionTypeEntry, generator); + + RegistryAccess.Frozen registryManager = server.registries().compositeAccess(); + MappedRegistry dimensionsRegistry = (MappedRegistry) registryManager + .lookupOrThrow(Registries.LEVEL_STEM); + + boolean isFrozen = ((CustomSimpleRegistry) dimensionsRegistry).multiWorld$isFrozen(); + ((CustomSimpleRegistry) dimensionsRegistry).multiWorld$setFrozen(false); + + ResourceKey dimensionKey = ResourceKey.create(Registries.LEVEL_STEM, worldKey.identifier()); + if (!dimensionsRegistry.containsKey(dimensionKey)) { + dimensionsRegistry.register(dimensionKey, options, RegistrationInfo.BUILT_IN); + } + + ((CustomSimpleRegistry) dimensionsRegistry).multiWorld$setFrozen(isFrozen); + + var levelProperties = server.getWorldData().overworldData(); + CustomServerWorldProperties newLevelProperties = new CustomServerWorldProperties(server.getWorldData(), + levelProperties, seed); + + CustomServerWorld.Constructor worldConstructor = CustomServerWorld::new; + CustomServerWorld addedWorld = worldConstructor.create(server, worldKey, options, newLevelProperties, seed); + WorldStateService.initializeCustomWorldState(addedWorld); + + if (gameMode != null) { + ((ServerLevelAccessor) addedWorld).getWorldProperties().setGameType(gameMode); + } + + // Set up dragon fight for custom end worlds. + if (dimensionType.equals(BuiltinDimensionTypes.END)) { + net.minecraft.world.level.dimension.end.EnderDragonFight dragonFight = net.minecraft.world.level.dimension.end.EnderDragonFight + .createDefault(); + dragonFight.init(addedWorld, seed, ServerLevel.END_SPAWN_POINT); + addedWorld.setDragonFight(dragonFight); + } + + ((IMinecraftServerMixin) server).getWorlds().put(addedWorld.dimension(), addedWorld); + + BlockPos resolvedSpawn; + if (spawnPos != null) { + if (worldMode == MultiWorld.WorldMode.CREATIVE && BlockPos.ZERO.equals(spawnPos)) { + int surfaceY = addedWorld.getHeight(Heightmap.Types.MOTION_BLOCKING, spawnPos.getX(), spawnPos.getZ()); + int safeY = surfaceY > addedWorld.getMinY() ? surfaceY : 63; + resolvedSpawn = new BlockPos(spawnPos.getX(), safeY, spawnPos.getZ()); + } else { + // Preserve imported/configured spawn exactly. + resolvedSpawn = spawnPos; + } + } else { + // Use the exact vanilla initial spawn pipeline. + IMinecraftServerMixin.invokeSetupSpawn( + addedWorld, + newLevelProperties, + false, + false, + ((IMinecraftServerMixin) server).multiWorld$getLevelLoadListener()); + LevelData.RespawnData respawnData = newLevelProperties.getRespawnData(); + if (respawnData != null) { + resolvedSpawn = respawnData.pos(); + } else { + resolvedSpawn = BlockPos.ZERO.above(80); + } + } + newLevelProperties.setSpawn(new LevelData.RespawnData(new GlobalPos(worldKey, resolvedSpawn), 0.0F, 0.0F)); + + WorldConfigService.saveWorldConfig( + server, + worldString, + dimensionType, + generator, + generator instanceof FlatLevelSource ? "flat" + : worldString.endsWith("_nether") ? "nether" + : worldString.endsWith("_end") ? "end" : "overworld", + seed, + spawnPos, + gameMode, + worldMode, + addedWorld.getGameRules()); + + MultiWorld.LOGGER.info("Successfully loaded world '{}'", worldString); + } + + static void unload(MinecraftServer server, ServerLevel world) { + ResourceKey dimensionKey = world.dimension(); + + if (((IMinecraftServerMixin) server).getWorlds().remove(dimensionKey, world)) { + world.save(new ProgressListener() { + @Override + public void progressStartNoAbort(@NonNull Component title) { + } + + @Override + public void progressStart(@NonNull Component title) { + } + + @Override + public void progressStage(@NonNull Component task) { + } + + @Override + public void progressStagePercentage(int percentage) { + } + + @Override + public void stop() { + } + }, true, false); + + RegistryAccess.Frozen registryManager = server.registries().compositeAccess(); + MappedRegistry dimensionsRegistry = (MappedRegistry) registryManager + .lookupOrThrow(Registries.LEVEL_STEM); + CustomSimpleRegistry.remove(dimensionsRegistry, dimensionKey.identifier()); + WorldStateService.clearCustomWorldState(dimensionKey); + } + } + + static void deleteWorld(MinecraftServer server, String worldName) { + MultiWorld.LOGGER.info("Deleting world: {}", worldName); + WorldStateService.registerWorldMode(worldName, MultiWorld.WorldMode.NORMAL); + + ResourceKey overWorldKey = ResourceKey.create(Registries.DIMENSION, + Identifier.fromNamespaceAndPath(MultiWorld.NAMESPACE, worldName)); + ResourceKey netherWorldKey = ResourceKey.create(Registries.DIMENSION, + Identifier.fromNamespaceAndPath(MultiWorld.NAMESPACE, worldName + "_nether")); + ResourceKey endWorldKey = ResourceKey.create(Registries.DIMENSION, + Identifier.fromNamespaceAndPath(MultiWorld.NAMESPACE, worldName + "_end")); + + ServerLevel overworld = server.getLevel(overWorldKey); + ServerLevel nether = server.getLevel(netherWorldKey); + ServerLevel end = server.getLevel(endWorldKey); + + RegistryAccess.Frozen registryManager = server.registries().compositeAccess(); + MappedRegistry dimensionsRegistry = (MappedRegistry) registryManager + .lookupOrThrow(Registries.LEVEL_STEM); + + if (overworld != null) { + CustomSimpleRegistry.remove(dimensionsRegistry, overWorldKey.identifier()); + } + if (nether != null) { + CustomSimpleRegistry.remove(dimensionsRegistry, netherWorldKey.identifier()); + } + if (end != null) { + CustomSimpleRegistry.remove(dimensionsRegistry, endWorldKey.identifier()); + } + + String serverWorldFolder = server.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).toString(); + + if (overworld != null) { + deleteWorldFiles(serverWorldFolder, overWorldKey); + ((IMinecraftServerMixin) server).getWorlds().remove(overWorldKey); + WorldStateService.clearCustomWorldState(overWorldKey); + } + if (nether != null) { + deleteWorldFiles(serverWorldFolder, netherWorldKey); + ((IMinecraftServerMixin) server).getWorlds().remove(netherWorldKey); + WorldStateService.clearCustomWorldState(netherWorldKey); + } + if (end != null) { + deleteWorldFiles(serverWorldFolder, endWorldKey); + ((IMinecraftServerMixin) server).getWorlds().remove(endWorldKey); + WorldStateService.clearCustomWorldState(endWorldKey); + } + + File multiworldFolder = new File(serverWorldFolder, MultiWorld.NAMESPACE); + File worldNameFolder = new File(multiworldFolder, worldName); + if (worldNameFolder.exists()) { + deleteFolder(worldNameFolder); + } + + MultiWorld.LOGGER.info("World deletion completed: {}", worldName); + } + + private static void deleteWorldFiles(String serverWorldFolder, ResourceKey worldKey) { + String worldPath = serverWorldFolder + "/dimensions/" + + worldKey.identifier().getNamespace() + "/" + worldKey.identifier().getPath(); + File worldFolder = new File(worldPath); + + if (worldFolder.exists()) { + deleteFolder(worldFolder); + } + } + + private static void deleteFolder(File folder) { + File[] files = folder.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + deleteFolder(file); + } else { + file.delete(); + } + } + } + folder.delete(); + } +} diff --git a/src/main/java/fr/jeanney/WorldStateService.java b/src/main/java/fr/jeanney/WorldStateService.java new file mode 100644 index 0000000..97dfd8a --- /dev/null +++ b/src/main/java/fr/jeanney/WorldStateService.java @@ -0,0 +1,319 @@ +package fr.jeanney; + +import fr.jeanney.mixin.ServerLevelAccessor; +import net.minecraft.core.Holder; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.ServerScoreboard; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.clock.ServerClockManager; +import net.minecraft.world.clock.WorldClock; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.gamerules.GameRules; +import net.minecraft.world.level.saveddata.WeatherData; +import net.minecraft.world.scores.ScoreboardSaveData; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.stream.StreamSupport; + +import static fr.jeanney.CustomServerWorld.getBaseWorldName; + +final class WorldStateService { + private static final int GAME_RULE_AUTOSAVE_INTERVAL_TICKS = 100; + + private static int gameRuleAutosaveCooldown = GAME_RULE_AUTOSAVE_INTERVAL_TICKS; + private static final Map, String> LAST_SAVED_GAMERULE_SNAPSHOTS = new HashMap<>(); + private static final Map, GameRules> CUSTOM_WORLD_GAMERULES = new HashMap<>(); + private static final Map, WeatherData> CUSTOM_WORLD_WEATHER = new HashMap<>(); + private static final Map, ServerClockManager> CUSTOM_WORLD_CLOCKS = new HashMap<>(); + private static final Map, ServerScoreboard> CUSTOM_WORLD_SCOREBOARDS = new HashMap<>(); + private static final Map PENDING_INITIAL_STATES = new HashMap<>(); + private static final Set CREATIVE_MODE_BASE_WORLDS = new HashSet<>(); + + private WorldStateService() { + } + + static void resetOnServerStarted() { + CREATIVE_MODE_BASE_WORLDS.clear(); + PENDING_INITIAL_STATES.clear(); + } + + static void resetOnServerStopping() { + LAST_SAVED_GAMERULE_SNAPSHOTS.clear(); + CUSTOM_WORLD_GAMERULES.clear(); + CUSTOM_WORLD_WEATHER.clear(); + CUSTOM_WORLD_CLOCKS.clear(); + CUSTOM_WORLD_SCOREBOARDS.clear(); + PENDING_INITIAL_STATES.clear(); + CREATIVE_MODE_BASE_WORLDS.clear(); + gameRuleAutosaveCooldown = GAME_RULE_AUTOSAVE_INTERVAL_TICKS; + } + + static ServerClockManager getClockManagerForLevel(ServerLevel level) { + if (!MultiWorld.NAMESPACE.equals(level.dimension().identifier().getNamespace())) { + return level.getServer().clockManager(); + } + + ResourceKey key = level.dimension(); + return CUSTOM_WORLD_CLOCKS.computeIfAbsent(key, unused -> { + ServerClockManager manager = level.getDataStorage().computeIfAbsent(ServerClockManager.TYPE); + manager.init(level.getServer()); + return manager; + }); + } + + static ServerScoreboard getScoreboardForLevel(ServerLevel level) { + if (!MultiWorld.NAMESPACE.equals(level.dimension().identifier().getNamespace())) { + return level.getServer().getScoreboard(); + } + + ResourceKey key = level.dimension(); + return CUSTOM_WORLD_SCOREBOARDS.computeIfAbsent(key, unused -> { + ServerScoreboard scoreboard = new ServerScoreboard(level.getServer()); + ScoreboardSaveData scoreboardSaveData = level.getDataStorage().computeIfAbsent(ScoreboardSaveData.TYPE); + if (scoreboardSaveData.getData() != null) { + scoreboard.load(scoreboardSaveData.getData()); + } + return scoreboard; + }); + } + + static GameRules getCustomWorldGameRules(ServerLevel level) { + if (!MultiWorld.NAMESPACE.equals(level.dimension().identifier().getNamespace())) { + return null; + } + + ResourceKey key = level.dimension(); + return CUSTOM_WORLD_GAMERULES.computeIfAbsent(key, + unused -> level.getServer().getGameRules().copy(level.getServer().getWorldData().enabledFeatures())); + } + + static WeatherData getCustomWorldWeatherData(ServerLevel level) { + if (!MultiWorld.NAMESPACE.equals(level.dimension().identifier().getNamespace())) { + return null; + } + + ResourceKey key = level.dimension(); + return CUSTOM_WORLD_WEATHER.computeIfAbsent(key, unused -> { + WeatherData base = level.getServer().getWeatherData(); + return new WeatherData( + base.getClearWeatherTime(), + base.getRainTime(), + base.getThunderTime(), + base.isRaining(), + base.isThundering()); + }); + } + + static void setCustomWorldWeather(ResourceKey key, WeatherData weatherData) { + CUSTOM_WORLD_WEATHER.put(key, weatherData); + } + + static void clearCustomWorldState(ResourceKey key) { + CUSTOM_WORLD_GAMERULES.remove(key); + CUSTOM_WORLD_WEATHER.remove(key); + CUSTOM_WORLD_CLOCKS.remove(key); + CUSTOM_WORLD_SCOREBOARDS.remove(key); + LAST_SAVED_GAMERULE_SNAPSHOTS.remove(key); + } + + static void initializeCustomWorldState(ServerLevel level) { + if (!MultiWorld.NAMESPACE.equals(level.dimension().identifier().getNamespace())) { + return; + } + + ResourceKey key = level.dimension(); + CUSTOM_WORLD_GAMERULES.put( + key, + level.getServer().getGameRules().copy(level.getServer().getWorldData().enabledFeatures())); + + WeatherData base = level.getServer().getWeatherData(); + CUSTOM_WORLD_WEATHER.put( + key, + new WeatherData( + base.getClearWeatherTime(), + base.getRainTime(), + base.getThunderTime(), + base.isRaining(), + base.isThundering())); + } + + static void tickCustomWorldClocks(MinecraftServer server) { + for (ServerLevel level : server.getAllLevels()) { + if (!MultiWorld.NAMESPACE.equals(level.dimension().identifier().getNamespace())) { + continue; + } + getClockManagerForLevel(level).tick(); + } + } + + static void setCustomWorldClockTimes(ServerLevel level, long gameTime) { + ServerClockManager clockManager = getClockManagerForLevel(level); + var clockRegistry = level.registryAccess().lookupOrThrow(Registries.WORLD_CLOCK); + clockRegistry.listElements().forEach(clock -> clockManager.setTotalTicks(clock, gameTime)); + } + + static void setOverworldClockTime(ServerLevel level, long clockTicks) { + ServerClockManager clockManager = getClockManagerForLevel(level); + var clockLookup = level.registryAccess().lookupOrThrow(Registries.WORLD_CLOCK); + Holder overworldClock = clockLookup.getOrThrow( + ResourceKey.create(Registries.WORLD_CLOCK, Identifier.fromNamespaceAndPath("minecraft", "overworld"))); + clockManager.setTotalTicks(overworldClock, clockTicks); + } + + private static void setCustomWorldTemporalState(ServerLevel level, WorldInitialState state) { + if (!MultiWorld.NAMESPACE.equals(level.dimension().identifier().getNamespace())) { + return; + } + + ((ServerLevelAccessor) level).getWorldProperties().setGameTime(state.gameTime()); + if (state.overworldClockTicks() >= 0L) { + setOverworldClockTime(level, state.overworldClockTicks()); + } else if (state.preserveSavedWorldClocks()) { + // Ensure saved world_clocks.dat is loaded, but keep its per-clock values. + getClockManagerForLevel(level); + } else { + setCustomWorldClockTimes(level, state.gameTime()); + } + + setCustomWorldWeather( + level.dimension(), + new WeatherData( + state.clearWeatherTime(), + state.rainTime(), + state.thunderTime(), + state.raining(), + state.thundering())); + } + + static void applyInitialStateToWorldFamily(MinecraftServer server, String worldString, WorldInitialState state) { + String[] dimensionNames = { worldString, worldString + "_nether", worldString + "_end" }; + for (String name : dimensionNames) { + ResourceKey key = ResourceKey.create(Registries.DIMENSION, + Identifier.fromNamespaceAndPath(MultiWorld.NAMESPACE, name)); + ServerLevel level = server.getLevel(key); + if (level != null) { + setCustomWorldTemporalState(level, state); + } + } + } + + static WorldInitialState createFreshInitialState(MultiWorld.WorldMode worldMode) { + WeatherData weatherData = new WeatherData(); + long initialGameTime = worldMode == MultiWorld.WorldMode.CREATIVE ? 1000L : 0L; + return new WorldInitialState( + initialGameTime, + weatherData.getClearWeatherTime(), + weatherData.getRainTime(), + weatherData.getThunderTime(), + weatherData.isRaining(), + weatherData.isThundering(), + false, + -1L); + } + + static WorldInitialState createImportedInitialState(CompoundTag dataTag, Optional savedWeather, + boolean preserveSavedWorldClocks, OptionalLong overworldClockTicks) { + long gameTime = dataTag.getLong("Time") + .or(() -> dataTag.getLong("DayTime")) + .orElse(0L); + + WeatherData weatherData = savedWeather.orElse( + new WeatherData( + dataTag.getInt("clearWeatherTime").orElse(0), + dataTag.getInt("rainTime").orElse(0), + dataTag.getInt("thunderTime").orElse(0), + dataTag.getBooleanOr("raining", false), + dataTag.getBooleanOr("thundering", false))); + + return new WorldInitialState( + gameTime, + weatherData.getClearWeatherTime(), + weatherData.getRainTime(), + weatherData.getThunderTime(), + weatherData.isRaining(), + weatherData.isThundering(), + preserveSavedWorldClocks, + overworldClockTicks.orElse(-1L)); + } + + static void rememberPendingInitialState(String worldName, WorldInitialState initialState) { + PENDING_INITIAL_STATES.put(getBaseWorldName(worldName), initialState); + } + + static Optional consumePendingInitialState(String worldName) { + String baseWorldName = getBaseWorldName(worldName); + return Optional.ofNullable(PENDING_INITIAL_STATES.remove(baseWorldName)); + } + + static void registerWorldMode(String worldName, MultiWorld.WorldMode mode) { + String baseWorldName = getBaseWorldName(worldName); + if (mode == MultiWorld.WorldMode.CREATIVE) { + CREATIVE_MODE_BASE_WORLDS.add(baseWorldName); + } else { + CREATIVE_MODE_BASE_WORLDS.remove(baseWorldName); + } + } + + static boolean isCreativeModeWorld(ResourceKey key) { + if (!MultiWorld.NAMESPACE.equals(key.identifier().getNamespace())) { + return false; + } + return CREATIVE_MODE_BASE_WORLDS.contains(getBaseWorldName(key.identifier().getPath())); + } + + static Optional getCreativeWorldName(MinecraftServer server) { + return StreamSupport.stream(server.getAllLevels().spliterator(), false) + .filter(level -> MultiWorld.NAMESPACE.equals(level.dimension().identifier().getNamespace())) + .map(level -> getBaseWorldName(level.dimension().identifier().getPath())) + .distinct() + .filter(CREATIVE_MODE_BASE_WORLDS::contains) + .min(Comparator.naturalOrder()); + } + + static boolean shouldAutosaveGameRulesThisTick() { + if (--gameRuleAutosaveCooldown > 0) { + return false; + } + gameRuleAutosaveCooldown = GAME_RULE_AUTOSAVE_INTERVAL_TICKS; + return true; + } + + static String getLastSavedGameRuleSnapshot(ResourceKey key) { + return LAST_SAVED_GAMERULE_SNAPSHOTS.get(key); + } + + static void setLastSavedGameRuleSnapshot(ResourceKey key, String snapshot) { + LAST_SAVED_GAMERULE_SNAPSHOTS.put(key, snapshot); + } + + static void autosaveCustomWorldScoreboards(MinecraftServer server) { + for (ServerLevel world : server.getAllLevels()) { + if (!MultiWorld.NAMESPACE.equals(world.dimension().identifier().getNamespace())) { + continue; + } + + ServerScoreboard scoreboard = CUSTOM_WORLD_SCOREBOARDS.get(world.dimension()); + if (scoreboard == null) { + continue; + } + + ScoreboardSaveData scoreboardSaveData = world.getDataStorage().computeIfAbsent(ScoreboardSaveData.TYPE); + scoreboard.storeToSaveDataIfDirty(scoreboardSaveData); + } + } + + record WorldInitialState(long gameTime, int clearWeatherTime, int rainTime, int thunderTime, + boolean raining, boolean thundering, boolean preserveSavedWorldClocks, + long overworldClockTicks) { + } +} diff --git a/src/main/java/com/gmail/anthony17j/multiworld/command/CreativeCommand.java b/src/main/java/fr/jeanney/command/CreativeCommand.java similarity index 64% rename from src/main/java/com/gmail/anthony17j/multiworld/command/CreativeCommand.java rename to src/main/java/fr/jeanney/command/CreativeCommand.java index 3c2c64a..87fa155 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/command/CreativeCommand.java +++ b/src/main/java/fr/jeanney/command/CreativeCommand.java @@ -1,6 +1,6 @@ -package com.gmail.anthony17j.multiworld.command; +package fr.jeanney.command; -import com.gmail.anthony17j.multiworld.Utils; +import fr.jeanney.PlayerProfileManager; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.exceptions.CommandSyntaxException; @@ -11,14 +11,15 @@ import java.io.IOException; -import static com.gmail.anthony17j.multiworld.CustomServerWorld.isCreativeWorld; -import static com.gmail.anthony17j.multiworld.MultiWorld.getCreativeWorldName; -import static com.gmail.anthony17j.multiworld.Utils.getMostRecentWorldSaved; -import static com.gmail.anthony17j.multiworld.command.MultiWorldCommand.getSourceWorldName; +import static fr.jeanney.CustomServerWorld.isCreativeWorld; +import static fr.jeanney.MultiWorld.getCreativeWorldName; +import static fr.jeanney.PlayerProfileManager.getMostRecentWorldSaved; +import static fr.jeanney.command.MultiWorldCommand.getSourceWorldName; import static net.minecraft.commands.Commands.literal; public class CreativeCommand { - public static void register(CommandDispatcher dispatcher, CommandBuildContext registryAccess, Commands.CommandSelection environment) { + public static void register(CommandDispatcher dispatcher, CommandBuildContext registryAccess, + Commands.CommandSelection environment) { dispatcher.register(literal("creative").executes(context -> { try { return creative(context); @@ -38,17 +39,18 @@ static int creative(CommandContext context) throws CommandSy String creativeWorld = getCreativeWorldName(context.getSource().getServer()).orElse(null); if (creativeWorld == null) { - context.getSource().sendFailure(net.minecraft.network.chat.Component.literal("No creative-mode world exists. Create one with /mw create [preset] creative [seed].")); + context.getSource().sendFailure(net.minecraft.network.chat.Component.literal( + "No creative-mode world exists. Create one with /mw create [preset] creative [seed].")); return 0; } if (!isCreativeWorld(player.level().dimension())) { - Utils.saveInv(player, sourceWorld); - Utils.loadInv(player, creativeWorld); + PlayerProfileManager.saveInv(player, sourceWorld); + PlayerProfileManager.loadInv(player, creativeWorld); } else { String mostRecentWorldSaved = getMostRecentWorldSaved(player, sourceWorld); - Utils.saveInv(player, creativeWorld); - Utils.loadInv(player, mostRecentWorldSaved); + PlayerProfileManager.saveInv(player, creativeWorld); + PlayerProfileManager.loadInv(player, mostRecentWorldSaved); } return 0; } diff --git a/src/main/java/fr/jeanney/command/MultiWorldCommand.java b/src/main/java/fr/jeanney/command/MultiWorldCommand.java new file mode 100644 index 0000000..1d43f6e --- /dev/null +++ b/src/main/java/fr/jeanney/command/MultiWorldCommand.java @@ -0,0 +1,550 @@ +package fr.jeanney.command; + +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.LongArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import fr.jeanney.MultiWorld; +import fr.jeanney.PlayerProfileManager; +import net.minecraft.commands.CommandBuildContext; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.SharedSuggestionProvider; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.NbtOps; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.saveddata.WeatherData; +import net.minecraft.world.level.storage.LevelResource; +import net.minecraft.world.level.storage.LevelData; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static fr.jeanney.CustomServerWorld.getBaseWorldName; +import static fr.jeanney.MultiWorld.*; +import static net.minecraft.commands.Commands.literal; + +public final class MultiWorldCommand { + public static void register(CommandDispatcher dispatcher, CommandBuildContext registryAccess, + Commands.CommandSelection environment) { + LiteralArgumentBuilder rootCommand = literal("mw") + .executes(ctx -> { + showHelp(ctx.getSource()); + return 1; + }); + + rootCommand.then(literal("tp") + .then(Commands.argument("world", StringArgumentType.word()) + .suggests((ctx, builder) -> SharedSuggestionProvider + .suggest(getAvailableWorlds(ctx.getSource()), builder)) + .executes(ctx -> { + String worldName = StringArgumentType.getString(ctx, "world"); + return teleportCommand(ctx.getSource(), worldName); + }))); + + rootCommand.then(literal("debug") + .executes(ctx -> debugCommand(ctx.getSource(), null)) + .then(Commands.argument("world", StringArgumentType.word()) + .suggests((ctx, builder) -> SharedSuggestionProvider + .suggest(getDebuggableWorlds(ctx.getSource()), builder)) + .executes(ctx -> debugCommand(ctx.getSource(), StringArgumentType.getString(ctx, "world"))))); + + rootCommand.then(literal("import") + .requires(Commands.hasPermission(Commands.LEVEL_OWNERS)) + .then(Commands.argument("folderPath", StringArgumentType.string()) + .suggests((ctx, builder) -> suggestFolderPaths(builder)) + .then(Commands.argument("worldName", StringArgumentType.word()) + .executes(ctx -> { + String folderPath = StringArgumentType.getString(ctx, "folderPath"); + String worldName = StringArgumentType.getString(ctx, "worldName"); + return importCommand(ctx.getSource(), folderPath, worldName); + })))); + + rootCommand.then(literal("create") + .requires(Commands.hasPermission(Commands.LEVEL_OWNERS)) + .then(Commands.argument("worldName", StringArgumentType.word()) + .executes(ctx -> { + String worldName = StringArgumentType.getString(ctx, "worldName"); + long seed = new Random().nextLong(); + return createCommand(ctx.getSource(), worldName, "normal", "normal", seed); + }) + .then(Commands.argument("preset", StringArgumentType.word()) + .suggests((ctx, builder) -> suggestPresets(builder)) + .executes(ctx -> { + String worldName = StringArgumentType.getString(ctx, "worldName"); + String preset = StringArgumentType.getString(ctx, "preset"); + long seed = new Random().nextLong(); + return createCommand(ctx.getSource(), worldName, preset, "normal", seed); + }) + .then(Commands.argument("mode", StringArgumentType.word()) + .suggests((ctx, builder) -> suggestModes(builder)) + .executes(ctx -> { + String worldName = StringArgumentType.getString(ctx, "worldName"); + String preset = StringArgumentType.getString(ctx, "preset"); + String mode = StringArgumentType.getString(ctx, "mode"); + long seed = new Random().nextLong(); + return createCommand(ctx.getSource(), worldName, preset, mode, seed); + }) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .executes(ctx -> { + String worldName = StringArgumentType.getString(ctx, "worldName"); + String preset = StringArgumentType.getString(ctx, "preset"); + String mode = StringArgumentType.getString(ctx, "mode"); + long seed = LongArgumentType.getLong(ctx, "seed"); + return createCommand(ctx.getSource(), worldName, preset, mode, + seed); + })) + .then(Commands.argument("seedText", StringArgumentType.word()) + .executes(ctx -> { + String worldName = StringArgumentType.getString(ctx, "worldName"); + String preset = StringArgumentType.getString(ctx, "preset"); + String mode = StringArgumentType.getString(ctx, "mode"); + long seed = parseSeed( + StringArgumentType.getString(ctx, "seedText")); + return createCommand(ctx.getSource(), worldName, preset, mode, + seed); + })))))); + + rootCommand.then(literal("delete") + .requires(Commands.hasPermission(Commands.LEVEL_OWNERS)) + .then(Commands.argument("worldName", StringArgumentType.word()) + .suggests((ctx, builder) -> SharedSuggestionProvider.suggest( + getAvailableCustomWorlds(ctx.getSource()), + builder)) + .executes(ctx -> { + String worldName = StringArgumentType.getString(ctx, "worldName"); + return deleteCommand(ctx.getSource(), worldName); + }))); + + dispatcher.register(rootCommand); + } + + private static void showHelp(CommandSourceStack source) { + boolean isAdmin = Commands.hasPermission(Commands.LEVEL_OWNERS).test(source); + + source.sendSystemMessage(Component.literal("§6MultiWorld Commands:")); + source.sendSystemMessage(Component.literal("§7/mw tp §8- §7Teleport to a world")); + source.sendSystemMessage(Component.literal("§7/mw debug [world] §8- §7Show world debug information")); + + if (isAdmin) { + source.sendSystemMessage(Component.literal("§7/mw import §8- §7Import world from folder")); + source.sendSystemMessage( + Component.literal("§7/mw create [preset] [mode] [seed] §8- §7Create a new world")); + source.sendSystemMessage(Component.literal("§7/mw delete §8- §7Delete a world")); + } + } + + private static int debugCommand(CommandSourceStack source, String worldName) { + ServerLevel world = worldName == null ? source.getLevel() : resolveWorldByBaseName(source, worldName); + if (world == null) { + source.sendFailure(Component.literal("World '" + worldName + "' not found.")); + return 0; + } + + List lines = collectWorldDebugInfo(source.getServer(), world); + lines.forEach(line -> source.sendSystemMessage(Component.literal(line))); + return 1; + } + + public static List collectWorldDebugInfo(MinecraftServer server, ServerLevel world) { + List lines = new ArrayList<>(); + String baseWorldName = getBaseWorldName(world.dimension().identifier().getPath()); + + lines.add("§6[MW Debug] " + baseWorldName + " (" + world.dimension().identifier() + ")"); + + LevelData.RespawnData respawnData = world.getRespawnData(); + if (respawnData != null) { + BlockPos spawnPos = respawnData.pos(); + lines.add("spawn: X=" + spawnPos.getX() + ", Y=" + spawnPos.getY() + ", Z=" + spawnPos.getZ()); + } else { + lines.add("spawn: "); + } + + lines.add("time: game=" + world.getGameTime() + ", clock=" + world.getDefaultClockTime()); + + WeatherData weatherData = MultiWorld.getCustomWorldWeatherData(world); + if (weatherData == null) { + weatherData = world.getWeatherData(); + } + lines.add("weather: c=" + weatherData.getClearWeatherTime() + + ", r=" + weatherData.getRainTime() + + ", t=" + weatherData.getThunderTime() + + ", rain=" + weatherData.isRaining() + + ", th=" + weatherData.isThundering()); + + lines.add("seed=" + world.getSeed()); + + CompoundTag encodedGameRules = encodeGameRules(server, world.getGameRules()); + int gameRuleCount = encodedGameRules.size(); + int trueRulesCount = (int) encodedGameRules.entrySet().stream() + .filter(entry -> "true".equalsIgnoreCase(entry.getValue().toString())) + .count(); + List sampleTrueRules = encodedGameRules.entrySet().stream() + .filter(entry -> "true".equalsIgnoreCase(entry.getValue().toString())) + .map(java.util.Map.Entry::getKey) + .sorted() + .limit(5) + .toList(); + lines.add("gamerules: total=" + gameRuleCount + ", true=" + trueRulesCount + + (sampleTrueRules.isEmpty() ? "" : ", sampleTrue=" + String.join(",", sampleTrueRules))); + + Path dataMinecraftFolder = resolveDataMinecraftFolder(server, baseWorldName); + lines.add("data/minecraft path: " + dataMinecraftFolder); + if (!Files.isDirectory(dataMinecraftFolder)) { + lines.add("data/minecraft: "); + return lines; + } + + try (Stream paths = Files.list(dataMinecraftFolder)) { + List datFiles = paths + .filter(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".dat")) + .sorted(Comparator.comparing(path -> path.getFileName().toString())) + .toList(); + + lines.add("saved-data files(" + datFiles.size() + "): " + datFiles.stream() + .map(path -> path.getFileName().toString()) + .collect(Collectors.joining(", "))); + + for (Path datFile : datFiles) { + lines.add(" - " + summarizeDatFile(datFile)); + } + } catch (Exception ex) { + lines.add("data/minecraft read error: " + ex.getMessage()); + } + + return lines; + } + + private static ServerLevel resolveWorldByBaseName(CommandSourceStack source, String worldName) { + String baseWorldName = getBaseWorldName(worldName); + if ("overworld".equals(baseWorldName)) { + return source.getServer().overworld(); + } + + ResourceKey worldKey = ResourceKey.create( + Registries.DIMENSION, + Identifier.fromNamespaceAndPath(NAMESPACE, baseWorldName)); + return source.getServer().getLevel(worldKey); + } + + private static List getDebuggableWorlds(CommandSourceStack source) { + return source.levels().stream() + .map(ResourceKey::identifier) + .filter(id -> id.getNamespace().equals(NAMESPACE) || id.getNamespace().equals("minecraft")) + .filter(id -> !id.getPath().endsWith("_nether") && !id.getPath().endsWith("_end")) + .map(Identifier::getPath) + .map(fr.jeanney.CustomServerWorld::getBaseWorldName) + .distinct() + .sorted() + .collect(Collectors.toList()); + } + + private static Path resolveDataMinecraftFolder(MinecraftServer server, String baseWorldName) { + Path worldRoot = server.getWorldPath(LevelResource.ROOT); + if ("overworld".equals(baseWorldName)) { + return worldRoot.resolve("data").resolve("minecraft"); + } + return worldRoot.resolve("dimensions") + .resolve(NAMESPACE) + .resolve(baseWorldName) + .resolve("data") + .resolve("minecraft"); + } + + private static String summarizeDatFile(Path datFile) { + try { + CompoundTag root = NbtIo.readCompressed(datFile, NbtAccounter.unlimitedHeap()); + List rootKeys = root.entrySet().stream() + .map(java.util.Map.Entry::getKey) + .sorted() + .toList(); + + Optional dataCompoundOpt = root.getCompound("data"); + if (dataCompoundOpt.isPresent()) { + CompoundTag dataCompound = dataCompoundOpt.get(); + List dataKeys = dataCompound.entrySet().stream() + .map(java.util.Map.Entry::getKey) + .sorted() + .toList(); + List sampleDataKeys = dataKeys.stream().limit(4).toList(); + return datFile.getFileName() + + " rk=" + rootKeys.size() + + " dk=" + dataKeys.size() + + (sampleDataKeys.isEmpty() ? "" : " sample=" + String.join(",", sampleDataKeys)); + } + + List sampleRootKeys = rootKeys.stream().limit(4).toList(); + return datFile.getFileName() + + " rk=" + rootKeys.size() + + (sampleRootKeys.isEmpty() ? "" : " sample=" + String.join(",", sampleRootKeys)); + } catch (Exception ex) { + return datFile.getFileName() + " "; + } + } + + private static CompoundTag encodeGameRules(MinecraftServer server, + net.minecraft.world.level.gamerules.GameRules gameRules) { + Object encoded = net.minecraft.world.level.gamerules.GameRules.codec(server.getWorldData().enabledFeatures()) + .encodeStart(NbtOps.INSTANCE, gameRules) + .result() + .orElse(null); + if (encoded instanceof CompoundTag compoundTag) { + return compoundTag; + } + return new CompoundTag(); + } + + private static java.util.List getAvailableWorlds(CommandSourceStack source) { + return source.levels().stream() + .map(ResourceKey::identifier) + .filter(id -> id.getNamespace().equals(NAMESPACE) || id.getNamespace().equals("minecraft")) + .filter(id -> !id.getPath().endsWith("_nether") && !id.getPath().endsWith("_end")) + .map(Identifier::getPath) + .filter(path -> !path.equals(getBaseWorldName(getSourceWorldName(source)))) + .collect(Collectors.toList()); + } + + private static java.util.List getAvailableCustomWorlds(CommandSourceStack source) { + return source.levels().stream() + .map(ResourceKey::identifier) + .filter(id -> id.getNamespace().equals(NAMESPACE)) + .filter(id -> !id.getPath().endsWith("_nether") && !id.getPath().endsWith("_end")) + .map(Identifier::getPath) + .collect(Collectors.toList()); + } + + private static CompletableFuture suggestPresets(SuggestionsBuilder builder) { + return SharedSuggestionProvider.suggest(new String[] { "normal", "flat" }, builder); + } + + private static CompletableFuture suggestModes(SuggestionsBuilder builder) { + return SharedSuggestionProvider.suggest(new String[] { "normal", "creative" }, builder); + } + + private static CompletableFuture suggestFolderPaths(SuggestionsBuilder builder) { + String remaining = builder.getRemaining(); + String normalizedInput = remaining == null ? "" : remaining; + boolean quotedInput = normalizedInput.startsWith("\""); + if (quotedInput) { + normalizedInput = normalizedInput.substring(1); + } + + normalizedInput = normalizedInput.replace('\\', '/'); + + Path path = Paths.get(""); + Path inputPath = normalizedInput.isEmpty() ? path : Paths.get(normalizedInput); + boolean trailingSlash = normalizedInput.endsWith("/"); + + Path basePath; + String namePrefix; + if (trailingSlash) { + basePath = inputPath; + namePrefix = ""; + } else { + Path parent = inputPath.getParent(); + basePath = parent == null ? path : parent; + Path fileName = inputPath.getFileName(); + namePrefix = fileName == null ? "" : fileName.toString(); + } + + Path searchPath = basePath.isAbsolute() + ? basePath.normalize() + : path.toAbsolutePath().resolve(basePath).normalize(); + + if (!Files.isDirectory(searchPath)) { + return builder.buildFuture(); + } + + String prefixLower = namePrefix.toLowerCase(); + + try (Stream pathStream = Files.list(searchPath)) { + pathStream + .filter(Files::isDirectory) + .map(Path::getFileName) + .filter(Objects::nonNull) + .map(Path::toString) + .filter(name -> name.toLowerCase().startsWith(prefixLower)) + .sorted() + .limit(200) + .forEach(name -> { + String suggestionPath; + if (basePath.toString().isEmpty()) { + suggestionPath = name; + } else { + suggestionPath = basePath.toString().replace('\\', '/') + "/" + name; + } + + String suggestion = suggestionPath.contains(" ") ? "\"" + suggestionPath + "\"" + : suggestionPath; + builder.suggest(suggestion); + }); + } catch (IOException ignored) { + return builder.buildFuture(); + } + + return builder.buildFuture(); + } + + private static long parseSeed(String value) { + try { + return Long.parseLong(value); + } catch (NumberFormatException ignored) { + return (long) value.hashCode(); + } + } + + private static int teleportCommand(CommandSourceStack source, String worldString) { + worldString = getBaseWorldName(worldString); + ServerLevel worldTarget = source.getServer().overworld(); + if (!worldString.equals("overworld")) { + ResourceKey worldKey = ResourceKey.create( + Registries.DIMENSION, + Identifier.fromNamespaceAndPath(NAMESPACE, worldString)); + worldTarget = source.getServer().getLevel(worldKey); + } + + if (worldTarget == null) { + source.sendFailure(Component.literal("World " + worldString + " not found!")); + return 0; + } + + String sourceWorldName = getSourceWorldName(source); + String targetWorldName = worldTarget.dimension().identifier().getPath(); + + if (sourceWorldName.equals(targetWorldName)) { + source.sendFailure(Component.literal("You are already in this world!")); + return 0; + } + + ServerPlayer player = source.getPlayer(); + + PlayerProfileManager.saveInv(player, sourceWorldName); + PlayerProfileManager.loadInv(player, targetWorldName); + + player.sendSystemMessage(Component.literal("Teleported to " + worldTarget.dimension().identifier() + " !")); + + return 1; + } + + private static int importCommand(CommandSourceStack source, String folderPath, String worldName) { + source.sendSystemMessage(Component.literal("Importing world from " + folderPath + " to " + worldName + "...")); + + try { + if (!importWorld(source.getServer(), folderPath, worldName)) { + source.sendFailure( + Component.literal("Error importing world: invalid source folder or missing level.dat")); + return 0; + } + source.sendSuccess(() -> Component.literal("World imported successfully!"), false); + return 1; + } catch (Exception e) { + source.sendFailure(Component.literal("Error importing world: " + e.getMessage())); + LOGGER.error("Error importing world", e); + return 0; + } + } + + private static int createCommand(CommandSourceStack source, String worldName, String preset, String mode, + long seed) { + ResourceKey worldKey = ResourceKey.create(Registries.DIMENSION, + Identifier.fromNamespaceAndPath(NAMESPACE, worldName)); + if (source.getServer().getLevel(worldKey) != null) { + source.sendFailure(Component.literal("§cA world with this name already exists!")); + return 0; + } + + String normalizedPreset = preset.toLowerCase(); + if (!normalizedPreset.equals("normal") && !normalizedPreset.equals("flat")) { + source.sendFailure(Component.literal("§cInvalid preset. Allowed values: normal, flat")); + return 0; + } + + String normalizedMode = mode.toLowerCase(); + if (!normalizedMode.equals("normal") && !normalizedMode.equals("creative")) { + source.sendFailure(Component.literal("§cInvalid mode. Allowed values: normal, creative")); + return 0; + } + + MultiWorld.WorldMode worldMode = MultiWorld.WorldMode.fromString(normalizedMode); + + source.sendSystemMessage(Component.literal("Creating new world '" + worldName + "' with seed: " + seed + + " (preset=" + normalizedPreset + ", mode=" + normalizedMode + ")")); + + try { + createWorldWithDimensions(source.getServer(), worldName, seed, null, normalizedPreset, worldMode); + source.sendSuccess( + () -> Component.literal( + "§aWorld created successfully! Use §6/mw tp " + worldName + "§a to teleport to it."), + false); + return 1; + } catch (Exception e) { + source.sendFailure(Component.literal("§cError creating world: " + e.getMessage())); + LOGGER.error("Error creating world", e); + return 0; + } + } + + private static int deleteCommand(CommandSourceStack source, String worldName) { + ResourceKey worldKey = ResourceKey.create(Registries.DIMENSION, + Identifier.fromNamespaceAndPath(NAMESPACE, worldName)); + if (source.getServer().getLevel(worldKey) == null) { + source.sendFailure(Component.literal("§cWorld '" + worldName + "' not found!")); + return 0; + } + + for (ServerPlayer player : source.getServer().getPlayerList().getPlayers()) { + String currentWorld = getBaseWorldName(player.level().dimension().identifier().getPath()); + if (currentWorld.equals(worldName)) { + PlayerProfileManager.saveInv(player, currentWorld); + PlayerProfileManager.loadInv(player, "overworld"); + player.sendSystemMessage( + Component.literal("§cWorld being deleted! You've been teleported to the overworld.")); + } + } + + source.sendSystemMessage(Component.literal("§eDeleting world '" + worldName + "'...")); + + try { + deleteWorld(source.getServer(), worldName); + source.sendSuccess(() -> Component.literal("§aWorld '" + worldName + "' has been successfully deleted."), + false); + return 1; + } catch (Exception e) { + source.sendFailure(Component.literal("§cError deleting world: " + e.getMessage())); + LOGGER.error("Error deleting world", e); + return 0; + } + } + + public static @NotNull String getSourceWorldName(CommandSourceStack source) { + ServerLevel sourceWorld = source.getLevel(); + ResourceKey sourceWorldKey = sourceWorld.dimension() == Level.END + || sourceWorld.dimension() == Level.NETHER ? Level.OVERWORLD : sourceWorld.dimension(); + String sourceWorldName = sourceWorldKey.identifier().getPath(); + return getBaseWorldName(sourceWorldName); + } +} diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/BaseFireBlockMixin.java b/src/main/java/fr/jeanney/mixin/BaseFireBlockMixin.java similarity index 88% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/BaseFireBlockMixin.java rename to src/main/java/fr/jeanney/mixin/BaseFireBlockMixin.java index c5e5572..5462458 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/BaseFireBlockMixin.java +++ b/src/main/java/fr/jeanney/mixin/BaseFireBlockMixin.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; import net.minecraft.resources.ResourceKey; import net.minecraft.world.level.Level; @@ -8,7 +8,7 @@ import java.util.Objects; -import static com.gmail.anthony17j.multiworld.MultiWorld.NAMESPACE; +import static fr.jeanney.MultiWorld.NAMESPACE; @Mixin(BaseFireBlock.class) public abstract class BaseFireBlockMixin { diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/ChangeDimensionTriggerMixin.java b/src/main/java/fr/jeanney/mixin/ChangeDimensionTriggerMixin.java similarity index 81% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/ChangeDimensionTriggerMixin.java rename to src/main/java/fr/jeanney/mixin/ChangeDimensionTriggerMixin.java index b338bbc..4c7a57a 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/ChangeDimensionTriggerMixin.java +++ b/src/main/java/fr/jeanney/mixin/ChangeDimensionTriggerMixin.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; import net.minecraft.advancements.criterion.ChangeDimensionTrigger; import net.minecraft.resources.ResourceKey; @@ -10,16 +10,18 @@ import java.util.Optional; -import static com.gmail.anthony17j.multiworld.CustomServerWorld.isCustomEndWorld; -import static com.gmail.anthony17j.multiworld.CustomServerWorld.isCustomNetherWorld; +import static fr.jeanney.CustomServerWorld.isCustomEndWorld; +import static fr.jeanney.CustomServerWorld.isCustomNetherWorld; @Mixin(ChangeDimensionTrigger.TriggerInstance.class) public class ChangeDimensionTriggerMixin { @Final - @Shadow private Optional> from; + @Shadow + private Optional> from; @Final - @Shadow private Optional> to; + @Shadow + private Optional> to; /** * @author Uxzylon diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/EndPortalBlockMixin.java b/src/main/java/fr/jeanney/mixin/EndPortalBlockMixin.java similarity index 68% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/EndPortalBlockMixin.java rename to src/main/java/fr/jeanney/mixin/EndPortalBlockMixin.java index ba34b4d..54f2232 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/EndPortalBlockMixin.java +++ b/src/main/java/fr/jeanney/mixin/EndPortalBlockMixin.java @@ -1,6 +1,6 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; -import com.gmail.anthony17j.multiworld.MultiWorld; +import fr.jeanney.MultiWorld; import net.minecraft.resources.Identifier; import net.minecraft.resources.ResourceKey; import net.minecraft.server.MinecraftServer; @@ -22,28 +22,23 @@ import java.util.Objects; -import static com.gmail.anthony17j.multiworld.CustomServerWorld.isCustomEndWorld; -import static com.gmail.anthony17j.multiworld.CustomServerWorld.isWorldWithEnd; -import static com.gmail.anthony17j.multiworld.MultiWorld.NAMESPACE; +import static fr.jeanney.CustomServerWorld.isCustomEndWorld; +import static fr.jeanney.CustomServerWorld.isWorldWithEnd; +import static fr.jeanney.MultiWorld.NAMESPACE; import static net.minecraft.core.registries.Registries.DIMENSION; @Mixin(EndPortalBlock.class) public abstract class EndPortalBlockMixin { @Inject(at = @At("HEAD"), method = "entityInside", cancellable = true) - public void entityInside(BlockState state, Level world, BlockPos pos, Entity entity, InsideBlockEffectApplier handler, boolean bl, CallbackInfo ci) { + public void entityInside(BlockState state, Level world, BlockPos pos, Entity entity, + InsideBlockEffectApplier handler, boolean bl, CallbackInfo ci) { if (!isWorldWithEnd(entity.level().dimension())) { ci.cancel(); } } - @Redirect( - method = "entityInside", - at = @At( - value = "INVOKE", - target = "Lnet/minecraft/world/level/Level;dimension()Lnet/minecraft/resources/ResourceKey;" - ) - ) + @Redirect(method = "entityInside", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/Level;dimension()Lnet/minecraft/resources/ResourceKey;")) private ResourceKey redirectGetRegistryKey(Level world) { ResourceKey originalKey = world.dimension(); @@ -54,12 +49,7 @@ private ResourceKey redirectGetRegistryKey(Level world) { return originalKey; } - @ModifyVariable( - method = "getPortalDestination(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/entity/Entity;Lnet/minecraft/core/BlockPos;)Lnet/minecraft/world/level/portal/TeleportTransition;", - at = @At(value = "STORE"), - ordinal = 0, - name = "bl" - ) + @ModifyVariable(method = "getPortalDestination(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/entity/Entity;Lnet/minecraft/core/BlockPos;)Lnet/minecraft/world/level/portal/TeleportTransition;", at = @At(value = "STORE"), ordinal = 0, name = "bl") private boolean modifyEndFlag(boolean bl, ServerLevel world, Entity entity, BlockPos pos) { ResourceKey currentWorldKey = world.dimension(); @@ -74,14 +64,9 @@ private boolean modifyEndFlag(boolean bl, ServerLevel world, Entity entity, Bloc return bl; } - @Redirect( - method = "getPortalDestination(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/entity/Entity;Lnet/minecraft/core/BlockPos;)Lnet/minecraft/world/level/portal/TeleportTransition;", - at = @At( - value = "INVOKE", - target = "Lnet/minecraft/server/MinecraftServer;getLevel(Lnet/minecraft/resources/ResourceKey;)Lnet/minecraft/server/level/ServerLevel;" - ) - ) - private ServerLevel redirectGetWorld(MinecraftServer server, ResourceKey registryKey, ServerLevel world, Entity entity, BlockPos pos) { + @Redirect(method = "getPortalDestination(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/entity/Entity;Lnet/minecraft/core/BlockPos;)Lnet/minecraft/world/level/portal/TeleportTransition;", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;getLevel(Lnet/minecraft/resources/ResourceKey;)Lnet/minecraft/server/level/ServerLevel;")) + private ServerLevel redirectGetWorld(MinecraftServer server, ResourceKey registryKey, ServerLevel world, + Entity entity, BlockPos pos) { ServerLevel targetWorld = server.getLevel(registryKey); ServerLevel initialWorld = entity.level().getServer().getLevel(entity.level().dimension()); @@ -90,8 +75,7 @@ private ServerLevel redirectGetWorld(MinecraftServer server, ResourceKey ResourceKey destinationKey = ResourceKey.create( DIMENSION, - Identifier.fromNamespaceAndPath(NAMESPACE, destinationName) - ); + Identifier.fromNamespaceAndPath(NAMESPACE, destinationName)); ServerLevel destinationWorld = initialWorld.getServer().getLevel(destinationKey); if (destinationWorld != null) { @@ -111,8 +95,7 @@ private ServerLevel redirectGetWorld(MinecraftServer server, ResourceKey if (originName.endsWith("_end")) { destinationName = originName.substring(0, originName.length() - 4); - } - else { + } else { destinationName = originName + "_end"; } return destinationName; diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/EntityMixin.java b/src/main/java/fr/jeanney/mixin/EntityMixin.java similarity index 53% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/EntityMixin.java rename to src/main/java/fr/jeanney/mixin/EntityMixin.java index 902f770..cfbdd0f 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/EntityMixin.java +++ b/src/main/java/fr/jeanney/mixin/EntityMixin.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; import net.minecraft.resources.ResourceKey; import net.minecraft.world.entity.Entity; @@ -9,40 +9,26 @@ import org.spongepowered.asm.mixin.injection.Redirect; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -import static com.gmail.anthony17j.multiworld.CustomServerWorld.getMockRegistryKey; -import static com.gmail.anthony17j.multiworld.CustomServerWorld.isWorldWithNether; +import static fr.jeanney.CustomServerWorld.getMockRegistryKey; +import static fr.jeanney.CustomServerWorld.isWorldWithNether; @Mixin(Entity.class) public abstract class EntityMixin { @Inject(at = @At("HEAD"), method = "handlePortal", cancellable = true) public void handlePortal(CallbackInfo ci) { - Entity self = (Entity)(Object)this; + Entity self = (Entity) (Object) this; if (!isWorldWithNether(self.level().dimension())) { ci.cancel(); } } - @Redirect( - method = "canTeleport", - at = @At( - value = "INVOKE", - target = "Lnet/minecraft/world/level/Level;dimension()Lnet/minecraft/resources/ResourceKey;", - ordinal = 0 - ) - ) + @Redirect(method = "canTeleport", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/Level;dimension()Lnet/minecraft/resources/ResourceKey;", ordinal = 0)) private ResourceKey redirectFromGetRegistryKey(Level world) { return getMockRegistryKey(world.dimension()); } - @Redirect( - method = "canTeleport", - at = @At( - value = "INVOKE", - target = "Lnet/minecraft/world/level/Level;dimension()Lnet/minecraft/resources/ResourceKey;", - ordinal = 1 - ) - ) + @Redirect(method = "canTeleport", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/Level;dimension()Lnet/minecraft/resources/ResourceKey;", ordinal = 1)) private ResourceKey redirectToGetRegistryKey(Level world) { return getMockRegistryKey(world.dimension()); } diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/FallingBlockEntityMixin.java b/src/main/java/fr/jeanney/mixin/FallingBlockEntityMixin.java similarity index 63% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/FallingBlockEntityMixin.java rename to src/main/java/fr/jeanney/mixin/FallingBlockEntityMixin.java index bbb43c7..409b18d 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/FallingBlockEntityMixin.java +++ b/src/main/java/fr/jeanney/mixin/FallingBlockEntityMixin.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; import net.minecraft.resources.ResourceKey; import net.minecraft.world.entity.item.FallingBlockEntity; @@ -7,25 +7,17 @@ import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.ModifyVariable; -import static com.gmail.anthony17j.multiworld.CustomServerWorld.getMockRegistryKey; +import static fr.jeanney.CustomServerWorld.getMockRegistryKey; @Mixin(FallingBlockEntity.class) public abstract class FallingBlockEntityMixin { - @ModifyVariable( - method = "teleport", - at = @At(value = "STORE"), - ordinal = 0 - ) + @ModifyVariable(method = "teleport", at = @At(value = "STORE"), ordinal = 0) private ResourceKey modifyRegistryKey(ResourceKey registryKey) { return getMockRegistryKey(registryKey); } - @ModifyVariable( - method = "teleport", - at = @At(value = "STORE"), - ordinal = 1 - ) + @ModifyVariable(method = "teleport", at = @At(value = "STORE"), ordinal = 1) private ResourceKey modifyRegistryKey2(ResourceKey registryKey2) { return getMockRegistryKey(registryKey2); } diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/IMinecraftServerMixin.java b/src/main/java/fr/jeanney/mixin/IMinecraftServerMixin.java similarity index 87% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/IMinecraftServerMixin.java rename to src/main/java/fr/jeanney/mixin/IMinecraftServerMixin.java index 5cda50d..43e7552 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/IMinecraftServerMixin.java +++ b/src/main/java/fr/jeanney/mixin/IMinecraftServerMixin.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; import net.minecraft.resources.ResourceKey; import net.minecraft.server.MinecraftServer; @@ -22,7 +22,8 @@ public interface IMinecraftServerMixin { LevelStorageSource.LevelStorageAccess getSession(); @Invoker("setInitialSpawn") - static void invokeSetupSpawn(ServerLevel world, ServerLevelData worldProperties, boolean bonusChest, boolean debugWorld, LevelLoadListener loadProgress) { + static void invokeSetupSpawn(ServerLevel world, ServerLevelData worldProperties, boolean bonusChest, + boolean debugWorld, LevelLoadListener loadProgress) { throw new AssertionError(); } diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/MapItemSavedDataMixin.java b/src/main/java/fr/jeanney/mixin/MapItemSavedDataMixin.java similarity index 61% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/MapItemSavedDataMixin.java rename to src/main/java/fr/jeanney/mixin/MapItemSavedDataMixin.java index 299d618..f14b8f2 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/MapItemSavedDataMixin.java +++ b/src/main/java/fr/jeanney/mixin/MapItemSavedDataMixin.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; import net.minecraft.resources.ResourceKey; import net.minecraft.world.level.Level; @@ -10,7 +10,7 @@ import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Redirect; -import static com.gmail.anthony17j.multiworld.CustomServerWorld.getMockRegistryKey; +import static fr.jeanney.CustomServerWorld.getMockRegistryKey; @Mixin(MapItemSavedData.class) public abstract class MapItemSavedDataMixin { @@ -19,13 +19,7 @@ public abstract class MapItemSavedDataMixin { @Shadow public ResourceKey dimension; - @Redirect( - method = "calculateRotation", - at = @At( - value = "FIELD", - target = "Lnet/minecraft/world/level/saveddata/maps/MapItemSavedData;dimension:Lnet/minecraft/resources/ResourceKey;", - opcode = Opcodes.GETFIELD) - ) + @Redirect(method = "calculateRotation", at = @At(value = "FIELD", target = "Lnet/minecraft/world/level/saveddata/maps/MapItemSavedData;dimension:Lnet/minecraft/resources/ResourceKey;", opcode = Opcodes.GETFIELD)) private ResourceKey redirectDimension(MapItemSavedData instance) { return getMockRegistryKey(this.dimension); } diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/MappedRegistryMixin.java b/src/main/java/fr/jeanney/mixin/MappedRegistryMixin.java similarity index 75% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/MappedRegistryMixin.java rename to src/main/java/fr/jeanney/mixin/MappedRegistryMixin.java index 33e6f3e..1000d2d 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/MappedRegistryMixin.java +++ b/src/main/java/fr/jeanney/mixin/MappedRegistryMixin.java @@ -1,7 +1,7 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; -import com.gmail.anthony17j.multiworld.CustomSimpleRegistry; -import com.gmail.anthony17j.multiworld.MultiWorld; +import fr.jeanney.CustomSimpleRegistry; +import fr.jeanney.MultiWorld; import com.llamalad7.mixinextras.injector.ModifyReturnValue; import it.unimi.dsi.fastutil.objects.ObjectList; import it.unimi.dsi.fastutil.objects.Reference2IntMap; @@ -22,14 +22,29 @@ @Mixin(MappedRegistry.class) public abstract class MappedRegistryMixin implements CustomSimpleRegistry, WritableRegistry { - @Shadow private boolean frozen; - @Shadow @Final private Map> byValue; - @Shadow @Final private Reference2IntMap toId; - @Shadow @Final private Map, Holder.Reference> byKey; - @Shadow @Final private Map> byLocation; - @Shadow @Final private ObjectList> byId; - @Shadow @Final private Map, RegistrationInfo> registrationInfos; - @Shadow @Final private ResourceKey> key; + @Shadow + private boolean frozen; + @Shadow + @Final + private Map> byValue; + @Shadow + @Final + private Reference2IntMap toId; + @Shadow + @Final + private Map, Holder.Reference> byKey; + @Shadow + @Final + private Map> byLocation; + @Shadow + @Final + private ObjectList> byId; + @Shadow + @Final + private Map, RegistrationInfo> registrationInfos; + @Shadow + @Final + private ResourceKey> key; @Override public boolean multiWorld$remove(T entry) { diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/NetherPortalBlockMixin.java b/src/main/java/fr/jeanney/mixin/NetherPortalBlockMixin.java similarity index 63% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/NetherPortalBlockMixin.java rename to src/main/java/fr/jeanney/mixin/NetherPortalBlockMixin.java index bc296c0..7da377c 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/NetherPortalBlockMixin.java +++ b/src/main/java/fr/jeanney/mixin/NetherPortalBlockMixin.java @@ -1,6 +1,6 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; -import com.gmail.anthony17j.multiworld.MultiWorld; +import fr.jeanney.MultiWorld; import net.minecraft.resources.Identifier; import net.minecraft.resources.ResourceKey; import net.minecraft.server.MinecraftServer; @@ -14,20 +14,15 @@ import java.util.Objects; -import static com.gmail.anthony17j.multiworld.MultiWorld.NAMESPACE; +import static fr.jeanney.MultiWorld.NAMESPACE; import static net.minecraft.core.registries.Registries.DIMENSION; @Mixin(NetherPortalBlock.class) public abstract class NetherPortalBlockMixin { - @Redirect( - method = "getPortalDestination(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/entity/Entity;Lnet/minecraft/core/BlockPos;)Lnet/minecraft/world/level/portal/TeleportTransition;", - at = @At( - value = "INVOKE", - target = "Lnet/minecraft/server/MinecraftServer;getLevel(Lnet/minecraft/resources/ResourceKey;)Lnet/minecraft/server/level/ServerLevel;" - ) - ) - private ServerLevel redirectGetWorld(MinecraftServer server, ResourceKey registryKey, ServerLevel world, Entity entity) { + @Redirect(method = "getPortalDestination(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/entity/Entity;Lnet/minecraft/core/BlockPos;)Lnet/minecraft/world/level/portal/TeleportTransition;", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;getLevel(Lnet/minecraft/resources/ResourceKey;)Lnet/minecraft/server/level/ServerLevel;")) + private ServerLevel redirectGetWorld(MinecraftServer server, ResourceKey registryKey, ServerLevel world, + Entity entity) { if (Objects.equals(world.dimension().identifier().getNamespace(), NAMESPACE)) { String originName = world.dimension().identifier().getPath(); String destinationName = originName.endsWith("_nether") @@ -36,8 +31,7 @@ private ServerLevel redirectGetWorld(MinecraftServer server, ResourceKey ResourceKey destinationKey = ResourceKey.create( DIMENSION, - Identifier.fromNamespaceAndPath(NAMESPACE, destinationName) - ); + Identifier.fromNamespaceAndPath(NAMESPACE, destinationName)); ServerLevel destinationWorld = server.getLevel(destinationKey); if (destinationWorld != null) { @@ -48,13 +42,7 @@ private ServerLevel redirectGetWorld(MinecraftServer server, ResourceKey return server.getLevel(registryKey); } - @Redirect( - method = "getPortalDestination(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/entity/Entity;Lnet/minecraft/core/BlockPos;)Lnet/minecraft/world/level/portal/TeleportTransition;", - at = @At( - value = "INVOKE", - target = "Lnet/minecraft/server/level/ServerLevel;dimension()Lnet/minecraft/resources/ResourceKey;" - ) - ) + @Redirect(method = "getPortalDestination(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/entity/Entity;Lnet/minecraft/core/BlockPos;)Lnet/minecraft/world/level/portal/TeleportTransition;", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/level/ServerLevel;dimension()Lnet/minecraft/resources/ResourceKey;")) private ResourceKey redirectGetRegistryKey(ServerLevel serverWorld) { ResourceKey originalKey = serverWorld.dimension(); String path = originalKey.identifier().getPath(); diff --git a/src/main/java/fr/jeanney/mixin/PlayerAdvancementsAccessor.java b/src/main/java/fr/jeanney/mixin/PlayerAdvancementsAccessor.java new file mode 100644 index 0000000..356497a --- /dev/null +++ b/src/main/java/fr/jeanney/mixin/PlayerAdvancementsAccessor.java @@ -0,0 +1,21 @@ +package fr.jeanney.mixin; + +import net.minecraft.advancements.AdvancementHolder; +import net.minecraft.server.PlayerAdvancements; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; +import org.spongepowered.asm.mixin.gen.Invoker; + +import java.nio.file.Path; + +@Mixin(PlayerAdvancements.class) +public interface PlayerAdvancementsAccessor { + @Accessor("playerSavePath") + Path getPlayerSavePath(); + + @Accessor("isFirstPacket") + void setFirstPacket(boolean firstPacket); + + @Invoker("markForVisibilityUpdate") + void invokeMarkForVisibilityUpdate(AdvancementHolder advancementHolder); +} diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/PlayerAdvancementsMixin.java b/src/main/java/fr/jeanney/mixin/PlayerAdvancementsMixin.java similarity index 86% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/PlayerAdvancementsMixin.java rename to src/main/java/fr/jeanney/mixin/PlayerAdvancementsMixin.java index 14431e7..e652570 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/PlayerAdvancementsMixin.java +++ b/src/main/java/fr/jeanney/mixin/PlayerAdvancementsMixin.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; import net.minecraft.advancements.AdvancementHolder; import net.minecraft.server.PlayerAdvancements; @@ -9,11 +9,12 @@ import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; -import static com.gmail.anthony17j.multiworld.CustomServerWorld.isCreativeWorld; +import static fr.jeanney.CustomServerWorld.isCreativeWorld; @Mixin(PlayerAdvancements.class) public abstract class PlayerAdvancementsMixin { - @Shadow private ServerPlayer player; + @Shadow + private ServerPlayer player; @Inject(at = @At("HEAD"), method = "award", cancellable = true) public void award(AdvancementHolder advancement, String criterionName, CallbackInfoReturnable cir) { diff --git a/src/main/java/fr/jeanney/mixin/PlayerListMixin.java b/src/main/java/fr/jeanney/mixin/PlayerListMixin.java new file mode 100644 index 0000000..3b476ab --- /dev/null +++ b/src/main/java/fr/jeanney/mixin/PlayerListMixin.java @@ -0,0 +1,54 @@ +package fr.jeanney.mixin; + +import fr.jeanney.MultiWorld; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientboundGameEventPacket; +import net.minecraft.network.protocol.game.ClientboundSetTimePacket; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.players.PlayerList; +import net.minecraft.world.clock.ServerClockManager; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.util.List; + +@Mixin(PlayerList.class) +public abstract class PlayerListMixin { + @Final + @Shadow + private List players; + + @Redirect(method = "sendLevelInfo", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;clockManager()Lnet/minecraft/world/clock/ServerClockManager;")) + private ServerClockManager useTargetLevelClockManager(MinecraftServer server, ServerPlayer player, + ServerLevel level) { + return MultiWorld.getClockManagerForLevel(level); + } + + @Inject(method = "broadcastAll(Lnet/minecraft/network/protocol/Packet;)V", at = @At("HEAD"), cancellable = true) + private void broadcastPerWorldTimeAndWeatherPackets(Packet packet, CallbackInfo ci) { + if (packet instanceof ClientboundSetTimePacket) { + for (ServerPlayer player : this.players) { + ServerLevel level = player.level(); + player.connection.send(MultiWorld.createTimeSyncPacketForLevel(level)); + } + ci.cancel(); + return; + } + + if (packet instanceof ClientboundGameEventPacket gameEventPacket + && MultiWorld.isWeatherGameEvent(gameEventPacket.getEvent())) { + for (ServerPlayer player : this.players) { + ServerLevel level = player.level(); + player.connection.send(MultiWorld.createWeatherSyncPacketForLevel(level, gameEventPacket.getEvent())); + } + ci.cancel(); + } + } +} diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/PlayerSpawnFinderAccessor.java b/src/main/java/fr/jeanney/mixin/PlayerSpawnFinderAccessor.java similarity index 93% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/PlayerSpawnFinderAccessor.java rename to src/main/java/fr/jeanney/mixin/PlayerSpawnFinderAccessor.java index 43f2fd8..4a3f3ed 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/PlayerSpawnFinderAccessor.java +++ b/src/main/java/fr/jeanney/mixin/PlayerSpawnFinderAccessor.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; import net.minecraft.core.BlockPos; import net.minecraft.server.level.PlayerSpawnFinder; diff --git a/src/main/java/fr/jeanney/mixin/ScoreboardCommandMixin.java b/src/main/java/fr/jeanney/mixin/ScoreboardCommandMixin.java new file mode 100644 index 0000000..7fc16ad --- /dev/null +++ b/src/main/java/fr/jeanney/mixin/ScoreboardCommandMixin.java @@ -0,0 +1,19 @@ +package fr.jeanney.mixin; + +import fr.jeanney.MultiWorld; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.ServerScoreboard; +import net.minecraft.server.commands.ScoreboardCommand; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(ScoreboardCommand.class) +public abstract class ScoreboardCommandMixin { + + @Redirect(method = "*", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;getScoreboard()Lnet/minecraft/server/ServerScoreboard;")) + private static ServerScoreboard routeScoreboardToWorldContext(MinecraftServer server, CommandSourceStack source) { + return MultiWorld.getScoreboardForSource(source, server); + } +} diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/ServerLevelAccessor.java b/src/main/java/fr/jeanney/mixin/ServerLevelAccessor.java similarity index 62% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/ServerLevelAccessor.java rename to src/main/java/fr/jeanney/mixin/ServerLevelAccessor.java index 7dc58bd..80e7709 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/ServerLevelAccessor.java +++ b/src/main/java/fr/jeanney/mixin/ServerLevelAccessor.java @@ -1,7 +1,6 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; import net.minecraft.server.level.ServerLevel; -import net.minecraft.world.level.dimension.end.EndDragonFight; import net.minecraft.world.level.storage.ServerLevelData; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.gen.Accessor; @@ -10,7 +9,4 @@ public interface ServerLevelAccessor { @Accessor("serverLevelData") ServerLevelData getWorldProperties(); - - @Accessor("dragonFight") - void setDragonFightAccessor(EndDragonFight dragonFight); } diff --git a/src/main/java/fr/jeanney/mixin/ServerLevelClockMixin.java b/src/main/java/fr/jeanney/mixin/ServerLevelClockMixin.java new file mode 100644 index 0000000..f27a319 --- /dev/null +++ b/src/main/java/fr/jeanney/mixin/ServerLevelClockMixin.java @@ -0,0 +1,22 @@ +package fr.jeanney.mixin; + +import fr.jeanney.MultiWorld; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.clock.ServerClockManager; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import static fr.jeanney.MultiWorld.NAMESPACE; + +@Mixin(ServerLevel.class) +public abstract class ServerLevelClockMixin { + @Inject(method = "clockManager", at = @At("HEAD"), cancellable = true) + private void usePerWorldClockManager(CallbackInfoReturnable cir) { + ServerLevel level = (ServerLevel) (Object) this; + if (NAMESPACE.equals(level.dimension().identifier().getNamespace())) { + cir.setReturnValue(MultiWorld.getClockManagerForLevel(level)); + } + } +} diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/ServerLevelMixin.java b/src/main/java/fr/jeanney/mixin/ServerLevelMixin.java similarity index 54% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/ServerLevelMixin.java rename to src/main/java/fr/jeanney/mixin/ServerLevelMixin.java index a4d4491..f90e317 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/ServerLevelMixin.java +++ b/src/main/java/fr/jeanney/mixin/ServerLevelMixin.java @@ -1,15 +1,17 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; +import fr.jeanney.MultiWorld; import net.minecraft.resources.ResourceKey; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerChunkCache; import net.minecraft.server.level.ServerLevel; -import net.minecraft.world.RandomSequences; +import net.minecraft.world.level.gamerules.GameRules; import net.minecraft.world.level.CustomSpawner; import net.minecraft.world.level.Level; import net.minecraft.world.level.biome.BiomeManager; import net.minecraft.world.level.dimension.LevelStem; import net.minecraft.world.level.levelgen.WorldOptions; +import net.minecraft.world.level.saveddata.WeatherData; import net.minecraft.world.level.storage.LevelData; import net.minecraft.world.level.storage.LevelStorageSource; import net.minecraft.world.level.storage.ServerLevelData; @@ -23,7 +25,7 @@ import java.util.Objects; import java.util.concurrent.Executor; -import static com.gmail.anthony17j.multiworld.MultiWorld.NAMESPACE; +import static fr.jeanney.MultiWorld.NAMESPACE; @Mixin(ServerLevel.class) public abstract class ServerLevelMixin { @@ -31,13 +33,7 @@ public abstract class ServerLevelMixin { @Shadow public abstract ServerChunkCache getChunkSource(); - @ModifyArgs( - method = "", - at = @At( - value = "INVOKE", - target = "Lnet/minecraft/world/level/Level;(Lnet/minecraft/world/level/storage/WritableLevelData;Lnet/minecraft/resources/ResourceKey;Lnet/minecraft/core/RegistryAccess;Lnet/minecraft/core/Holder;ZZJI)V" - ) - ) + @ModifyArgs(method = "", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/Level;(Lnet/minecraft/world/level/storage/WritableLevelData;Lnet/minecraft/resources/ResourceKey;Lnet/minecraft/core/RegistryAccess;Lnet/minecraft/core/Holder;ZZJI)V")) private static void modifySeedForWorldConstructor(Args args) { ResourceKey worldKey = args.get(1); long seed = args.get(6); @@ -47,19 +43,13 @@ private static void modifySeedForWorldConstructor(Args args) { } } - @Redirect( - method = "", - at = @At( - value = "INVOKE", - target = "Lnet/minecraft/world/level/levelgen/WorldOptions;seed()J" - ) - ) + @Redirect(method = "", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/levelgen/WorldOptions;seed()J")) private long redirectGetSeed(WorldOptions instance, - MinecraftServer server, Executor workerExecutor, - LevelStorageSource.LevelStorageAccess session, ServerLevelData properties, - ResourceKey worldKey, LevelStem dimensionOptions, - boolean debugWorld, long seed, List spawners, - boolean shouldTickTime, RandomSequences randomSequences) { + MinecraftServer server, Executor workerExecutor, + LevelStorageSource.LevelStorageAccess session, ServerLevelData properties, + ResourceKey worldKey, LevelStem dimensionOptions, + boolean debugWorld, long seed, List spawners, + boolean shouldTickTime) { if (Objects.equals(worldKey.identifier().getNamespace(), NAMESPACE)) { return seed; } @@ -73,4 +63,22 @@ private void useCustomWorldRespawnData(CallbackInfoReturnable cir) { + ServerLevel level = (ServerLevel) (Object) this; + GameRules customRules = MultiWorld.getCustomWorldGameRules(level); + if (customRules != null) { + cir.setReturnValue(customRules); + } + } + + @Inject(method = "getWeatherData", at = @At("HEAD"), cancellable = true) + private void useCustomWorldWeatherData(CallbackInfoReturnable cir) { + ServerLevel level = (ServerLevel) (Object) this; + WeatherData weatherData = MultiWorld.getCustomWorldWeatherData(level); + if (weatherData != null) { + cir.setReturnValue(weatherData); + } + } } diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/ServerPlayerAccessor.java b/src/main/java/fr/jeanney/mixin/ServerPlayerAccessor.java similarity index 91% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/ServerPlayerAccessor.java rename to src/main/java/fr/jeanney/mixin/ServerPlayerAccessor.java index a66449d..c9636eb 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/ServerPlayerAccessor.java +++ b/src/main/java/fr/jeanney/mixin/ServerPlayerAccessor.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.level.storage.ValueInput; diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/ServerPlayerMixin.java b/src/main/java/fr/jeanney/mixin/ServerPlayerMixin.java similarity index 67% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/ServerPlayerMixin.java rename to src/main/java/fr/jeanney/mixin/ServerPlayerMixin.java index aab6527..61722f1 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/ServerPlayerMixin.java +++ b/src/main/java/fr/jeanney/mixin/ServerPlayerMixin.java @@ -1,6 +1,6 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; -import com.gmail.anthony17j.multiworld.MultiWorld; +import fr.jeanney.MultiWorld; import net.minecraft.resources.ResourceKey; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; @@ -12,22 +12,16 @@ import java.util.Objects; -import static com.gmail.anthony17j.multiworld.CustomServerWorld.getBaseWorldName; -import static com.gmail.anthony17j.multiworld.CustomServerWorld.getRegistryKey; -import static com.gmail.anthony17j.multiworld.MultiWorld.NAMESPACE; +import static fr.jeanney.CustomServerWorld.getBaseWorldName; +import static fr.jeanney.CustomServerWorld.getRegistryKey; +import static fr.jeanney.MultiWorld.NAMESPACE; @Mixin(ServerPlayer.class) public abstract class ServerPlayerMixin { - @Redirect( - method = "findRespawnPositionAndUseSpawnBlock", - at = @At( - value = "INVOKE", - target = "Lnet/minecraft/server/MinecraftServer;getLevel(Lnet/minecraft/resources/ResourceKey;)Lnet/minecraft/server/level/ServerLevel;" - ) - ) + @Redirect(method = "findRespawnPositionAndUseSpawnBlock", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;getLevel(Lnet/minecraft/resources/ResourceKey;)Lnet/minecraft/server/level/ServerLevel;")) private ServerLevel redirectGetWorld(MinecraftServer server, ResourceKey registryKey) { - ServerPlayer player = (ServerPlayer)(Object)this; + ServerPlayer player = (ServerPlayer) (Object) this; ServerLevel currentWorld = player.level(); ServerLevel originalWorld = server.getLevel(registryKey); diff --git a/src/main/java/fr/jeanney/mixin/ServerStatsCounterAccessor.java b/src/main/java/fr/jeanney/mixin/ServerStatsCounterAccessor.java new file mode 100644 index 0000000..d0b8f34 --- /dev/null +++ b/src/main/java/fr/jeanney/mixin/ServerStatsCounterAccessor.java @@ -0,0 +1,13 @@ +package fr.jeanney.mixin; + +import net.minecraft.stats.ServerStatsCounter; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +import java.nio.file.Path; + +@Mixin(ServerStatsCounter.class) +public interface ServerStatsCounterAccessor { + @Accessor("file") + Path getFile(); +} diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/StatsCounterAccessor.java b/src/main/java/fr/jeanney/mixin/StatsCounterAccessor.java similarity index 88% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/StatsCounterAccessor.java rename to src/main/java/fr/jeanney/mixin/StatsCounterAccessor.java index ebfdc37..b32d76d 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/StatsCounterAccessor.java +++ b/src/main/java/fr/jeanney/mixin/StatsCounterAccessor.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; import it.unimi.dsi.fastutil.objects.Object2IntMap; import net.minecraft.stats.Stat; diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/TeleportTransitionAccessor.java b/src/main/java/fr/jeanney/mixin/TeleportTransitionAccessor.java similarity index 91% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/TeleportTransitionAccessor.java rename to src/main/java/fr/jeanney/mixin/TeleportTransitionAccessor.java index ba980c4..d1f762a 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/TeleportTransitionAccessor.java +++ b/src/main/java/fr/jeanney/mixin/TeleportTransitionAccessor.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.Entity; diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/TeleportTransitionMixin.java b/src/main/java/fr/jeanney/mixin/TeleportTransitionMixin.java similarity index 70% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/TeleportTransitionMixin.java rename to src/main/java/fr/jeanney/mixin/TeleportTransitionMixin.java index 7163b3a..63cef61 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/TeleportTransitionMixin.java +++ b/src/main/java/fr/jeanney/mixin/TeleportTransitionMixin.java @@ -1,6 +1,6 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; -import com.gmail.anthony17j.multiworld.MultiWorld; +import fr.jeanney.MultiWorld; import net.minecraft.resources.ResourceKey; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; @@ -15,19 +15,13 @@ import java.util.Objects; -import static com.gmail.anthony17j.multiworld.CustomServerWorld.*; -import static com.gmail.anthony17j.multiworld.MultiWorld.NAMESPACE; +import static fr.jeanney.CustomServerWorld.*; +import static fr.jeanney.MultiWorld.NAMESPACE; @Mixin(TeleportTransition.class) public abstract class TeleportTransitionMixin { - @Redirect( - method = "createDefault", - at = @At( - value = "INVOKE", - target = "Lnet/minecraft/server/MinecraftServer;findRespawnDimension()Lnet/minecraft/server/level/ServerLevel;" - ) - ) + @Redirect(method = "createDefault", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;findRespawnDimension()Lnet/minecraft/server/level/ServerLevel;")) private static ServerLevel redirectGetSpawnWorldInCreateDefault(MinecraftServer server, ServerPlayer player) { ServerLevel currentWorld = player.level(); @@ -46,13 +40,7 @@ private static ServerLevel redirectGetSpawnWorldInCreateDefault(MinecraftServer return server.findRespawnDimension(); } - @Redirect( - method = "missingRespawnBlock", - at = @At( - value = "INVOKE", - target = "Lnet/minecraft/server/MinecraftServer;findRespawnDimension()Lnet/minecraft/server/level/ServerLevel;" - ) - ) + @Redirect(method = "missingRespawnBlock", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;findRespawnDimension()Lnet/minecraft/server/level/ServerLevel;")) private static ServerLevel redirectGetSpawnWorldInMissingRespawnBlock(MinecraftServer server, ServerPlayer player) { ServerLevel currentWorld = player.level(); @@ -71,13 +59,7 @@ private static ServerLevel redirectGetSpawnWorldInMissingRespawnBlock(MinecraftS return server.findRespawnDimension(); } - @Redirect( - method = "createDefault", - at = @At( - value = "INVOKE", - target = "Lnet/minecraft/world/level/portal/TeleportTransition;findAdjustedSharedSpawnPos(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/entity/Entity;)Lnet/minecraft/world/phys/Vec3;" - ) - ) + @Redirect(method = "createDefault", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/portal/TeleportTransition;findAdjustedSharedSpawnPos(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/entity/Entity;)Lnet/minecraft/world/phys/Vec3;")) private static Vec3 redirectFindAdjustedSharedSpawnPos(ServerLevel world, Entity entity) { Level entityWorld = entity.level(); if (isCreativeWorld(entityWorld.dimension())) { diff --git a/src/main/java/com/gmail/anthony17j/multiworld/mixin/TheEndGatewayBlockEntityMixin.java b/src/main/java/fr/jeanney/mixin/TheEndGatewayBlockEntityMixin.java similarity index 64% rename from src/main/java/com/gmail/anthony17j/multiworld/mixin/TheEndGatewayBlockEntityMixin.java rename to src/main/java/fr/jeanney/mixin/TheEndGatewayBlockEntityMixin.java index 604bb53..f6809e2 100644 --- a/src/main/java/com/gmail/anthony17j/multiworld/mixin/TheEndGatewayBlockEntityMixin.java +++ b/src/main/java/fr/jeanney/mixin/TheEndGatewayBlockEntityMixin.java @@ -1,4 +1,4 @@ -package com.gmail.anthony17j.multiworld.mixin; +package fr.jeanney.mixin; import net.minecraft.resources.ResourceKey; import net.minecraft.server.level.ServerLevel; @@ -8,18 +8,12 @@ import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Redirect; -import static com.gmail.anthony17j.multiworld.CustomServerWorld.isCustomEndWorld; +import static fr.jeanney.CustomServerWorld.isCustomEndWorld; @Mixin(TheEndGatewayBlockEntity.class) public abstract class TheEndGatewayBlockEntityMixin { - @Redirect( - method = "getPortalPosition", - at = @At( - value = "INVOKE", - target = "Lnet/minecraft/server/level/ServerLevel;dimension()Lnet/minecraft/resources/ResourceKey;" - ) - ) + @Redirect(method = "getPortalPosition", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/level/ServerLevel;dimension()Lnet/minecraft/resources/ResourceKey;")) private ResourceKey redirectGetRegistryKey(ServerLevel world) { ResourceKey originalKey = world.dimension(); diff --git a/src/main/java/fr/jeanney/mixin/ThrownEnderpearlMixin.java b/src/main/java/fr/jeanney/mixin/ThrownEnderpearlMixin.java new file mode 100644 index 0000000..dc9cb30 --- /dev/null +++ b/src/main/java/fr/jeanney/mixin/ThrownEnderpearlMixin.java @@ -0,0 +1,24 @@ +package fr.jeanney.mixin; + +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.entity.projectile.throwableitemprojectile.ThrownEnderpearl; +import net.minecraft.world.level.Level; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +import static fr.jeanney.CustomServerWorld.getMockRegistryKey; + +@Mixin(ThrownEnderpearl.class) +public abstract class ThrownEnderpearlMixin { + + @Redirect(method = "canTeleport", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/Level;dimension()Lnet/minecraft/resources/ResourceKey;", ordinal = 0)) + private ResourceKey redirectFromGetRegistryKey(Level world) { + return getMockRegistryKey(world.dimension()); + } + + @Redirect(method = "canTeleport", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/Level;dimension()Lnet/minecraft/resources/ResourceKey;", ordinal = 1)) + private ResourceKey redirectToGetRegistryKey(Level world) { + return getMockRegistryKey(world.dimension()); + } +} diff --git a/src/main/java/fr/jeanney/mixin/TimeCommandMixin.java b/src/main/java/fr/jeanney/mixin/TimeCommandMixin.java new file mode 100644 index 0000000..a87a9a3 --- /dev/null +++ b/src/main/java/fr/jeanney/mixin/TimeCommandMixin.java @@ -0,0 +1,73 @@ +package fr.jeanney.mixin; + +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import fr.jeanney.MultiWorld; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.core.Holder; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.commands.TimeCommand; +import net.minecraft.world.clock.ClockTimeMarker; +import net.minecraft.world.clock.ServerClockManager; +import net.minecraft.world.clock.WorldClock; +import net.minecraft.world.timeline.Timeline; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(TimeCommand.class) +public abstract class TimeCommandMixin { + @Redirect(method = "suggestTimeMarkers", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;clockManager()Lnet/minecraft/world/clock/ServerClockManager;")) + private static ServerClockManager redirectSuggestTimeMarkersClockManager(MinecraftServer server, + CommandSourceStack source, SuggestionsBuilder builder, Holder clock) { + return MultiWorld.getClockManagerForSource(source, server); + } + + @Redirect(method = "queryTime", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;clockManager()Lnet/minecraft/world/clock/ServerClockManager;")) + private static ServerClockManager redirectQueryTimeClockManager(MinecraftServer server, CommandSourceStack source, + Holder clock) { + return MultiWorld.getClockManagerForSource(source, server); + } + + @Redirect(method = "queryTimelineTicks", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;clockManager()Lnet/minecraft/world/clock/ServerClockManager;")) + private static ServerClockManager redirectQueryTimelineTicksClockManager(MinecraftServer server, + CommandSourceStack source, Holder clock, Holder timeline) { + return MultiWorld.getClockManagerForSource(source, server); + } + + @Redirect(method = "queryTimelineRepetitions", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;clockManager()Lnet/minecraft/world/clock/ServerClockManager;")) + private static ServerClockManager redirectQueryTimelineRepetitionsClockManager(MinecraftServer server, + CommandSourceStack source, Holder clock, Holder timeline) { + return MultiWorld.getClockManagerForSource(source, server); + } + + @Redirect(method = "setTotalTicks", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;clockManager()Lnet/minecraft/world/clock/ServerClockManager;")) + private static ServerClockManager redirectSetTotalTicksClockManager(MinecraftServer server, + CommandSourceStack source, Holder clock, int ticks) { + return MultiWorld.getClockManagerForSource(source, server); + } + + @Redirect(method = "addTime", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;clockManager()Lnet/minecraft/world/clock/ServerClockManager;")) + private static ServerClockManager redirectAddTimeClockManager(MinecraftServer server, CommandSourceStack source, + Holder clock, int ticks) { + return MultiWorld.getClockManagerForSource(source, server); + } + + @Redirect(method = "setTimeToTimeMarker", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;clockManager()Lnet/minecraft/world/clock/ServerClockManager;")) + private static ServerClockManager redirectSetTimeToTimeMarkerClockManager(MinecraftServer server, + CommandSourceStack source, Holder clock, ResourceKey markerKey) { + return MultiWorld.getClockManagerForSource(source, server); + } + + @Redirect(method = "setPaused", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;clockManager()Lnet/minecraft/world/clock/ServerClockManager;")) + private static ServerClockManager redirectSetPausedClockManager(MinecraftServer server, CommandSourceStack source, + Holder clock, boolean paused) { + return MultiWorld.getClockManagerForSource(source, server); + } + + @Redirect(method = "setRate", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;clockManager()Lnet/minecraft/world/clock/ServerClockManager;")) + private static ServerClockManager redirectSetRateClockManager(MinecraftServer server, CommandSourceStack source, + Holder clock, float rate) { + return MultiWorld.getClockManagerForSource(source, server); + } +} diff --git a/src/main/resources/assets/multiworld/icon.png b/src/main/resources/assets/multiworld/icon.png index 047b91f..6b67c2a 100644 Binary files a/src/main/resources/assets/multiworld/icon.png and b/src/main/resources/assets/multiworld/icon.png differ diff --git a/src/main/resources/data/multiworld/dimension_type/default.json b/src/main/resources/data/multiworld/dimension_type/default.json index 87a073d..99d5e12 100644 --- a/src/main/resources/data/multiworld/dimension_type/default.json +++ b/src/main/resources/data/multiworld/dimension_type/default.json @@ -13,7 +13,8 @@ "infiniburn": "#minecraft:infiniburn_overworld", "logical_height": 256, "min_y": 0, - "height": 256, + "height": 2032, + "has_ender_dragon_fight": false, "monster_spawn_block_light_limit": 15, "monster_spawn_light_level": 15 } \ No newline at end of file diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 8bc55cc..4efdf27 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -1,38 +1,31 @@ { - "schemaVersion": 1, - "id": "multiworld", - "version": "${version}", - - "name": "MultiWorld", - "description": "Fabric Server Side mod that adds multiple worlds with seperate player data.", - "authors": [ - "Uxzylon" - ], - "contact": { - "homepage": "https://anthony.jeanney.fr", - "sources": "https://github.com/Uxzylon/MultiWorld" - }, - - "license": "CC0-1.0", - "icon": "assets/multiworld/icon.png", - - "environment": "*", - "entrypoints": { - "main": [ - "com.gmail.anthony17j.multiworld.MultiWorld" - ] - }, - "mixins": [ - "multiworld.mixins.json" - ], - - "depends": { - "fabricloader": ">=0.18.4", - "minecraft": "~1.21.11", - "java": ">=21", - "fabric-api": "*" - }, - "suggests": { - "another-mod": "*" - } -} + "schemaVersion": 1, + "id": "multiworld", + "version": "${version}", + "name": "MultiWorld", + "description": "Fabric Server Side mod that adds multiple worlds with seperate player data.", + "authors": [ + "Uxzylon" + ], + "contact": { + "homepage": "https://anthony.jeanney.fr", + "sources": "https://github.com/Uxzylon/MultiWorld" + }, + "license": "CC0-1.0", + "icon": "assets/multiworld/icon.png", + "environment": "*", + "entrypoints": { + "main": [ + "fr.jeanney.MultiWorld" + ] + }, + "mixins": [ + "multiworld.mixins.json" + ], + "depends": { + "fabricloader": ">=0.18.5", + "minecraft": "~26.1", + "java": ">=25", + "fabric-api": "*" + } +} \ No newline at end of file diff --git a/src/main/resources/multiworld.mixins.json b/src/main/resources/multiworld.mixins.json index 7cf001d..a624e48 100644 --- a/src/main/resources/multiworld.mixins.json +++ b/src/main/resources/multiworld.mixins.json @@ -1,35 +1,39 @@ { - "required": true, - "minVersion": "0.8", - "package": "com.gmail.anthony17j.multiworld.mixin", - "compatibilityLevel": "JAVA_21", - "mixins": [ - "BaseFireBlockMixin", - "PlayerAdvancementsMixin", - "ChangeDimensionTriggerMixin", - "ThrownEnderpearlMixin", - "TheEndGatewayBlockEntityMixin", - "EndPortalBlockMixin", - "EntityMixin", - "FallingBlockEntityMixin", - "LegacyStructureDataHandlerMixin", - "IMinecraftServerMixin", - "MapItemSavedDataMixin", - "NetherPortalBlockMixin", - "ServerPlayerAccessor", - "ServerPlayerMixin", - "ServerLevelAccessor", - "ServerLevelMixin", - "MappedRegistryMixin", - "PlayerSpawnFinderAccessor", - "StatsCounterAccessor", - "TeleportTransitionMixin", - "TeleportTransitionAccessor" - ], - "injectors": { - "defaultRequire": 1 - }, - "overwrites": { - "requireAnnotations": true - } -} + "required": true, + "package": "fr.jeanney.mixin", + "compatibilityLevel": "JAVA_25", + "mixins": [ + "BaseFireBlockMixin", + "ChangeDimensionTriggerMixin", + "EndPortalBlockMixin", + "EntityMixin", + "FallingBlockEntityMixin", + "IMinecraftServerMixin", + "MapItemSavedDataMixin", + "MappedRegistryMixin", + "NetherPortalBlockMixin", + "PlayerAdvancementsAccessor", + "PlayerAdvancementsMixin", + "PlayerListMixin", + "PlayerSpawnFinderAccessor", + "ScoreboardCommandMixin", + "ServerLevelAccessor", + "ServerLevelClockMixin", + "ServerLevelMixin", + "ServerPlayerAccessor", + "ServerPlayerMixin", + "ServerStatsCounterAccessor", + "StatsCounterAccessor", + "TeleportTransitionAccessor", + "TeleportTransitionMixin", + "TheEndGatewayBlockEntityMixin", + "ThrownEnderpearlMixin", + "TimeCommandMixin" + ], + "injectors": { + "defaultRequire": 1 + }, + "overwrites": { + "requireAnnotations": true + } +} \ No newline at end of file