diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b9dbf06 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "gradle" + directory: "/" + schedule: + interval: "daily" + + - package-ecosystem: "github-actions" + # Directory should be `/` instead of `/.github/workflows` according to the docs. + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 90a1c04..9582747 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -2,8 +2,8 @@ name: Java CI with Gradle on: push: - branches: - - "**" + branches-ignore: + - "dependabot/**" tags-ignore: - "**" paths: @@ -57,7 +57,7 @@ jobs: run: ./gradlew spotlessJsonCheck || (echo "::error::JSON validation failed! Run './gradlew spotlessApply' to fix style issues, or check the full error message for syntax errors." && exit 1) - name: Validate Java code style - run: ./gradlew spotlessCheck || (echo "::error::Java code style validation failed! To fix, run 'Clean Up' and then 'Format' in Eclipse, or './gradlew spotlessApply' in the terminal." && exit 1) + run: ./gradlew spotlessJavaCheck || (echo "::error::Java code style validation failed! To fix, run 'Clean Up' and then 'Format' in Eclipse, or './gradlew spotlessApply' in the terminal." && exit 1) - name: Run unit tests run: ./gradlew test --stacktrace --warning-mode=fail @@ -92,3 +92,52 @@ jobs: echo "- [$filename]($url)" >> $GITHUB_STEP_SUMMARY done echo "" >> $GITHUB_STEP_SUMMARY + + - name: Run the mod and take screenshots + uses: modmuss50/xvfb-action@c56c7da0c8fc9a7cb5df2e50dd2a43a80b64c5cb + with: + run: ./gradlew runEndToEndTest --stacktrace --warning-mode=fail + + # Needed because the screenshot gallery won't be created on pull requests. + # Also useful if Imgur uploads fail. + - name: Upload Test Screenshots.zip artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: Test Screenshots + path: runs/client/screenshots + + - name: Create test screenshot gallery + if: ${{ env.IMGUR_CLIENT_ID && (success() || failure()) }} + shell: bash + run: | + echo "
" >> $GITHUB_STEP_SUMMARY + echo "📸 Test Screenshots" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + for img in runs/client/screenshots/*.png; do + if [ -f "$img" ]; then + filename=$(basename "$img") + name_without_ext="${filename%.*}" + + # Upload to Imgur + response=$(curl -s -X POST \ + -H "Authorization: Client-ID $IMGUR_CLIENT_ID" \ + -F "image=@$img" \ + https://api.imgur.com/3/image) + + # Extract the URL from the response + url=$(echo $response | grep -o '"link":"[^"]*"' | cut -d'"' -f4) + + if [ ! -z "$url" ]; then + # Convert underscores to spaces and capitalize first letter of each word + title=$(echo "$name_without_ext" | tr '_' ' ' | awk '{for(i=1;i<=NF;i++)sub(/./,toupper(substr($i,1,1)),$i)}1') + echo "### $title" >> $GITHUB_STEP_SUMMARY + echo "![${name_without_ext}]($url)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + else + echo "Failed to upload $filename" >> $GITHUB_STEP_SUMMARY + fi + fi + done + echo "
" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5aa3ade..5a95f7d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,9 @@ name: Publish Release +run-name: "Publish release from ${{ github.ref_name }} branch" + +permissions: + # Needed to push the tag. + contents: write on: workflow_dispatch: @@ -28,6 +33,11 @@ on: required: true type: boolean default: true + update_website: + description: "Update wimods.net post (only works if there already is one and publish_curseforge is true)" + required: false + type: boolean + default: false jobs: publish: @@ -37,9 +47,13 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }} MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }} - steps: - - uses: actions/checkout@v4 + + - name: Checkout repository + uses: actions/checkout@v4 + with: + # Include all tags in case the new tag already exists. + fetch-tags: true - name: Set up Java 21 uses: actions/setup-java@v4 @@ -58,11 +72,11 @@ jobs: - name: Create and push tag run: | - MOD_VERSION=$(grep "mod_version" gradle.properties | cut -d'=' -f2 | tr -d ' ') + MOD_VERSION=$(grep "mod_version" gradle.properties | cut -d'=' -f2 | tr -d ' \r') git config --global user.name "Wurst-Bot" git config --global user.email "contact.wurstimperium@gmail.com" - git tag v$MOD_VERSION - git push origin v$MOD_VERSION + git tag "v$MOD_VERSION" + git push origin "v$MOD_VERSION" - name: Close milestone if: ${{ inputs.close_milestone }} @@ -82,6 +96,29 @@ jobs: if: ${{ inputs.publish_curseforge }} run: ./gradlew publishCurseforge --stacktrace + - name: Get CurseForge file ID + id: cf_file_id + if: ${{ inputs.publish_curseforge }} + run: | + file_id=$(./gradlew getCurseforgeId | grep -o 'CURSEFORGE_FILE_ID=[0-9]*' | grep -o '[0-9]*') + echo "file_id=$file_id" >> "$GITHUB_OUTPUT" + echo "CurseForge file ID: `$file_id`" >> $GITHUB_STEP_SUMMARY + - name: Publish to Modrinth if: ${{ inputs.publish_modrinth }} run: ./gradlew publishModrinth --stacktrace + + - name: Trigger website update + if: ${{ inputs.update_website && inputs.publish_curseforge }} + env: + GH_TOKEN: ${{ secrets.WIMODS_NET_PUBLISH_TOKEN }} + run: | + MOD_VERSION=$(grep "mod_version" gradle.properties | cut -d'=' -f2 | tr -d ' \r' | sed 's/-MC.*$//') + MC_VERSION=$(grep "minecraft_version" gradle.properties | cut -d'=' -f2 | tr -d ' \r') + gh workflow run add_mod_port.yml \ + -R Wurst-Imperium/wimods.net \ + -f mod="mo-glass" \ + -f modloader="neoforge" \ + -f mod_version="$MOD_VERSION" \ + -f mc_version="$MC_VERSION" \ + -f file_id="${{ steps.cf_file_id.outputs.file_id }}" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index e0a1612..e7b27b7 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,8 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - name: Run stale bot + uses: actions/stale@v9 with: stale-issue-message: | This issue has been open for a while with no recent activity. If this issue is still important to you, please add a comment within the next 7 days to keep it open. Otherwise, the issue will be automatically closed to free up time for other tasks. diff --git a/README.md b/README.md index 41c4fb0..592cde1 100644 --- a/README.md +++ b/README.md @@ -4,24 +4,25 @@ Mo Glass is a Minecraft mod that adds glass stairs and glass slabs, including st ![A Minecraft house with its roof made out of glass stairs, powered by the Mo Glass mod](https://user-images.githubusercontent.com/10100202/69939492-ab78a480-14e8-11ea-8aa7-c351657b334b.jpg) -## Downloads (for users) +## Downloads [![Download Mo Glass](https://user-images.githubusercontent.com/10100202/214880552-859aa2ed-b4bc-4f8d-9ee7-bdd8c7fb33a2.png)](https://www.wimods.net/mo-glass/download/?utm_source=GitHub&utm_medium=Mo+Glass&utm_content=Mo+Glass+GitHub+repo+download+button) -## Setup (for developers) +## Installation -(This assumes that you are using Windows with [Eclipse](https://www.eclipse.org/downloads/) and [Java Development Kit 21](https://adoptium.net/?variant=openjdk21&jvmVariant=hotspot) already installed.) +> [!IMPORTANT] +> Always make sure that your modloader and all of your mods are made for the same Minecraft version. Your game will crash if you mix different versions. -1. Clone / download the repository. +### Installation using Fabric -2. Run these two commands in PowerShell: +1. Install [Fabric Loader](https://fabricmc.net/use/installer/). +2. Add [Fabric API](https://modrinth.com/mod/fabric-api) to your mods folder. +3. Add Mo Glass to your mods folder. - ```powershell - ./gradlew.bat --stop - ./gradlew.bat eclipse - ``` +### Installation using NeoForge -3. In Eclipse, go to `Import...` > `Existing Projects into Workspace` and select this project. +1. Install [NeoForge](https://neoforged.net/). +2. Add Mo Glass to your mods folder. ## Features @@ -59,43 +60,53 @@ That's a lot of effort just to add two new blocks to the game - and a lot of opp
*Here's how I got those numbers: (click to expand) - possible variations of stairs: + possible variations of stairs: pvStairs = 4 * 2 * 5 = 40 - possible variations of slabs: + possible variations of slabs: pvSlabs = 3 - possible variations of glass blocks: + possible variations of glass blocks: pvGlass = 1 - possible variations of non-transparent blocks: + possible variations of non-transparent blocks: pvBlocks = 1 (because any variations would be ignored when calculating transparency) - possible combinations combined: - pvAll = pvStairs + pvSlabs + pvGlass + pvBlocks = 40 + 3 + 1 + 1 = 45 + possible combinations combined: + pvAll = pvStairs + pvSlabs + pvGlass + pvBlocks = 40 + 3 + 1 + 1 = 45 - possibly transparent faces of a block (including stairs, even though they have more faces): + possibly transparent faces of a block (including stairs, even though they have more faces): f = 6 - possible scenarios for transparency of stairs: + possible scenarios for transparency of stairs: psStairs = pvAll * f * pvStairs = 45 * 6 * 40 = 10800 - possible scenarios for transparency of slabs: + possible scenarios for transparency of slabs: psSlabs = pvAll * f * pvSlabs = 45 * 6 * 3 = 810 - possible scenarios for transparency of glass blocks: + possible scenarios for transparency of glass blocks: psGlass = pvAll * f * pvGlass = 45 * 6 * 1 = 270 - possible scenarios for transparency of glass blocks if glass stairs and slabs don't exist: - psGlassVanilla = (pvGlass + pvBlocks) * f * pvGlass = (1 + 1) * 6 * 1 = 12  + possible scenarios for transparency of glass blocks if glass stairs and slabs don't exist: + psGlassVanilla = (pvGlass + pvBlocks) * f * pvGlass = (1 + 1) * 6 * 1 = 12
+## What about connected textures mods? + +~~So far, all connected textures mods that I've seen only seem to work on full blocks. They don't generate connected textures for stairs or slabs, which makes using them with Mo Glass impossible.~~ + +~~It's not that Mo Glass doesn't have support for connected textures, it's that connected textures mods don't have support for Mo Glass (or any other mod that adds stairs/slabs).~~ + +~~This might change one day as people make new mods all the time, so do let me know if there is a connected texture mod that supports stairs now. I'd be happy to add the extra texture files needed (if any) to make that work with Mo Glass.~~ + +This has changed and support for connected textures is currently being worked on. Please be patient. + ## Crafting Recipes
Glass Slab: (click to expand) - ![glass slab crafting recipse](https://user-images.githubusercontent.com/10100202/69957444-5a2ddc80-150b-11ea-8c8c-e2afc5d72fb7.png) + ![glass slab crafting recipe](https://user-images.githubusercontent.com/10100202/69957444-5a2ddc80-150b-11ea-8c8c-e2afc5d72fb7.png) ![glass slab stonecutter recipe](https://user-images.githubusercontent.com/10100202/70445670-2a974b00-1a9c-11ea-9a09-46c304cd167b.png)
@@ -106,7 +117,7 @@ That's a lot of effort just to add two new blocks to the game - and a lot of opp ![glass stairs stonecutter recipe](https://user-images.githubusercontent.com/10100202/70445677-2c610e80-1a9c-11ea-8e1b-108863b47124.png) -## Supported Languages +## Supported languages - Chinese (Simplified) (since v1.2) - Chinese (Traditional) (since v1.2) @@ -126,3 +137,72 @@ That's a lot of effort just to add two new blocks to the game - and a lot of opp - Spanish (Mexico) (since v1.4) - Spanish (Uruguay) (since v1.4) - Spanish (Venezuela) (since v1.4) + +## Development Setup + +> [!IMPORTANT] +> Make sure you have [Java Development Kit 21](https://adoptium.net/?variant=openjdk21&jvmVariant=hotspot) installed. It won't work with other versions. + +### Development using Eclipse + +1. Clone the repository: + + ```pwsh + git clone https://github.com/Wurst-Imperium/Mo-Glass.git + cd Mo-Glass + ``` + +2. Generate the sources: + + In Fabric versions: + ```pwsh + ./gradlew genSources eclipse + ``` + + In NeoForge versions: + ```pwsh + ./gradlew eclipse + ``` + +3. In Eclipse, go to `Import...` > `Existing Projects into Workspace` and select this project. + +4. **Optional:** Right-click on the project and select `Properties` > `Java Code Style`. Then under `Clean Up`, `Code Templates`, `Formatter`, import the respective files in the `codestyle` folder. + +### Development using VSCode / Cursor + +> [!TIP] +> You'll probably want to install the [Extension Pack for Java](https://marketplace.visualstudio.com/items?itemName=vscjava.vscode-java-pack) to make development easier. + +1. Clone the repository: + + ```pwsh + git clone https://github.com/Wurst-Imperium/Mo-Glass.git + cd Mo-Glass + ``` + +2. Generate the sources: + + In Fabric versions: + ```pwsh + ./gradlew genSources vscode + ``` + + In NeoForge versions: + ```pwsh + ./gradlew eclipse + ``` + (That's not a typo. NeoForge doesn't have `vscode`, but `eclipse` works fine.) + +3. Open the `Mo-Glass` folder in VSCode / Cursor. + +4. **Optional:** In the VSCode settings, set `java.format.settings.url` to `https://raw.githubusercontent.com/Wurst-Imperium/Mo-Glass/master/codestyle/formatter.xml` and `java.format.settings.profile` to `Wurst-Imperium`. + +### Development using IntelliJ IDEA + +I don't use or recommend IntelliJ, but the commands to run would be: + +```pwsh +git clone https://github.com/Wurst-Imperium/Mo-Glass.git +cd Mo-Glass +./gradlew genSources idea +``` diff --git a/build.gradle b/build.gradle index 4d86f2a..34311b4 100644 --- a/build.gradle +++ b/build.gradle @@ -8,9 +8,9 @@ plugins { id "java-library" id "eclipse" id "idea" - id "net.neoforged.gradle.userdev" version "7.0.145" - id "net.neoforged.gradle.mixin" version "7.0.145" - id "me.modmuss50.mod-publish-plugin" version "0.5.2" + id "net.neoforged.gradle.userdev" version "7.0.171" + id "net.neoforged.gradle.mixin" version "7.0.171" + id "me.modmuss50.mod-publish-plugin" version "0.8.1" id "com.diffplug.spotless" version "6.25.0" } @@ -38,7 +38,7 @@ mixin { config 'mo-glass.mixins.json' } -//minecraft.accessTransformers.file rootProject.file('src/main/resources/META-INF/accesstransformer.cfg') +minecraft.accessTransformers.file rootProject.file('src/main/resources/META-INF/accesstransformer.cfg') //minecraft.accessTransformers.entry public net.minecraft.client.Minecraft textureManager # textureManager // Default run configurations. @@ -164,6 +164,27 @@ tasks.withType(ProcessResources).configureEach { } } +tasks.register('runEndToEndTest') { + group = 'NeoGradle/Runs' + description = 'Runs the game with end-to-end tests enabled' + dependsOn 'runClient' +} + +// Modify runClient if it's running as a dependency of runEndToEndTest. +// Since NeoGradle doesn't allow custom run types yet, this is the only way to +// make runEndToEndTest work for now. +tasks.withType(JavaExec).configureEach { + doFirst { + if (gradle.startParameter.taskNames.contains('runEndToEndTest')) { + jvmArgs += [ + '-Dmo_glass.e2eTest', + '-Dmixin.debug.verify=true', + '-Dmixin.debug.countInjections=true' + ] + } + } +} + tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' // Use the UTF-8 charset for Java compilation } @@ -228,6 +249,16 @@ publishMods { } } +import groovy.json.JsonSlurper + +tasks.register("getCurseforgeId") { + inputs.file publishCurseforge.result + doLast { + def result = new JsonSlurper().parseText(publishCurseforge.result.get().asFile.text) + println "CURSEFORGE_FILE_ID=${result.fileId}" + } +} + tasks.named("publishMods").configure { dependsOn(tasks.named("build")) } @@ -240,6 +271,10 @@ tasks.named("publishModrinth").configure { dependsOn(tasks.named("build")) } +tasks.named("getCurseforgeId").configure { + dependsOn(tasks.named("publishCurseforge")) +} + import org.kohsuke.github.GHReleaseBuilder import org.kohsuke.github.GitHub diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 79eb9d0..c1d5e01 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-8.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/java/net/wurstclient/glass/MoGlass.java b/src/main/java/net/wurstclient/glass/MoGlass.java index 79fd14f..50ef31a 100644 --- a/src/main/java/net/wurstclient/glass/MoGlass.java +++ b/src/main/java/net/wurstclient/glass/MoGlass.java @@ -15,6 +15,7 @@ import net.neoforged.fml.common.Mod; import net.neoforged.neoforge.event.BuildCreativeModeTabContentsEvent; import net.neoforged.neoforge.registries.DeferredBlock; +import net.wurstclient.glass.test.MoGlassTestClient; @Mod(MoGlass.MODID) @EventBusSubscriber(modid = MoGlass.MODID, bus = EventBusSubscriber.Bus.MOD) @@ -32,6 +33,10 @@ public MoGlass(IEventBus modBus, ModContainer container) System.out.println("Starting Mo Glass..."); MoGlassBlocks.BLOCKS.register(modBus); MoGlassBlocks.ITEMS.register(modBus); + + // Run end-to-end test, if enabled + if(System.getProperty("mo_glass.e2eTest") != null) + MoGlassTestClient.start(); } @SubscribeEvent diff --git a/src/main/java/net/wurstclient/glass/MoGlassBlocks.java b/src/main/java/net/wurstclient/glass/MoGlassBlocks.java index 29cdac5..cd26cbe 100644 --- a/src/main/java/net/wurstclient/glass/MoGlassBlocks.java +++ b/src/main/java/net/wurstclient/glass/MoGlassBlocks.java @@ -7,7 +7,8 @@ */ package net.wurstclient.glass; -import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.function.Supplier; import net.minecraft.core.BlockPos; @@ -74,77 +75,13 @@ public enum MoGlassBlocks .isSuffocating(MoGlassBlocks::never) .isViewBlocking(MoGlassBlocks::never))); - public static final ArrayList> STAINED_GLASS_SLABS = - new ArrayList<>(); + public static final List> STAINED_GLASS_SLABS = + Arrays.stream(DyeColor.values()) + .map(color -> createStainedGlassSlab(color)).toList(); - public static final ArrayList> STAINED_GLASS_STAIRS = - new ArrayList<>(); - - public static final DeferredBlock WHITE_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.WHITE); - public static final DeferredBlock ORANGE_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.ORANGE); - public static final DeferredBlock MAGENTA_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.MAGENTA); - public static final DeferredBlock LIGHT_BLUE_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.LIGHT_BLUE); - public static final DeferredBlock YELLOW_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.YELLOW); - public static final DeferredBlock LIME_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.LIME); - public static final DeferredBlock PINK_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.PINK); - public static final DeferredBlock GRAY_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.GRAY); - public static final DeferredBlock LIGHT_GRAY_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.LIGHT_GRAY); - public static final DeferredBlock CYAN_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.CYAN); - public static final DeferredBlock PURPLE_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.PURPLE); - public static final DeferredBlock BLUE_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.BLUE); - public static final DeferredBlock BROWN_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.BROWN); - public static final DeferredBlock GREEN_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.GREEN); - public static final DeferredBlock RED_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.RED); - public static final DeferredBlock BLACK_STAINED_GLASS_SLAB = - createStainedGlassSlab(DyeColor.BLACK); - - public static final DeferredBlock WHITE_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.WHITE); - public static final DeferredBlock ORANGE_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.ORANGE); - public static final DeferredBlock MAGENTA_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.MAGENTA); - public static final DeferredBlock LIGHT_BLUE_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.LIGHT_BLUE); - public static final DeferredBlock YELLOW_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.YELLOW); - public static final DeferredBlock LIME_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.LIME); - public static final DeferredBlock PINK_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.PINK); - public static final DeferredBlock GRAY_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.GRAY); - public static final DeferredBlock LIGHT_GRAY_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.LIGHT_GRAY); - public static final DeferredBlock CYAN_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.CYAN); - public static final DeferredBlock PURPLE_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.PURPLE); - public static final DeferredBlock BLUE_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.BLUE); - public static final DeferredBlock BROWN_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.BROWN); - public static final DeferredBlock GREEN_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.GREEN); - public static final DeferredBlock RED_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.RED); - public static final DeferredBlock BLACK_STAINED_GLASS_STAIRS = - createStainedGlassStairs(DyeColor.BLACK); + public static final List> STAINED_GLASS_STAIRS = + Arrays.stream(DyeColor.values()) + .map(color -> createStainedGlassStairs(color)).toList(); private static DeferredBlock registerBlock( String idPath, Supplier block) @@ -160,37 +97,29 @@ private static DeferredBlock registerBlock( private static DeferredBlock createStainedGlassSlab( DyeColor color) { - DeferredBlock result = - registerBlock(color + "_stained_glass_slab", - () -> new StainedGlassSlabBlock(color, - BlockBehaviour.Properties.of().mapColor(color) - .instrument(NoteBlockInstrument.HAT).strength(0.3F) - .sound(SoundType.GLASS).noOcclusion() - .isValidSpawn(MoGlassBlocks::never) - .isRedstoneConductor(MoGlassBlocks::never) - .isSuffocating(MoGlassBlocks::never) - .isViewBlocking(MoGlassBlocks::never))); - - STAINED_GLASS_SLABS.add(result); - return result; + return registerBlock(color + "_stained_glass_slab", + () -> new StainedGlassSlabBlock(color, + BlockBehaviour.Properties.of().mapColor(color) + .instrument(NoteBlockInstrument.HAT).strength(0.3F) + .sound(SoundType.GLASS).noOcclusion() + .isValidSpawn(MoGlassBlocks::never) + .isRedstoneConductor(MoGlassBlocks::never) + .isSuffocating(MoGlassBlocks::never) + .isViewBlocking(MoGlassBlocks::never))); } private static DeferredBlock createStainedGlassStairs( DyeColor color) { - DeferredBlock result = - registerBlock(color + "_stained_glass_stairs", - () -> new StainedGlassStairsBlock(color, - BlockBehaviour.Properties.of().mapColor(color) - .instrument(NoteBlockInstrument.HAT).strength(0.3F) - .sound(SoundType.GLASS).noOcclusion() - .isValidSpawn(MoGlassBlocks::never) - .isRedstoneConductor(MoGlassBlocks::never) - .isSuffocating(MoGlassBlocks::never) - .isViewBlocking(MoGlassBlocks::never))); - - STAINED_GLASS_STAIRS.add(result); - return result; + return registerBlock(color + "_stained_glass_stairs", + () -> new StainedGlassStairsBlock(color, + BlockBehaviour.Properties.of().mapColor(color) + .instrument(NoteBlockInstrument.HAT).strength(0.3F) + .sound(SoundType.GLASS).noOcclusion() + .isValidSpawn(MoGlassBlocks::never) + .isRedstoneConductor(MoGlassBlocks::never) + .isSuffocating(MoGlassBlocks::never) + .isViewBlocking(MoGlassBlocks::never))); } // Copies of the Blocks.never() methods because the originals are not diff --git a/src/main/java/net/wurstclient/glass/test/MoGlassTestClient.java b/src/main/java/net/wurstclient/glass/test/MoGlassTestClient.java new file mode 100644 index 0000000..b2b9fbb --- /dev/null +++ b/src/main/java/net/wurstclient/glass/test/MoGlassTestClient.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2019-2024 Wurst-Imperium and contributors. + * + * This source code is subject to the terms of the GNU General Public + * License, version 3. If a copy of the GPL was not distributed with this + * file, You can obtain one at: https://www.gnu.org/licenses/gpl-3.0.txt + */ +package net.wurstclient.glass.test; + +import static net.wurstclient.glass.test.WiModsTestHelper.*; + +import java.time.Duration; + +import org.spongepowered.asm.mixin.MixinEnvironment; + +import net.minecraft.SharedConstants; +import net.minecraft.client.gui.screens.AccessibilityOnboardingScreen; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen; +import net.minecraft.client.gui.screens.worldselection.SelectWorldScreen; + +public final class MoGlassTestClient +{ + public static void start() + { + Thread.ofVirtual().name("Mo Glass End-to-End Test") + .uncaughtExceptionHandler((t, e) -> { + e.printStackTrace(); + System.exit(1); + }).start(new MoGlassTestClient()::runTests); + } + + private void runTests() + { + System.out.println("Starting Mo Glass End-to-End Test"); + waitForResourceLoading(); + + if(submitAndGet(mc -> mc.options.onboardAccessibility)) + { + System.out.println("Onboarding is enabled. Waiting for it"); + waitForScreen(AccessibilityOnboardingScreen.class); + System.out.println("Reached onboarding screen"); + clickButton("gui.continue"); + } + + waitForScreen(TitleScreen.class); + waitForTitleScreenFade(); + System.out.println("Reached title screen"); + takeScreenshot("title_screen", Duration.ZERO); + + System.out.println("Clicking singleplayer button"); + clickButton("menu.singleplayer"); + + if(submitAndGet( + mc -> !mc.getLevelSource().findLevelCandidates().isEmpty())) + { + System.out.println("World list is not empty. Waiting for it"); + waitForScreen(SelectWorldScreen.class); + System.out.println("Reached select world screen"); + takeScreenshot("select_world_screen"); + clickButton("selectWorld.create"); + } + + waitForScreen(CreateWorldScreen.class); + System.out.println("Reached create world screen"); + + // Set MC version as world name + setTextFieldText(0, + "E2E Test " + SharedConstants.getCurrentVersion().getName()); + // Select creative mode + clickButton("selectWorld.gameMode"); + clickButton("selectWorld.gameMode"); + takeScreenshot("create_world_screen"); + + System.out.println("Creating test world"); + clickButton("selectWorld.create"); + + waitForWorldLoad(); + dismissTutorialToasts(); + waitForWorldTicks(200); + runChatCommand("seed"); + System.out.println("Reached singleplayer world"); + takeScreenshot("in_game", Duration.ZERO); + clearChat(); + + System.out.println("Opening debug menu"); + toggleDebugHud(); + takeScreenshot("debug_menu"); + + System.out.println("Closing debug menu"); + toggleDebugHud(); + + System.out.println("Checking for broken mixins"); + MixinEnvironment.getCurrentEnvironment().audit(); + + System.out.println("Opening inventory"); + openInventory(); + takeScreenshot("inventory"); + + System.out.println("Closing inventory"); + closeScreen(); + + // Build a test platform and clear out the space above it + runChatCommand("fill ~-5 ~-1 ~-5 ~5 ~-1 ~5 stone"); + runChatCommand("fill ~-5 ~ ~-5 ~5 ~30 ~5 air"); + + // Clear inventory and chat before running tests + runChatCommand("clear"); + clearChat(); + + // TODO: Add Mo Glass-specific test code here + + System.out.println("Opening game menu"); + openGameMenu(); + takeScreenshot("game_menu"); + + System.out.println("Returning to title screen"); + clickButton("menu.returnToMenu"); + waitForScreen(TitleScreen.class); + + System.out.println("Stopping the game"); + clickButton("menu.quit"); + } +} diff --git a/src/main/java/net/wurstclient/glass/test/WiModsTestHelper.java b/src/main/java/net/wurstclient/glass/test/WiModsTestHelper.java new file mode 100644 index 0000000..fb27120 --- /dev/null +++ b/src/main/java/net/wurstclient/glass/test/WiModsTestHelper.java @@ -0,0 +1,394 @@ +/* + * Copyright (c) 2019-2024 Wurst-Imperium and contributors. + * + * This source code is subject to the terms of the GNU General Public + * License, version 3. If a copy of the GPL was not distributed with this + * file, You can obtain one at: https://www.gnu.org/licenses/gpl-3.0.txt + */ +package net.wurstclient.glass.test; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; + +import com.mojang.brigadier.ParseResults; +import com.mojang.brigadier.exceptions.CommandSyntaxException; + +import net.minecraft.client.CameraType; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.CycleButton; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Renderable; +import net.minecraft.client.gui.screens.LevelLoadingScreen; +import net.minecraft.client.gui.screens.PauseScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.client.gui.screens.inventory.InventoryScreen; +import net.minecraft.client.multiplayer.ClientPacketListener; +import net.minecraft.client.resources.language.I18n; +import net.minecraft.client.tutorial.TutorialSteps; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; + +public enum WiModsTestHelper +{ + ; + + private static final AtomicInteger screenshotCounter = new AtomicInteger(0); + + /** + * Runs the given consumer on Minecraft's main thread and waits for it to + * complete. + */ + public static void submitAndWait(Consumer consumer) + { + Minecraft mc = Minecraft.getInstance(); + mc.submit(() -> consumer.accept(mc)).join(); + } + + /** + * Runs the given function on Minecraft's main thread, waits for it to + * complete, and returns the result. + */ + public static T submitAndGet(Function function) + { + Minecraft mc = Minecraft.getInstance(); + return mc.submit(() -> function.apply(mc)).join(); + } + + /** + * Waits for the given duration. + */ + public static void wait(Duration duration) + { + try + { + Thread.sleep(duration.toMillis()); + + }catch(InterruptedException e) + { + throw new RuntimeException(e); + } + } + + /** + * Waits until the given condition is true, or fails if the timeout is + * reached. + */ + public static void waitUntil(String event, Predicate condition, + Duration maxDuration) + { + LocalDateTime startTime = LocalDateTime.now(); + LocalDateTime timeout = startTime.plus(maxDuration); + System.out.println("Waiting until " + event); + + while(true) + { + if(submitAndGet(condition::test)) + { + double seconds = + Duration.between(startTime, LocalDateTime.now()).toMillis() + / 1000.0; + System.out.println( + "Waiting until " + event + " took " + seconds + "s"); + break; + } + + if(startTime.isAfter(timeout)) + throw new RuntimeException( + "Waiting until " + event + " took too long"); + + wait(Duration.ofMillis(50)); + } + } + + /** + * Waits until the given condition is true, or fails after 10 seconds. + */ + public static void waitUntil(String event, Predicate condition) + { + waitUntil(event, condition, Duration.ofSeconds(10)); + } + + /** + * Waits until the given screen is open, or fails after 10 seconds. + */ + public static void waitForScreen(Class screenClass) + { + waitUntil("screen " + screenClass.getName() + " is open", + mc -> screenClass.isInstance(mc.screen)); + } + + /** + * Waits for the fading animation of the title screen to finish, or fails + * after 10 seconds. + */ + public static void waitForTitleScreenFade() + { + waitUntil("title screen fade is complete", mc -> { + if(!(mc.screen instanceof TitleScreen titleScreen)) + return false; + + return !titleScreen.fading; + }); + } + + /** + * Waits until the red overlay with the Mojang logo and progress bar goes + * away, or fails after 5 minutes. + */ + public static void waitForResourceLoading() + { + waitUntil("loading is complete", mc -> mc.getOverlay() == null, + Duration.ofMinutes(5)); + } + + public static void waitForWorldLoad() + { + waitUntil("world is loaded", + mc -> mc.level != null + && !(mc.screen instanceof LevelLoadingScreen), + Duration.ofMinutes(30)); + } + + public static void waitForWorldTicks(int ticks) + { + long startTicks = submitAndGet(mc -> mc.level.getGameTime()); + waitUntil(ticks + " world ticks have passed", + mc -> mc.level.getGameTime() >= startTicks + ticks, + Duration.ofMillis(ticks * 100).plusMinutes(5)); + } + + /** + * Waits for 50ms and then takes a screenshot with the given name. + */ + public static void takeScreenshot(String name) + { + takeScreenshot(name, Duration.ofMillis(50)); + } + + /** + * Waits for the given delay and then takes a screenshot with the given + * name. + */ + public static void takeScreenshot(String name, Duration delay) + { + wait(delay); + + String count = + String.format("%02d", screenshotCounter.incrementAndGet()); + String filename = count + "_" + name + ".png"; + + submitAndWait(mc -> Screenshot.grab(mc.gameDirectory, filename, + mc.getMainRenderTarget(), message -> {})); + } + + /** + * Returns the first button on the current screen that has the given + * translation key, or fails if not found. + * + *

+ * For non-translated buttons, the translationKey parameter should be the + * raw button text instead. + */ + public static Button findButton(Minecraft mc, String translationKey) + { + String message = I18n.get(translationKey); + + for(Renderable drawable : mc.screen.renderables) + if(drawable instanceof Button button + && button.getMessage().getString().equals(message)) + return button; + + throw new RuntimeException(message + " button could not be found"); + } + + /** + * Looks for the given button at the given coordinates and fails if it is + * not there. + */ + public static void checkButtonPosition(Button button, int expectedX, + int expectedY) + { + String buttonName = button.getMessage().getString(); + + if(button.getX() != expectedX) + throw new RuntimeException(buttonName + + " button is at the wrong X coordinate. Expected X: " + + expectedX + ", actual X: " + button.getX()); + + if(button.getY() != expectedY) + throw new RuntimeException(buttonName + + " button is at the wrong Y coordinate. Expected Y: " + + expectedY + ", actual Y: " + button.getY()); + } + + /** + * Clicks the button with the given translation key, or fails after 10 + * seconds. + * + *

+ * For non-translated buttons, the translationKey parameter should be the + * raw button text instead. + */ + public static void clickButton(String translationKey) + { + String buttonText = I18n.get(translationKey); + + waitUntil("button saying " + buttonText + " is visible", mc -> { + Screen screen = mc.screen; + if(screen == null) + return false; + + for(Renderable drawable : screen.renderables) + { + if(!(drawable instanceof AbstractWidget widget)) + continue; + + if(widget instanceof Button button + && buttonText.equals(button.getMessage().getString())) + { + button.onPress(); + return true; + } + + if(widget instanceof CycleButton button + && buttonText.equals(button.name.getString())) + { + button.onPress(); + return true; + } + } + + return false; + }); + } + + /** + * Types the given text into the nth text field on the current screen, or + * fails after 10 seconds. + */ + public static void setTextFieldText(int index, String text) + { + waitUntil("text field #" + index + " is visible", mc -> { + Screen screen = mc.screen; + if(screen == null) + return false; + + int i = 0; + for(Renderable drawable : screen.renderables) + { + if(!(drawable instanceof EditBox textField)) + continue; + + if(i == index) + { + textField.setValue(text); + return true; + } + + i++; + } + + return false; + }); + } + + public static void closeScreen() + { + submitAndWait(mc -> mc.setScreen(null)); + } + + public static void openGameMenu() + { + submitAndWait(mc -> mc.setScreen(new PauseScreen(true))); + } + + public static void openInventory() + { + submitAndWait(mc -> mc.setScreen(new InventoryScreen(mc.player))); + } + + public static void toggleDebugHud() + { + submitAndWait(mc -> mc.gui.getDebugOverlay().toggleOverlay()); + } + + public static void setPerspective(CameraType perspective) + { + submitAndWait(mc -> mc.options.setCameraType(perspective)); + } + + public static void dismissTutorialToasts() + { + submitAndWait(mc -> mc.getTutorial().setStep(TutorialSteps.NONE)); + } + + public static void clearChat() + { + submitAndWait(mc -> mc.gui.getChat().clearMessages(true)); + } + + /** + * Runs the given chat command and waits one tick for the action to + * complete. + * + *

+ * Do not put a / at the start of the command. + */ + public static void runChatCommand(String command) + { + System.out.println("Running command: /" + command); + submitAndWait(mc -> { + ClientPacketListener netHandler = mc.getConnection(); + + // Validate command using client-side command dispatcher + ParseResults results = netHandler.getCommands().parse(command, + netHandler.getSuggestionsProvider()); + + // Command is invalid, fail the test + if(!results.getExceptions().isEmpty()) + { + StringBuilder errors = + new StringBuilder("Invalid command: " + command); + for(CommandSyntaxException e : results.getExceptions().values()) + errors.append("\n").append(e.getMessage()); + + throw new RuntimeException(errors.toString()); + } + + // Command is valid, send it + netHandler.sendCommand(command); + }); + waitForWorldTicks(1); + } + + public static void assertOneItemInSlot(int slot, Item item) + { + submitAndWait(mc -> { + ItemStack stack = mc.player.getInventory().getItem(slot); + if(!stack.is(item) || stack.getCount() != 1) + throw new RuntimeException("Expected 1 " + + item.getName(stack).getString() + " at slot " + slot + + ", found " + stack.getCount() + " " + + stack.getItem().getName(stack).getString() + " instead"); + }); + } + + public static void assertNoItemInSlot(int slot) + { + submitAndWait(mc -> { + ItemStack stack = mc.player.getInventory().getItem(slot); + if(!stack.isEmpty()) + throw new RuntimeException("Expected no item in slot " + slot + + ", found " + stack.getCount() + " " + + stack.getItem().getName(stack).getString() + " instead"); + }); + } +} diff --git a/src/main/resources/META-INF/accesstransformer.cfg b/src/main/resources/META-INF/accesstransformer.cfg new file mode 100644 index 0000000..2eab375 --- /dev/null +++ b/src/main/resources/META-INF/accesstransformer.cfg @@ -0,0 +1,3 @@ +public net.minecraft.client.gui.screens.Screen renderables # renderables +public net.minecraft.client.gui.screens.TitleScreen fading # fading +public net.minecraft.client.gui.components.CycleButton name # name