From f3a936a83394324076f65351aa6993cc59c58ce9 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Mon, 11 May 2026 17:20:47 +0300 Subject: [PATCH 01/47] Initial commit (moved from affs) --- .gitignore | 8 + README.md | 14 + TEST_FRAMEWORK.md | 49 ++ build.gradle | 57 ++ dependencies.gradle | 7 + gradle.properties | 2 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 52818 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 169 +++++ gradlew.bat | 84 +++ repositories.gradle | 7 + settings.gradle | 17 + .../forge/testing/HeadlessGameTest.java | 19 + .../forge/testing/TestAssertions.java | 35 + .../forge/testing/TestBootstrap.java | 23 + .../stannismod/forge/testing/TestContext.java | 58 ++ .../forge/testing/TestOrchestrator.java | 79 ++ .../stannismod/forge/testing/TestOutcome.java | 72 ++ .../forge/testing/TestRegistry.java | 20 + .../forge/testing/TestReportWriter.java | 116 +++ .../stannismod/forge/testing/TestStatus.java | 9 + .../forge/testing/client/ClientBot.java | 188 +++++ .../testing/client/RealClientHarness.java | 692 ++++++++++++++++++ .../bridge/ForgeTestClientBootstrap.java | 591 +++++++++++++++ .../server/RealDedicatedServerHarness.java | 202 +++++ .../forge/testing/server/TestClient.java | 130 ++++ .../forge/testing/TestFrameworkTest.java | 157 ++++ 27 files changed, 2811 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 TEST_FRAMEWORK.md create mode 100644 build.gradle create mode 100644 dependencies.gradle create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 repositories.gradle create mode 100644 settings.gradle create mode 100644 src/main/java/com/github/stannismod/forge/testing/HeadlessGameTest.java create mode 100644 src/main/java/com/github/stannismod/forge/testing/TestAssertions.java create mode 100644 src/main/java/com/github/stannismod/forge/testing/TestBootstrap.java create mode 100644 src/main/java/com/github/stannismod/forge/testing/TestContext.java create mode 100644 src/main/java/com/github/stannismod/forge/testing/TestOrchestrator.java create mode 100644 src/main/java/com/github/stannismod/forge/testing/TestOutcome.java create mode 100644 src/main/java/com/github/stannismod/forge/testing/TestRegistry.java create mode 100644 src/main/java/com/github/stannismod/forge/testing/TestReportWriter.java create mode 100644 src/main/java/com/github/stannismod/forge/testing/TestStatus.java create mode 100644 src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java create mode 100644 src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java create mode 100644 src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java create mode 100644 src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java create mode 100644 src/main/java/com/github/stannismod/forge/testing/server/TestClient.java create mode 100644 src/test/java/com/github/stannismod/forge/testing/TestFrameworkTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..b45be2369 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +.gradle_home/ +build/ +out/ +logs/ +run/ +*.iml +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 000000000..dc5d3e610 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Forge Test Framework + +Reusable testing infrastructure for Forge 1.12.2 mods. The project is intended to be consumed as a Gradle composite build by a mod project. + +See [TEST_FRAMEWORK.md](TEST_FRAMEWORK.md) for the framework summary and extension points. + +## Local commands + +Use Java 8. + +```bash +./gradlew test +./gradlew build +``` diff --git a/TEST_FRAMEWORK.md b/TEST_FRAMEWORK.md new file mode 100644 index 000000000..b491005c0 --- /dev/null +++ b/TEST_FRAMEWORK.md @@ -0,0 +1,49 @@ +# Test Framework Summary + +This project contains reusable testing infrastructure for Forge 1.12.2 mod verification. It is a library, not a mod-specific test suite. + +## Layers + +- `com.github.stannismod.forge.testing` contains the generic scenario runner. +- `com.github.stannismod.forge.testing.server` starts and controls a real dedicated server process. +- `com.github.stannismod.forge.testing.client` starts and controls a real client process through a socket bridge. +- `com.github.stannismod.forge.testing.client.bridge` runs inside the client JVM and translates test commands into real client-thread actions. + +## Generic Scenario Runner + +A reusable headless scenario implements `HeadlessGameTest`: + +- `setUp(TestContext)` prepares per-case state. +- `tick(TestContext)` advances the scenario and returns `RUNNING`, `PASSED`, `FAILED`, or `SKIPPED`. +- `tearDown(TestContext)` releases per-case state. +- `timeoutTicks()` prevents stuck scenarios. + +`TestRegistry` stores scenarios. `TestOrchestrator` runs them one by one in isolated work directories. `TestBootstrap` combines orchestration and report writing. `TestReportWriter` writes `summary.txt` and `summary.json`. + +`TestContext` provides a per-scenario work directory, notes, and an attribute map for sharing state between setup, ticks, and teardown. + +## Dedicated Server Harness + +`RealDedicatedServerHarness` starts `GradleStartServer` in a separate JVM with `--nogui`, a temporary game directory, and an automatically reserved localhost port. It waits until the server reports readiness, then exposes a `TestClient`. + +`TestClient` writes commands to the server console, appends a unique marker with `say`, and reads stdout until that marker appears. This gives tests deterministic command completion without depending on arbitrary sleeps. + +## Real Client Harness + +`RealClientHarness` starts `GradleStart` in a separate JVM and connects it to the dedicated server. It also opens a localhost control socket. The client-side bridge connects back and sends `READY` when the bridge is available. + +`ClientBot` is the test-facing command API. It can wait for a client world, select a hotbar slot, right-click a block, click or drag GUI points, type text, close screens, inspect client-visible block state, and report player or GUI state. + +The harness uses temporary game directories and cleans them up on close. Client output is mirrored to `client.log` when the client bootstrap installs logging. + +## Client Bridge + +`ForgeTestClientBootstrap` is loaded inside the modded client JVM by the consuming mod when a test system property is present. It listens on the control socket and schedules actions on the Minecraft client thread. This keeps interactions aligned with the real client runtime instead of mutating state directly from the test JVM. + +The bridge intentionally performs actions through client-visible paths such as right-clicking blocks, GUI clicks, GUI drags, typing, and hotbar selection. Server probe commands may observe the result, but should not replace the player action under test. + +## Expected Consumer Responsibilities + +A consuming mod project provides its own test scenarios and any mod-specific server probe commands. The framework provides process control, client control, scenario orchestration, and reporting. + +The consuming mod should keep gameplay assertions in its own test source set. Framework classes should stay reusable and avoid importing mod-specific tile entities, blocks, items, or packets. diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..6e84e0053 --- /dev/null +++ b/build.gradle @@ -0,0 +1,57 @@ +plugins { + id 'java' + id 'java-library' + id 'base' + id 'eclipse' + id 'maven-publish' + id 'com.gtnewhorizons.retrofuturagradle' version '1.4.0' +} + +group = 'com.github.stannismod.forge' +version = '0.1.0' + +base { + archivesName = 'forge-test-framework' +} + +sourceCompatibility = targetCompatibility = '1.8' + +java { + withSourcesJar() +} + +minecraft { + mcVersion = '1.12.2' + username = 'Developer' + useDependencyAccessTransformers = true +} + +if (file('repositories.gradle').exists()) { + apply from: 'repositories.gradle' +} + +if (file('dependencies.gradle').exists()) { + apply from: 'dependencies.gradle' +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + sourceCompatibility = '1.8' + targetCompatibility = '1.8' +} + +test { + useJUnit() + testLogging { + events 'failed', 'skipped' + exceptionFormat 'full' + } +} + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + } + } +} diff --git a/dependencies.gradle b/dependencies.gradle new file mode 100644 index 000000000..36f0ab163 --- /dev/null +++ b/dependencies.gradle @@ -0,0 +1,7 @@ +dependencies { + api 'io.netty:netty-all:4.1.9.Final' + api 'com.google.code.gson:gson:2.8.0' + api 'net.java.dev.jna:jna:4.4.0' + + testImplementation 'junit:junit:4.13.2' +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..f9ec7a35c --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.logging.stacktrace=all +org.gradle.jvmargs=-Xmx3G diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..deedc7fa5e6310eac3148a7dd0b1f069b07364cb GIT binary patch literal 52818 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≺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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..48bb4e36a --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sun Oct 17 21:57:43 MSK 2021 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip diff --git a/gradlew b/gradlew new file mode 100644 index 000000000..9aa616c27 --- /dev/null +++ b/gradlew @@ -0,0 +1,169 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [[ "$(uname)" == "Darwin" ]] && [[ "$HOME" == "$PWD" ]]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..f9553162f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/repositories.gradle b/repositories.gradle new file mode 100644 index 000000000..9ee8351b2 --- /dev/null +++ b/repositories.gradle @@ -0,0 +1,7 @@ +repositories { + mavenCentral() + maven { + name = 'GTNH Maven' + url = 'https://nexus.gtnewhorizons.com/repository/public/' + } +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..924063e14 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + maven { + name = 'GTNH Maven' + url = 'https://nexus.gtnewhorizons.com/repository/public/' + mavenContent { + includeGroup 'com.gtnewhorizons' + includeGroup 'com.gtnewhorizons.retrofuturagradle' + } + } + gradlePluginPortal() + mavenCentral() + mavenLocal() + } +} + +rootProject.name = 'ForgeTestFramework' diff --git a/src/main/java/com/github/stannismod/forge/testing/HeadlessGameTest.java b/src/main/java/com/github/stannismod/forge/testing/HeadlessGameTest.java new file mode 100644 index 000000000..bba053bd0 --- /dev/null +++ b/src/main/java/com/github/stannismod/forge/testing/HeadlessGameTest.java @@ -0,0 +1,19 @@ +package com.github.stannismod.forge.testing; + +public interface HeadlessGameTest { + + String id(); + + String category(); + + boolean required(); + + int timeoutTicks(); + + void setUp(TestContext context) throws Exception; + + TestStatus tick(TestContext context) throws Exception; + + void tearDown(TestContext context) throws Exception; +} + diff --git a/src/main/java/com/github/stannismod/forge/testing/TestAssertions.java b/src/main/java/com/github/stannismod/forge/testing/TestAssertions.java new file mode 100644 index 000000000..b76b60b1a --- /dev/null +++ b/src/main/java/com/github/stannismod/forge/testing/TestAssertions.java @@ -0,0 +1,35 @@ +package com.github.stannismod.forge.testing; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; + +public final class TestAssertions { + + private TestAssertions() { + } + + public static ByteBuf newBuffer() { + return Unpooled.buffer(); + } + + public static T roundTrip(T value, BufferWriter writer, BufferReader reader) { + ByteBuf buffer = newBuffer(); + writer.write(value, buffer); + return reader.read(buffer); + } + + public static void assertFullyConsumed(ByteBuf buffer) { + if (buffer.isReadable()) { + throw new AssertionError("Expected buffer to be fully consumed but still had " + buffer.readableBytes() + " readable bytes"); + } + } + + public interface BufferWriter { + void write(T value, ByteBuf buffer); + } + + public interface BufferReader { + T read(ByteBuf buffer); + } +} + diff --git a/src/main/java/com/github/stannismod/forge/testing/TestBootstrap.java b/src/main/java/com/github/stannismod/forge/testing/TestBootstrap.java new file mode 100644 index 000000000..66cd2233c --- /dev/null +++ b/src/main/java/com/github/stannismod/forge/testing/TestBootstrap.java @@ -0,0 +1,23 @@ +package com.github.stannismod.forge.testing; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; + +public final class TestBootstrap { + + private final TestRegistry registry; + private final TestReportWriter reportWriter; + + public TestBootstrap(TestRegistry registry, TestReportWriter reportWriter) { + this.registry = registry; + this.reportWriter = reportWriter; + } + + public List run(Path reportRoot) throws IOException { + List outcomes = new TestOrchestrator(registry).runAll(reportRoot); + reportWriter.write(reportRoot, outcomes); + return outcomes; + } +} + diff --git a/src/main/java/com/github/stannismod/forge/testing/TestContext.java b/src/main/java/com/github/stannismod/forge/testing/TestContext.java new file mode 100644 index 000000000..ec43ce820 --- /dev/null +++ b/src/main/java/com/github/stannismod/forge/testing/TestContext.java @@ -0,0 +1,58 @@ +package com.github.stannismod.forge.testing; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +public final class TestContext implements AutoCloseable { + + private final String testId; + private final Path workDir; + private final Map attributes = new LinkedHashMap<>(); + private final List notes = new ArrayList<>(); + + public TestContext(String testId, Path workDir) { + this.testId = testId; + this.workDir = workDir; + } + + public String testId() { + return testId; + } + + public Path workDir() { + return workDir; + } + + public void ensureWorkDir() throws IOException { + Files.createDirectories(workDir); + } + + public void note(String message) { + notes.add(message); + } + + public List notes() { + return Collections.unmodifiableList(notes); + } + + public void put(String key, Object value) { + attributes.put(key, value); + } + + @SuppressWarnings("unchecked") + public T get(String key) { + return (T) attributes.get(key); + } + + public Map attributes() { + return Collections.unmodifiableMap(attributes); + } + + @Override + public void close() { + // The harness keeps cleanup explicit to stay predictable in tests. + } +} + diff --git a/src/main/java/com/github/stannismod/forge/testing/TestOrchestrator.java b/src/main/java/com/github/stannismod/forge/testing/TestOrchestrator.java new file mode 100644 index 000000000..01ddc282b --- /dev/null +++ b/src/main/java/com/github/stannismod/forge/testing/TestOrchestrator.java @@ -0,0 +1,79 @@ +package com.github.stannismod.forge.testing; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +public final class TestOrchestrator { + + private final TestRegistry registry; + + public TestOrchestrator(TestRegistry registry) { + this.registry = registry; + } + + public List runAll(Path reportRoot) throws IOException { + Files.createDirectories(reportRoot); + List outcomes = new ArrayList<>(); + for (HeadlessGameTest test : registry.tests()) { + outcomes.add(runOne(test, reportRoot.resolve(safeId(test.id())))); + } + return outcomes; + } + + private TestOutcome runOne(HeadlessGameTest test, Path workDir) throws IOException { + Files.createDirectories(workDir); + TestContext context = new TestContext(test.id(), workDir); + long startedAt = System.nanoTime(); + TestStatus status = TestStatus.RUNNING; + Throwable failure = null; + int ticks = 0; + + try { + test.setUp(context); + while (status == TestStatus.RUNNING) { + if (ticks >= Math.max(1, test.timeoutTicks())) { + status = TestStatus.FAILED; + failure = new AssertionError("Timed out after " + ticks + " ticks"); + break; + } + + TestStatus nextStatus = test.tick(context); + ticks++; + status = nextStatus == null ? TestStatus.RUNNING : nextStatus; + } + } catch (Throwable t) { + status = TestStatus.FAILED; + failure = t; + } finally { + try { + test.tearDown(context); + } catch (Throwable t) { + if (failure == null) { + failure = t; + } + status = TestStatus.FAILED; + } + context.close(); + } + + long duration = System.nanoTime() - startedAt; + return new TestOutcome( + test.id(), + test.category(), + test.required(), + status, + ticks, + duration, + failure, + new ArrayList<>(context.notes()) + ); + } + + private static String safeId(String id) { + return id.replaceAll("[^a-zA-Z0-9._-]", "_"); + } +} + diff --git a/src/main/java/com/github/stannismod/forge/testing/TestOutcome.java b/src/main/java/com/github/stannismod/forge/testing/TestOutcome.java new file mode 100644 index 000000000..d3049cd1d --- /dev/null +++ b/src/main/java/com/github/stannismod/forge/testing/TestOutcome.java @@ -0,0 +1,72 @@ +package com.github.stannismod.forge.testing; + +import java.util.Collections; +import java.util.List; + +public final class TestOutcome { + + private final String id; + private final String category; + private final boolean required; + private final TestStatus status; + private final int ticks; + private final long durationNanos; + private final Throwable failure; + private final List notes; + + public TestOutcome( + String id, + String category, + boolean required, + TestStatus status, + int ticks, + long durationNanos, + Throwable failure, + List notes) { + this.id = id; + this.category = category; + this.required = required; + this.status = status; + this.ticks = ticks; + this.durationNanos = durationNanos; + this.failure = failure; + this.notes = notes == null ? Collections.emptyList() : Collections.unmodifiableList(notes); + } + + public String id() { + return id; + } + + public String category() { + return category; + } + + public boolean required() { + return required; + } + + public TestStatus status() { + return status; + } + + public int ticks() { + return ticks; + } + + public long durationNanos() { + return durationNanos; + } + + public Throwable failure() { + return failure; + } + + public List notes() { + return notes; + } + + public boolean passed() { + return status == TestStatus.PASSED; + } +} + diff --git a/src/main/java/com/github/stannismod/forge/testing/TestRegistry.java b/src/main/java/com/github/stannismod/forge/testing/TestRegistry.java new file mode 100644 index 000000000..9abbaf294 --- /dev/null +++ b/src/main/java/com/github/stannismod/forge/testing/TestRegistry.java @@ -0,0 +1,20 @@ +package com.github.stannismod.forge.testing; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class TestRegistry { + + private final List tests = new ArrayList<>(); + + public TestRegistry register(HeadlessGameTest test) { + tests.add(test); + return this; + } + + public List tests() { + return Collections.unmodifiableList(tests); + } +} + diff --git a/src/main/java/com/github/stannismod/forge/testing/TestReportWriter.java b/src/main/java/com/github/stannismod/forge/testing/TestReportWriter.java new file mode 100644 index 000000000..eb629ee5c --- /dev/null +++ b/src/main/java/com/github/stannismod/forge/testing/TestReportWriter.java @@ -0,0 +1,116 @@ +package com.github.stannismod.forge.testing; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +public final class TestReportWriter { + + public void write(Path root, List outcomes) throws IOException { + Files.createDirectories(root); + Files.write(root.resolve("summary.txt"), buildText(outcomes).getBytes(StandardCharsets.UTF_8)); + Files.write(root.resolve("summary.json"), buildJson(outcomes).getBytes(StandardCharsets.UTF_8)); + } + + private static String buildText(List outcomes) { + StringBuilder builder = new StringBuilder(); + int passed = 0; + int failed = 0; + int skipped = 0; + for (TestOutcome outcome : outcomes) { + if (outcome.status() == TestStatus.PASSED) { + passed++; + } else if (outcome.status() == TestStatus.SKIPPED) { + skipped++; + } else if (outcome.status() == TestStatus.FAILED) { + failed++; + } + } + + builder.append("total=").append(outcomes.size()) + .append(", passed=").append(passed) + .append(", failed=").append(failed) + .append(", skipped=").append(skipped) + .append(System.lineSeparator()); + for (TestOutcome outcome : outcomes) { + builder.append(outcome.status()) + .append(' ') + .append(outcome.id()) + .append(" [") + .append(outcome.category()) + .append("] ticks=") + .append(outcome.ticks()) + .append(" durationNanos=") + .append(outcome.durationNanos()); + if (outcome.failure() != null) { + builder.append(" failure=").append(outcome.failure().getClass().getSimpleName()); + } + builder.append(System.lineSeparator()); + } + return builder.toString(); + } + + private static String buildJson(List outcomes) { + StringBuilder builder = new StringBuilder(); + int passed = 0; + int failed = 0; + int skipped = 0; + for (TestOutcome outcome : outcomes) { + if (outcome.status() == TestStatus.PASSED) { + passed++; + } else if (outcome.status() == TestStatus.SKIPPED) { + skipped++; + } else if (outcome.status() == TestStatus.FAILED) { + failed++; + } + } + + builder.append("{"); + builder.append("\"total\":").append(outcomes.size()).append(","); + builder.append("\"passed\":").append(passed).append(","); + builder.append("\"failed\":").append(failed).append(","); + builder.append("\"skipped\":").append(skipped).append(","); + builder.append("\"tests\":["); + for (int i = 0; i < outcomes.size(); i++) { + if (i > 0) { + builder.append(","); + } + TestOutcome outcome = outcomes.get(i); + builder.append("{") + .append("\"id\":\"").append(escape(outcome.id())).append("\",") + .append("\"category\":\"").append(escape(outcome.category())).append("\",") + .append("\"status\":\"").append(outcome.status()).append("\",") + .append("\"required\":").append(outcome.required()).append(",") + .append("\"ticks\":").append(outcome.ticks()).append(",") + .append("\"durationNanos\":").append(outcome.durationNanos()).append(",") + .append("\"notes\":["); + List notes = outcome.notes(); + for (int j = 0; j < notes.size(); j++) { + if (j > 0) { + builder.append(","); + } + builder.append("\"").append(escape(notes.get(j))).append("\""); + } + builder.append("]"); + if (outcome.failure() != null) { + builder.append(",\"failure\":\"") + .append(escape(outcome.failure().toString())) + .append("\""); + } + builder.append("}"); + } + builder.append("]}"); + return builder.toString(); + } + + private static String escape(String value) { + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r"); + } +} + diff --git a/src/main/java/com/github/stannismod/forge/testing/TestStatus.java b/src/main/java/com/github/stannismod/forge/testing/TestStatus.java new file mode 100644 index 000000000..5aff7db6d --- /dev/null +++ b/src/main/java/com/github/stannismod/forge/testing/TestStatus.java @@ -0,0 +1,9 @@ +package com.github.stannismod.forge.testing; + +public enum TestStatus { + RUNNING, + PASSED, + FAILED, + SKIPPED +} + diff --git a/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java b/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java new file mode 100644 index 000000000..ee61601da --- /dev/null +++ b/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java @@ -0,0 +1,188 @@ +package com.github.stannismod.forge.testing.client; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.EnumHand; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.Closeable; +import java.io.IOException; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Objects; + +public final class ClientBot implements Closeable { + + private final Socket socket; + private final BufferedReader reader; + private final BufferedWriter writer; + + ClientBot(Socket socket) throws IOException { + this.socket = socket; + this.socket.setTcpNoDelay(true); + this.socket.setSoTimeout((int) Duration.ofMinutes(2).toMillis()); + this.reader = new BufferedReader(new java.io.InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); + this.writer = new BufferedWriter(new java.io.OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)); + awaitReady(Duration.ofMinutes(2)); + } + + public void waitForWorld() throws IOException { + assertOk(execute(command("wait_world"))); + } + + public void waitTicks(int ticks) throws IOException { + JsonObject command = command("wait_ticks"); + command.addProperty("ticks", ticks); + assertOk(execute(command)); + } + + public void selectHotbar(int slot) throws IOException { + JsonObject command = command("select_hotbar"); + command.addProperty("slot", slot); + assertOk(execute(command)); + } + + public void rightClickBlock(int x, int y, int z, EnumFacing face, EnumHand hand) throws IOException { + JsonObject command = command("right_click_block"); + command.addProperty("x", x); + command.addProperty("y", y); + command.addProperty("z", z); + command.addProperty("face", face.name()); + command.addProperty("hand", hand.name()); + assertOk(execute(command)); + } + + public void clickScreenPoint(int x, int y, int button) throws IOException { + JsonObject command = command("click_screen_point"); + command.addProperty("x", x); + command.addProperty("y", y); + command.addProperty("button", button); + assertOk(execute(command)); + } + + public void clickButton(int index) throws IOException { + JsonObject command = command("click_button"); + command.addProperty("index", index); + assertOk(execute(command)); + } + + public void clickButtonAtRatio(int index, double ratio) throws IOException { + JsonObject command = command("click_button_ratio"); + command.addProperty("index", index); + command.addProperty("ratio", ratio); + assertOk(execute(command)); + } + + public void dragScreenPoint(int startX, int startY, int endX, int endY, int button) throws IOException { + JsonObject command = command("drag_screen_point"); + command.addProperty("startX", startX); + command.addProperty("startY", startY); + command.addProperty("endX", endX); + command.addProperty("endY", endY); + command.addProperty("button", button); + assertOk(execute(command)); + } + + public void focusField(String fieldName) throws IOException { + JsonObject command = command("focus_field"); + command.addProperty("field", fieldName); + assertOk(execute(command)); + } + + public void typeText(String text) throws IOException { + JsonObject command = command("type_text"); + command.addProperty("text", text); + assertOk(execute(command)); + } + + public void pressEnterAfterTyping(String text) throws IOException { + JsonObject command = command("type_text"); + command.addProperty("text", text); + command.addProperty("pressEnter", true); + assertOk(execute(command)); + } + + public JsonObject reportState() throws IOException { + return assertOk(execute(command("report_state"))); + } + + public JsonObject blockState(int x, int y, int z) throws IOException { + JsonObject command = command("block_state"); + command.addProperty("x", x); + command.addProperty("y", y); + command.addProperty("z", z); + return assertOk(execute(command)); + } + + public void closeScreen() throws IOException { + assertOk(execute(command("close_screen"))); + } + + public void shutdown() throws IOException { + assertOk(execute(command("shutdown"))); + } + + @Override + public void close() throws IOException { + try { + if (socket.isConnected() && !socket.isClosed()) { + try { + shutdown(); + } catch (IOException ignored) { + // The client may already be gone. + } + } + } finally { + socket.close(); + } + } + + private JsonObject execute(JsonObject command) throws IOException { + synchronized (writer) { + writer.write(command.toString()); + writer.newLine(); + writer.flush(); + } + + String line = reader.readLine(); + if (line == null) { + throw new IOException("Client bridge closed unexpectedly"); + } + + JsonElement parsed = new JsonParser().parse(line); + if (!parsed.isJsonObject()) { + throw new IOException("Malformed client bridge response: " + line); + } + return parsed.getAsJsonObject(); + } + + private JsonObject assertOk(JsonObject response) throws IOException { + if (!response.has("ok") || !response.get("ok").getAsBoolean()) { + String message = response.has("error") ? response.get("error").getAsString() : "unknown client bridge error"; + throw new IOException(message); + } + return response; + } + + private void awaitReady(Duration timeout) throws IOException { + String line = reader.readLine(); + if (line == null) { + throw new IOException("Client bridge disconnected before signaling readiness"); + } + if ("READY".equals(line)) { + return; + } + throw new IOException("Timed out waiting for client bridge readiness"); + } + + private static JsonObject command(String command) { + JsonObject object = new JsonObject(); + object.addProperty("command", Objects.requireNonNull(command, "command")); + return object; + } +} + diff --git a/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java b/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java new file mode 100644 index 000000000..fd35e56c6 --- /dev/null +++ b/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java @@ -0,0 +1,692 @@ +package com.github.stannismod.forge.testing.client; + +import com.github.stannismod.forge.testing.server.RealDedicatedServerHarness; +import com.sun.jna.*; +import com.sun.jna.ptr.IntByReference; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +public final class RealClientHarness implements AutoCloseable { + + private static final String CLIENT_USERNAME = "ForgeTestClient"; + private static final boolean WINDOWS = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT).contains("win"); + private static final int NORMAL_PRIORITY_CLASS = 0x00000020; + private static final int CREATE_NEW_PROCESS_GROUP = 0x00000200; + private static final int WAIT_TIMEOUT = 0x00000102; + private static final int WAIT_FAILED = 0xFFFFFFFF; + private static final int STILL_ACTIVE = 259; + private static final int STARTF_USESHOWWINDOW = 0x00000001; + private static final int SW_SHOWNOACTIVATE = 4; + + private final Path root; + private final Process process; + private final ClientBot bot; + private final Path clientLogFile; + + private RealClientHarness(Path root, Process process, ClientBot bot, Path clientLogFile) { + this.root = root; + this.process = process; + this.bot = bot; + this.clientLogFile = clientLogFile; + } + + public static RealClientHarness start(RealDedicatedServerHarness serverHarness) throws Exception { + Path root = Files.createTempDirectory("forge-client-"); + Files.createDirectories(root.resolve("resourcepacks")); + bootstrapClientFiles(root); + + int controlPort = reservePort(); + Path clientLogFile = root.resolve("client.log"); + Process process = null; + try (java.net.ServerSocket controlSocket = openControlSocket(controlPort)) { + process = launchClient(root, serverHarness.port(), controlPort, clientLogFile); + + ClientBot bot = awaitClientBot(controlSocket); + bot.waitForWorld(); + return new RealClientHarness(root, process, bot, clientLogFile); + } catch (Exception exception) { + shutdownProcess(process); + deleteRecursively(root); + throw new IOException("Failed to start real client harness. Recent client log:\n" + tailFile(clientLogFile), exception); + } + } + + public Path root() { + return root; + } + + public ClientBot bot() { + return bot; + } + + @Override + public void close() throws IOException { + try { + if (bot != null) { + bot.close(); + } + } finally { + try { + shutdownProcess(process); + } finally { + deleteRecursively(root); + } + } + } + + private static Process launchClient(Path root, int serverPort, int controlPort, Path clientLogFile) throws IOException { + Path javaBinary = resolveJavaBinary(); + Path assetsDir = gradleUserHome().resolve("caches").resolve("retro_futura_gradle").resolve("assets"); + Path nativesDir = resolveNativesDir(); + + String currentClassPath = Objects.requireNonNull(System.getProperty("java.class.path"), "java.class.path"); + Path libDir = Files.createTempDirectory(root, "client-libs-"); + String launcherClassPath = buildLauncherClassPath(currentClassPath, libDir); + + List javaArgs = new ArrayList<>(); + javaArgs.add("-Djava.awt.headless=true"); + javaArgs.add("-Dforge.test.client=true"); + javaArgs.add("-Dforge.test.client.port=" + controlPort); + javaArgs.add("-Djava.library.path=" + nativesDir.toAbsolutePath()); + javaArgs.add("-Dorg.lwjgl.librarypath=" + nativesDir.toAbsolutePath()); + javaArgs.add("-Dforge.test.client.logFile=" + clientLogFile.toAbsolutePath()); + javaArgs.add("-cp"); + javaArgs.add(launcherClassPath); + javaArgs.add("GradleStart"); + javaArgs.add("--server"); + javaArgs.add("127.0.0.1"); + javaArgs.add("--port"); + javaArgs.add(String.valueOf(serverPort)); + javaArgs.add("--gameDir"); + javaArgs.add(root.toAbsolutePath().toString()); + javaArgs.add("--assetsDir"); + javaArgs.add(assetsDir.toAbsolutePath().toString()); + javaArgs.add("--resourcePackDir"); + javaArgs.add(root.resolve("resourcepacks").toAbsolutePath().toString()); + javaArgs.add("--version"); + javaArgs.add("FML_DEV"); + javaArgs.add("--assetIndex"); + javaArgs.add("1.12.2"); + javaArgs.add("--username"); + javaArgs.add(CLIENT_USERNAME); + javaArgs.add("--accessToken"); + javaArgs.add("FML"); + javaArgs.add("--userProperties"); + javaArgs.add("{}"); + javaArgs.add("--profileProperties"); + javaArgs.add("{}"); + javaArgs.add("--uuid"); + javaArgs.add(UUID.nameUUIDFromBytes(("OfflinePlayer:" + CLIENT_USERNAME).getBytes(StandardCharsets.UTF_8)).toString().replace("-", "")); + javaArgs.add("--width"); + javaArgs.add("640"); + javaArgs.add("--height"); + javaArgs.add("480"); + + List command = new ArrayList<>(); + command.add(javaBinary.toString()); + command.addAll(javaArgs); + + if (WINDOWS) { + try { + return launchWindowsClient(root, javaBinary, javaArgs); + } catch (IOException nativeLaunchFailure) { + ProcessBuilder fallback = new ProcessBuilder(command); + fallback.directory(root.toFile()); + fallback.redirectErrorStream(true); + Process process = fallback.start(); + return new LoggedProcess(process, clientLogFile); + } + } + + ProcessBuilder builder = new ProcessBuilder(command); + builder.directory(root.toFile()); + builder.redirectErrorStream(true); + return builder.start(); + } + + private static ClientBot awaitClientBot(java.net.ServerSocket serverSocket) throws IOException { + serverSocket.setSoTimeout((int) TimeUnit.MINUTES.toMillis(2)); + java.net.Socket socket = serverSocket.accept(); + return new ClientBot(socket); + } + + private static Path resolveJavaBinary() throws IOException { + String javaHome = System.getProperty("java.home"); + if (javaHome == null || javaHome.trim().isEmpty()) { + return Paths.get(WINDOWS ? "javaw.exe" : "java"); + } + + List candidates = new ArrayList<>(); + Path javaHomePath = Paths.get(javaHome); + if (WINDOWS) { + candidates.add(javaHomePath.resolve("bin").resolve("javaw.exe")); + candidates.add(javaHomePath.resolve("bin").resolve("java.exe")); + Path parent = javaHomePath.getParent(); + if (parent != null) { + candidates.add(parent.resolve("bin").resolve("javaw.exe")); + candidates.add(parent.resolve("bin").resolve("java.exe")); + } + } else { + candidates.add(javaHomePath.resolve("bin").resolve("java")); + Path parent = javaHomePath.getParent(); + if (parent != null) { + candidates.add(parent.resolve("bin").resolve("java")); + } + } + + for (Path candidate : candidates) { + if (Files.isRegularFile(candidate)) { + return candidate; + } + } + + throw new IOException("Unable to locate Java launcher for java.home=" + javaHome + ", candidates=" + candidates); + } + + private static Process launchWindowsClient(Path root, Path javaBinary, List javaArgs) throws IOException { + List commandLineArgs = new ArrayList<>(); + commandLineArgs.add(javaBinary.toAbsolutePath().toString()); + commandLineArgs.addAll(javaArgs); + + STARTUPINFO startupInfo = new STARTUPINFO(); + startupInfo.cb = startupInfo.size(); + startupInfo.dwFlags = STARTF_USESHOWWINDOW; + startupInfo.wShowWindow = (short) SW_SHOWNOACTIVATE; + + PROCESS_INFORMATION processInformation = new PROCESS_INFORMATION(); + startupInfo.write(); + processInformation.write(); + boolean created = Kernel32Native.INSTANCE.CreateProcessW( + new WString(javaBinary.toAbsolutePath().toString()), + new WString(buildCommandLine(commandLineArgs)), + null, + null, + false, + NORMAL_PRIORITY_CLASS | CREATE_NEW_PROCESS_GROUP, + Pointer.NULL, + new WString(root.toAbsolutePath().toString()), + startupInfo, + processInformation); + + if (!created) { + throw new IOException("Failed to create client process at " + + javaBinary.toAbsolutePath() + + ", lastError=" + + Native.getLastError()); + } + + processInformation.read(); + Kernel32Native.INSTANCE.CloseHandle(processInformation.hThread); + return new NativeClientProcess(processInformation.hProcess, processInformation.dwProcessId); + } + + private static String buildCommandLine(List javaArgs) { + StringBuilder commandLine = new StringBuilder(); + for (int i = 0; i < javaArgs.size(); i++) { + String arg = javaArgs.get(i); + if (i > 0) { + commandLine.append(' '); + } + commandLine.append(quoteForCommandLine(arg)); + } + return commandLine.toString(); + } + + private static String quoteForCommandLine(String value) { + if (value == null || value.isEmpty()) { + return "\"\""; + } + if (value.indexOf(' ') < 0 && value.indexOf('\t') < 0 && value.indexOf('"') < 0) { + return value; + } + StringBuilder builder = new StringBuilder(); + builder.append('"'); + int backslashes = 0; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '\\') { + backslashes++; + continue; + } + if (c == '"') { + for (int j = 0; j < backslashes * 2 + 1; j++) { + builder.append('\\'); + } + builder.append('"'); + backslashes = 0; + continue; + } + for (int j = 0; j < backslashes; j++) { + builder.append('\\'); + } + backslashes = 0; + builder.append(c); + } + for (int j = 0; j < backslashes; j++) { + builder.append('\\'); + } + builder.append('"'); + return builder.toString(); + } + + private static void bootstrapClientFiles(Path root) throws IOException { + List options = new ArrayList<>(); + options.add("pauseOnLostFocus:false"); + options.add("fboEnable:true"); + options.add("renderDistance:8"); + Files.write(root.resolve("options.txt"), options, StandardCharsets.UTF_8); + } + + private static int reservePort() throws IOException { + try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) { + socket.setReuseAddress(true); + return socket.getLocalPort(); + } + } + + private static java.net.ServerSocket openControlSocket(int port) throws IOException { + java.net.ServerSocket socket = new java.net.ServerSocket(); + socket.setReuseAddress(true); + socket.bind(new java.net.InetSocketAddress("127.0.0.1", port)); + return socket; + } + + private static Path gradleUserHome() { + String env = System.getenv("GRADLE_USER_HOME"); + if (env != null && !env.trim().isEmpty()) { + return Paths.get(env.trim()); + } + return Paths.get(System.getProperty("user.home"), ".gradle"); + } + + private static Path resolveNativesDir() throws IOException { + Path[] candidates = new Path[] { + gradleUserHome().resolve("caches").resolve("minecraft").resolve("net").resolve("minecraft").resolve("natives").resolve("1.12.2"), + Paths.get(System.getProperty("user.home"), ".gradle").resolve("caches").resolve("minecraft").resolve("net").resolve("minecraft").resolve("natives").resolve("1.12.2") + }; + + for (Path candidate : candidates) { + if (Files.isRegularFile(candidate.resolve("lwjgl64.dll")) || Files.isRegularFile(candidate.resolve("lwjgl.dll"))) { + return candidate; + } + } + + throw new IOException("Unable to locate LWJGL natives directory in any known Gradle cache location"); + } + + private static Path findCachedJar(String fileName) throws IOException { + Path cacheRoot = gradleUserHome().resolve("caches").resolve("modules-2").resolve("files-2.1"); + try (java.util.stream.Stream stream = Files.walk(cacheRoot)) { + return stream + .filter(path -> Files.isRegularFile(path) && fileName.equals(path.getFileName().toString())) + .findFirst() + .orElseThrow(() -> new IOException("Missing cached jar: " + fileName + " under " + cacheRoot)); + } + } + + private static String buildLauncherClassPath(String currentClassPath, Path libDir) throws IOException { + List entries = new ArrayList<>(); + String[] split = currentClassPath.split(java.io.File.pathSeparator); + for (String rawEntry : split) { + if (rawEntry == null || rawEntry.trim().isEmpty()) { + continue; + } + + Path entryPath = Paths.get(rawEntry); + if (Files.isDirectory(entryPath)) { + entries.add(entryPath.toAbsolutePath().toString()); + continue; + } + + if (rawEntry.endsWith(".jar")) { + Path target = libDir.resolve(entryPath.getFileName()); + Files.copy(entryPath, target, StandardCopyOption.REPLACE_EXISTING); + continue; + } + + entries.add(entryPath.toAbsolutePath().toString()); + } + + copyCachedJar(libDir, "lwjgl-2.9.4-nightly-20150209.jar"); + copyCachedJar(libDir, "lwjgl_util-2.9.4-nightly-20150209.jar"); + copyCachedJar(libDir, "jinput-2.0.5.jar"); + copyCachedJar(libDir, "librarylwjglopenal-20100824.jar"); + copyCachedJar(libDir, "lwjgl-platform-2.9.4-nightly-20150209-natives-windows.jar"); + copyCachedJar(libDir, "jinput-platform-2.0.5-natives-windows.jar"); + + entries.add(libDir.toAbsolutePath() + java.io.File.separator + "*"); + return String.join(java.io.File.pathSeparator, entries); + } + + private static void copyCachedJar(Path libDir, String fileName) throws IOException { + Path source = findCachedJar(fileName); + Path target = libDir.resolve(source.getFileName()); + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + } + + private static void deleteRecursively(Path root) throws IOException { + if (root == null || !Files.exists(root)) { + return; + } + Files.walkFileTree(root, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.deleteIfExists(dir); + return FileVisitResult.CONTINUE; + } + }); + } + + private static String tailFile(Path file) { + if (file == null || !Files.isRegularFile(file)) { + return ""; + } + try { + List lines = Files.readAllLines(file, StandardCharsets.UTF_8); + if (lines.isEmpty()) { + return ""; + } + int from = Math.max(0, lines.size() - 40); + StringBuilder builder = new StringBuilder(); + for (int i = from; i < lines.size(); i++) { + if (i > from) { + builder.append(System.lineSeparator()); + } + builder.append(lines.get(i)); + } + return builder.toString(); + } catch (IOException ignored) { + return ""; + } + } + + private static void shutdownProcess(Process process) { + if (process == null) { + return; + } + try { + process.destroyForcibly(); + process.waitFor(30, TimeUnit.SECONDS); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + } finally { + if (process instanceof NativeClientProcess) { + ((NativeClientProcess) process).closeHandle(); + } + } + } + + private static final class NativeClientProcess extends Process { + private final Pointer processHandle; + private final int processId; + + private NativeClientProcess(Pointer processHandle, int processId) { + this.processHandle = processHandle; + this.processId = processId; + } + + @Override + public OutputStream getOutputStream() { + return new OutputStream() { + @Override + public void write(int b) { + // No stdin pipe. + } + }; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(new byte[0]); + } + + @Override + public InputStream getErrorStream() { + return new ByteArrayInputStream(new byte[0]); + } + + @Override + public int waitFor() throws InterruptedException { + int waitResult = Kernel32Native.INSTANCE.WaitForSingleObject(processHandle, -1); + if (waitResult == WAIT_FAILED) { + throw new IllegalStateException("WaitForSingleObject failed for client process " + processId); + } + return exitValue(); + } + + @Override + public boolean waitFor(long timeout, TimeUnit unit) throws InterruptedException { + long timeoutMillis = unit.toMillis(timeout); + int waitResult = Kernel32Native.INSTANCE.WaitForSingleObject(processHandle, (int) Math.min(Integer.MAX_VALUE, timeoutMillis)); + if (waitResult == WAIT_FAILED) { + throw new IllegalStateException("WaitForSingleObject failed for client process " + processId); + } + return waitResult != WAIT_TIMEOUT; + } + + @Override + public int exitValue() { + IntByReference code = new IntByReference(); + if (!Kernel32Native.INSTANCE.GetExitCodeProcess(processHandle, code)) { + throw new IllegalThreadStateException("Unable to query exit code for client process " + processId); + } + int value = code.getValue(); + if (value == STILL_ACTIVE) { + throw new IllegalThreadStateException("Client process " + processId + " is still running"); + } + return value; + } + + @Override + public void destroy() { + Kernel32Native.INSTANCE.TerminateProcess(processHandle, 1); + } + + @Override + public Process destroyForcibly() { + destroy(); + return this; + } + + @Override + public boolean isAlive() { + return Kernel32Native.INSTANCE.WaitForSingleObject(processHandle, 0) == WAIT_TIMEOUT; + } + + private void closeHandle() { + Kernel32Native.INSTANCE.CloseHandle(processHandle); + } + } + + private interface Kernel32Native extends Library { + Kernel32Native INSTANCE = Native.loadLibrary("kernel32", Kernel32Native.class); + + boolean CreateProcessW(WString lpApplicationName, + WString lpCommandLine, + Pointer lpProcessAttributes, + Pointer lpThreadAttributes, + boolean bInheritHandles, + int dwCreationFlags, + Pointer lpEnvironment, + WString lpCurrentDirectory, + STARTUPINFO lpStartupInfo, + PROCESS_INFORMATION lpProcessInformation); + + int WaitForSingleObject(Pointer hHandle, int dwMilliseconds); + + boolean GetExitCodeProcess(Pointer hProcess, IntByReference lpExitCode); + + boolean TerminateProcess(Pointer hProcess, int uExitCode); + + boolean CloseHandle(Pointer hObject); + } + + public static final class STARTUPINFO extends Structure { + public int cb; + public String lpReserved; + public String lpDesktop; + public String lpTitle; + public int dwX; + public int dwY; + public int dwXSize; + public int dwYSize; + public int dwXCountChars; + public int dwYCountChars; + public int dwFillAttribute; + public int dwFlags; + public short wShowWindow; + public short cbReserved2; + public Pointer lpReserved2; + public Pointer hStdInput; + public Pointer hStdOutput; + public Pointer hStdError; + + @Override + protected List getFieldOrder() { + return java.util.Arrays.asList( + "cb", + "lpReserved", + "lpDesktop", + "lpTitle", + "dwX", + "dwY", + "dwXSize", + "dwYSize", + "dwXCountChars", + "dwYCountChars", + "dwFillAttribute", + "dwFlags", + "wShowWindow", + "cbReserved2", + "lpReserved2", + "hStdInput", + "hStdOutput", + "hStdError"); + } + } + + public static final class PROCESS_INFORMATION extends Structure { + public Pointer hProcess; + public Pointer hThread; + public int dwProcessId; + public int dwThreadId; + + @Override + protected List getFieldOrder() { + return java.util.Arrays.asList( + "hProcess", + "hThread", + "dwProcessId", + "dwThreadId"); + } + } + + private static final class LoggedProcess extends Process { + private final Process delegate; + private final Thread stdoutPump; + private final Thread stderrPump; + + private LoggedProcess(Process delegate, Path logFile) throws IOException { + this.delegate = delegate; + this.stdoutPump = pump(delegate.getInputStream(), logFile); + this.stderrPump = pump(delegate.getErrorStream(), logFile); + } + + @Override + public OutputStream getOutputStream() { + return delegate.getOutputStream(); + } + + @Override + public InputStream getInputStream() { + return delegate.getInputStream(); + } + + @Override + public InputStream getErrorStream() { + return delegate.getErrorStream(); + } + + @Override + public int waitFor() throws InterruptedException { + int code = delegate.waitFor(); + joinPump(stdoutPump); + joinPump(stderrPump); + return code; + } + + @Override + public boolean waitFor(long timeout, TimeUnit unit) throws InterruptedException { + boolean finished = delegate.waitFor(timeout, unit); + if (finished) { + joinPump(stdoutPump); + joinPump(stderrPump); + } + return finished; + } + + @Override + public int exitValue() { + return delegate.exitValue(); + } + + @Override + public void destroy() { + delegate.destroy(); + } + + @Override + public Process destroyForcibly() { + delegate.destroyForcibly(); + return this; + } + + @Override + public boolean isAlive() { + return delegate.isAlive(); + } + + private static Thread pump(InputStream input, Path logFile) throws IOException { + Thread thread = new Thread(() -> { + try (InputStream in = input; + OutputStream out = Files.newOutputStream(logFile, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) { + byte[] buffer = new byte[4096]; + int read; + while ((read = in.read(buffer)) >= 0) { + out.write(buffer, 0, read); + out.flush(); + } + } catch (IOException ignored) { + // Best effort logging only. + } + }, "forge-client-log-pump"); + thread.setDaemon(true); + thread.start(); + return thread; + } + + private static void joinPump(Thread thread) throws InterruptedException { + if (thread != null) { + thread.join(TimeUnit.SECONDS.toMillis(5)); + } + } + } +} + diff --git a/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java b/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java new file mode 100644 index 000000000..739310708 --- /dev/null +++ b/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java @@ -0,0 +1,591 @@ +package com.github.stannismod.forge.testing.client.bridge; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.minecraft.client.Minecraft; +import net.minecraft.client.entity.EntityPlayerSP; +import net.minecraft.client.gui.GuiButton; +import net.minecraft.client.gui.GuiScreen; +import net.minecraft.client.gui.GuiTextField; +import net.minecraft.client.gui.inventory.GuiContainer; +import net.minecraft.client.multiplayer.PlayerControllerMP; +import net.minecraft.network.play.client.CPacketHeldItemChange; +import net.minecraft.util.EnumFacing; +import net.minecraft.util.EnumHand; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; +import net.minecraftforge.fml.common.FMLCommonHandler; +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; +import net.minecraftforge.fml.common.gameevent.TickEvent; +import org.lwjgl.input.Keyboard; + +import java.io.*; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.Callable; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +public final class ForgeTestClientBootstrap { + + private static final AtomicBoolean STARTED = new AtomicBoolean(false); + private static final AtomicLong CLIENT_TICKS = new AtomicLong(0L); + + private ForgeTestClientBootstrap() { + } + + public static void bootstrap() { + if (!STARTED.compareAndSet(false, true)) { + return; + } + + installClientLogFile(); + FMLCommonHandler.instance().bus().register(new TickCounter()); + Thread bridgeThread = new Thread(ForgeTestClientBootstrap::runBridge, "forge-test-client-bridge"); + bridgeThread.setDaemon(true); + bridgeThread.start(); + } + + private static void installClientLogFile() { + String logFile = System.getProperty("forge.test.client.logFile"); + if (logFile == null || logFile.trim().isEmpty()) { + return; + } + + try { + File file = new File(logFile); + File parent = file.getParentFile(); + if (parent != null && !parent.exists()) { + // Best effort only. + parent.mkdirs(); + } + + PrintStream originalOut = System.out; + PrintStream originalErr = System.err; + PrintStream fileStream = new PrintStream(new FileOutputStream(file, true), true, StandardCharsets.UTF_8.name()); + PrintStream teeOut = new PrintStream(new TeeOutputStream(originalOut, fileStream), true, StandardCharsets.UTF_8.name()); + PrintStream teeErr = new PrintStream(new TeeOutputStream(originalErr, fileStream), true, StandardCharsets.UTF_8.name()); + System.setOut(teeOut); + System.setErr(teeErr); + System.out.println("Forge test client bootstrap logging installed: " + file.getAbsolutePath()); + } catch (IOException exception) { + exception.printStackTrace(); + } + } + + private static void runBridge() { + Integer port = Integer.getInteger("forge.test.client.port"); + if (port == null || port <= 0) { + return; + } + + Socket socket = null; + try { + socket = connectWithRetry(port.intValue()); + BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)); + writer.write("READY"); + writer.newLine(); + writer.flush(); + + String line; + while ((line = reader.readLine()) != null) { + JsonObject response; + try { + JsonElement parsed = new JsonParser().parse(line); + if (!parsed.isJsonObject()) { + response = error("Malformed command payload"); + } else { + response = handleCommand(parsed.getAsJsonObject()); + } + } catch (RuntimeException exception) { + response = error(exception.getMessage() == null ? exception.toString() : exception.getMessage()); + } + + writer.write(response.toString()); + writer.newLine(); + writer.flush(); + } + } catch (IOException exception) { + exception.printStackTrace(); + } finally { + if (socket != null) { + try { + socket.close(); + } catch (IOException ignored) { + // Nothing left to do. + } + } + } + } + + private static Socket connectWithRetry(int port) throws IOException { + IOException last = null; + long deadline = System.nanoTime() + TimeUnit.MINUTES.toNanos(2); + + while (System.nanoTime() < deadline) { + try { + Socket socket = new Socket(); + socket.connect(new InetSocketAddress("127.0.0.1", port), 1000); + return socket; + } catch (IOException exception) { + last = exception; + try { + Thread.sleep(200L); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for test bridge socket", interruptedException); + } + } + } + + throw new IOException("Timed out connecting Forge test bridge", last); + } + + private static JsonObject handleCommand(JsonObject request) { + String command = request.has("command") ? request.get("command").getAsString() : ""; + switch (command) { + case "wait_world": + waitForWorld(); + return ok(); + case "wait_ticks": + return waitTicks(request); + case "select_hotbar": + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + int slot = boundedInt(request, "slot", 0, 8); + mc.player.inventory.currentItem = slot; + mc.player.connection.sendPacket(new CPacketHeldItemChange(slot)); + JsonObject response = ok(); + response.addProperty("selectedHotbar", slot); + return response; + }); + case "right_click_block": + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + EntityPlayerSP player = requirePlayer(mc); + PlayerControllerMP controller = mc.playerController; + BlockPos pos = new BlockPos(requireInt(request, "x"), requireInt(request, "y"), requireInt(request, "z")); + EnumFacing face = EnumFacing.valueOf(requireString(request, "face").toUpperCase(Locale.ROOT)); + EnumHand hand = EnumHand.valueOf(requireString(request, "hand").toUpperCase(Locale.ROOT)); + Vec3d hit = new Vec3d(pos.getX() + 0.5D, pos.getY() + 0.5D, pos.getZ() + 0.5D); + controller.processRightClickBlock(player, mc.world, pos, face, hit, hand); + return ok(); + }); + case "click_screen_point": + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + GuiScreen screen = mc.currentScreen; + if (screen == null) { + throw new IllegalStateException("No current GUI to click"); + } + invokeMouseClicked(screen, requireInt(request, "x"), requireInt(request, "y"), boundedInt(request, "button", 0, 2)); + return ok(); + }); + case "click_button": + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + GuiScreen screen = mc.currentScreen; + if (screen == null) { + throw new IllegalStateException("No current GUI to click"); + } + int index = boundedInt(request, "index", 0, Integer.MAX_VALUE); + List buttons = buttonList(screen); + if (index < 0 || index >= buttons.size()) { + throw new IllegalArgumentException("Button index " + index + " is out of range"); + } + GuiButton button = (GuiButton) buttons.get(index); + invokeMouseClicked(screen, button.x + button.width / 2, button.y + button.height / 2, 0); + return ok(); + }); + case "click_button_ratio": + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + GuiScreen screen = mc.currentScreen; + if (screen == null) { + throw new IllegalStateException("No current GUI to click"); + } + int index = boundedInt(request, "index", 0, Integer.MAX_VALUE); + double ratio = request.has("ratio") ? request.get("ratio").getAsDouble() : 0.5D; + ratio = Math.max(0.0D, Math.min(1.0D, ratio)); + List buttons = buttonList(screen); + if (index < 0 || index >= buttons.size()) { + throw new IllegalArgumentException("Button index " + index + " is out of range"); + } + GuiButton button = (GuiButton) buttons.get(index); + int x = button.x + 2 + (int) Math.round((button.width - 8 - 4) * ratio); + int y = button.y + button.height / 2; + invokeMouseClicked(screen, x, y, 0); + return ok(); + }); + case "drag_screen_point": + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + GuiScreen screen = mc.currentScreen; + if (screen == null) { + throw new IllegalStateException("No current GUI to drag"); + } + int startX = requireInt(request, "startX"); + int startY = requireInt(request, "startY"); + int endX = requireInt(request, "endX"); + int endY = requireInt(request, "endY"); + int button = boundedInt(request, "button", 0, 2); + invokeMouseClicked(screen, startX, startY, button); + for (int step = 1; step <= 8; step++) { + int x = startX + (int) Math.round((endX - startX) * (step / 8.0D)); + int y = startY + (int) Math.round((endY - startY) * (step / 8.0D)); + invokeMouseClickMove(screen, x, y, button, step * 50L); + } + invokeMouseReleased(screen, endX, endY, button); + return ok(); + }); + case "focus_field": + return runOnClientThread(() -> { + GuiScreen screen = Minecraft.getMinecraft().currentScreen; + if (screen == null) { + throw new IllegalStateException("No current GUI to focus"); + } + String fieldName = requireString(request, "field"); + GuiTextField textField = textField(screen, fieldName); + textField.setFocused(true); + textField.setCursorPositionEnd(); + return ok(); + }); + case "type_text": + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + GuiScreen screen = mc.currentScreen; + if (screen == null) { + throw new IllegalStateException("No current GUI to type into"); + } + String text = requireString(request, "text"); + for (int i = 0; i < text.length(); i++) { + char typed = text.charAt(i); + invokeKeyTyped(screen, typed, 0); + } + if (request.has("pressEnter") && request.get("pressEnter").getAsBoolean()) { + invokeKeyTyped(screen, '\n', Keyboard.KEY_RETURN); + } + return ok(); + }); + case "close_screen": + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + if (mc.player != null) { + mc.player.closeScreen(); + } else { + mc.displayGuiScreen(null); + } + return ok(); + }); + case "report_state": + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + JsonObject response = ok(); + response.addProperty("worldReady", mc.world != null && mc.player != null); + response.addProperty("screen", mc.currentScreen == null ? "" : mc.currentScreen.getClass().getName()); + response.addProperty("ticks", CLIENT_TICKS.get()); + response.addProperty("screenWidth", mc.currentScreen == null ? 0 : mc.currentScreen.width); + response.addProperty("screenHeight", mc.currentScreen == null ? 0 : mc.currentScreen.height); + response.addProperty("guiLeft", 0); + response.addProperty("guiTop", 0); + response.addProperty("guiXSize", 0); + response.addProperty("guiYSize", 0); + if (mc.currentScreen instanceof net.minecraft.client.gui.inventory.GuiContainer) { + net.minecraft.client.gui.inventory.GuiContainer containerScreen = (net.minecraft.client.gui.inventory.GuiContainer) mc.currentScreen; + response.addProperty("guiLeft", intField(containerScreen, "guiLeft")); + response.addProperty("guiTop", intField(containerScreen, "guiTop")); + response.addProperty("guiXSize", intField(containerScreen, "xSize")); + response.addProperty("guiYSize", intField(containerScreen, "ySize")); + } + if (mc.player != null) { + response.addProperty("selectedHotbar", mc.player.inventory.currentItem); + response.addProperty("playerX", mc.player.posX); + response.addProperty("playerY", mc.player.posY); + response.addProperty("playerZ", mc.player.posZ); + response.addProperty("health", mc.player.getHealth()); + response.addProperty("heldItem", mc.player.getHeldItemMainhand().isEmpty() + ? "" + : String.valueOf(mc.player.getHeldItemMainhand().getItem().getRegistryName())); + } + if (mc.currentScreen instanceof GuiContainer) { + response.addProperty("container", mc.currentScreen.getClass().getName()); + } + return response; + }); + case "block_state": + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + BlockPos pos = new BlockPos(requireInt(request, "x"), requireInt(request, "y"), requireInt(request, "z")); + JsonObject response = ok(); + if (mc.world == null) { + response.addProperty("block", ""); + response.addProperty("tile", ""); + response.addProperty("loaded", false); + return response; + } + response.addProperty("loaded", mc.world.isBlockLoaded(pos)); + if (mc.world.isBlockLoaded(pos)) { + response.addProperty("block", String.valueOf(mc.world.getBlockState(pos).getBlock().getRegistryName())); + response.addProperty("tile", mc.world.getTileEntity(pos) == null + ? "" + : mc.world.getTileEntity(pos).getClass().getName()); + } else { + response.addProperty("block", ""); + response.addProperty("tile", ""); + } + return response; + }); + case "shutdown": + return runOnClientThread(() -> { + Minecraft.getMinecraft().shutdown(); + return ok(); + }); + default: + return error("Unknown command: " + command); + } + } + + private static JsonObject waitTicks(JsonObject request) { + int ticks = boundedInt(request, "ticks", 0, 1000000); + long start = CLIENT_TICKS.get(); + long deadline = System.nanoTime() + TimeUnit.MINUTES.toNanos(2); + + while (CLIENT_TICKS.get() - start < ticks) { + if (System.nanoTime() > deadline) { + return error("Timed out waiting for " + ticks + " client ticks"); + } + try { + Thread.sleep(25L); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + return error("Interrupted while waiting for ticks"); + } + } + return ok(); + } + + private static void waitForWorld() { + long deadline = System.nanoTime() + TimeUnit.MINUTES.toNanos(2); + while (System.nanoTime() < deadline) { + try { + Boolean ready = runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + return mc.world != null && mc.player != null && mc.player.connection != null; + }); + if (Boolean.TRUE.equals(ready)) { + return; + } + Thread.sleep(100L); + } catch (RuntimeException exception) { + throw exception; + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for the client world to load", interruptedException); + } + } + throw new IllegalStateException("Timed out waiting for the client world to load"); + } + + private static T runOnClientThread(Callable callable) { + Minecraft mc = Minecraft.getMinecraft(); + FutureTask task = new FutureTask<>(callable); + mc.addScheduledTask(task); + try { + return task.get(2, TimeUnit.MINUTES); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + } + + private static EntityPlayerSP requirePlayer(Minecraft mc) { + if (mc.player == null) { + throw new IllegalStateException("Client player is not available"); + } + return mc.player; + } + + private static JsonObject ok() { + JsonObject response = new JsonObject(); + response.addProperty("ok", true); + return response; + } + + private static JsonObject error(String message) { + JsonObject response = new JsonObject(); + response.addProperty("ok", false); + response.addProperty("error", message == null ? "unknown" : message); + return response; + } + + private static int requireInt(JsonObject object, String key) { + if (!object.has(key)) { + throw new IllegalArgumentException("Missing required key: " + key); + } + return object.get(key).getAsInt(); + } + + private static int boundedInt(JsonObject object, String key, int min, int max) { + int value = requireInt(object, key); + return Math.max(min, Math.min(max, value)); + } + + private static String requireString(JsonObject object, String key) { + if (!object.has(key)) { + throw new IllegalArgumentException("Missing required key: " + key); + } + return object.get(key).getAsString(); + } + + @SuppressWarnings("unchecked") + private static List buttonList(GuiScreen screen) { + try { + java.lang.reflect.Field field = GuiScreen.class.getDeclaredField("buttonList"); + field.setAccessible(true); + return (List) field.get(screen); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("Failed to access GUI button list", exception); + } + } + + private static void invokeMouseClicked(GuiScreen screen, int x, int y, int button) { + try { + java.lang.reflect.Method method = findMethod(screen.getClass(), "mouseClicked", int.class, int.class, int.class); + method.setAccessible(true); + method.invoke(screen, x, y, button); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("Failed to click GUI point", exception); + } + } + + private static void invokeKeyTyped(GuiScreen screen, char typedChar, int keyCode) { + try { + java.lang.reflect.Method method = findMethod(screen.getClass(), "keyTyped", char.class, int.class); + method.setAccessible(true); + method.invoke(screen, typedChar, keyCode); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("Failed to type into GUI", exception); + } + } + + private static void invokeMouseClickMove(GuiScreen screen, int mouseX, int mouseY, int clickedMouseButton, long timeSinceLastClick) { + try { + java.lang.reflect.Method method = findMethod(screen.getClass(), "mouseClickMove", int.class, int.class, int.class, long.class); + method.setAccessible(true); + method.invoke(screen, mouseX, mouseY, clickedMouseButton, timeSinceLastClick); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("Failed to drag GUI point", exception); + } + } + + private static void invokeMouseReleased(GuiScreen screen, int mouseX, int mouseY, int state) { + try { + java.lang.reflect.Method method = findMethod(screen.getClass(), "mouseReleased", int.class, int.class, int.class); + method.setAccessible(true); + method.invoke(screen, mouseX, mouseY, state); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("Failed to release GUI point", exception); + } + } + + private static java.lang.reflect.Method findMethod(Class type, String methodName, Class... parameterTypes) throws NoSuchMethodException { + Class current = type; + while (current != null) { + try { + return current.getDeclaredMethod(methodName, parameterTypes); + } catch (NoSuchMethodException ignored) { + current = current.getSuperclass(); + } + } + throw new NoSuchMethodException(methodName); + } + + private static GuiTextField textField(GuiScreen screen, String fieldName) { + try { + java.lang.reflect.Field field = screen.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + Object value = field.get(screen); + if (!(value instanceof GuiTextField)) { + throw new IllegalStateException("Field '" + fieldName + "' is not a GuiTextField"); + } + return (GuiTextField) value; + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("Failed to access GUI text field '" + fieldName + "'", exception); + } + } + + private static int intField(Object target, String fieldName) { + try { + java.lang.reflect.Field field = findField(target.getClass(), fieldName); + field.setAccessible(true); + return field.getInt(target); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("Failed to access integer field '" + fieldName + "' on " + target.getClass().getName(), exception); + } + } + + private static java.lang.reflect.Field findField(Class type, String fieldName) throws NoSuchFieldException { + Class current = type; + while (current != null) { + try { + return current.getDeclaredField(fieldName); + } catch (NoSuchFieldException ignored) { + current = current.getSuperclass(); + } + } + throw new NoSuchFieldException(fieldName); + } + + private static final class TickCounter { + @SubscribeEvent + public void onClientTick(TickEvent.ClientTickEvent event) { + if (event.phase == TickEvent.Phase.END) { + CLIENT_TICKS.incrementAndGet(); + } + } + } + + private static final class TeeOutputStream extends OutputStream { + private final OutputStream first; + private final OutputStream second; + + private TeeOutputStream(OutputStream first, OutputStream second) { + this.first = first; + this.second = second; + } + + @Override + public void write(int b) throws IOException { + first.write(b); + second.write(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + first.write(b, off, len); + second.write(b, off, len); + } + + @Override + public void flush() throws IOException { + first.flush(); + second.flush(); + } + + @Override + public void close() throws IOException { + try { + first.close(); + } finally { + second.close(); + } + } + } +} + diff --git a/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java b/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java new file mode 100644 index 000000000..89be6f031 --- /dev/null +++ b/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java @@ -0,0 +1,202 @@ +package com.github.stannismod.forge.testing.server; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +public final class RealDedicatedServerHarness implements AutoCloseable { + + private final Path root; + private final int port; + private final TestClient client; + private final Thread readerThread; + + private RealDedicatedServerHarness(Path root, int port, TestClient client, Thread readerThread) { + this.root = root; + this.port = port; + this.client = client; + this.readerThread = readerThread; + } + + public static RealDedicatedServerHarness start() throws IOException, InterruptedException { + Path root = Files.createTempDirectory("forge-dedicated-server-"); + int port = reservePort(); + bootstrapServerFiles(root, port); + Process process = launchServer(root, port); + + List transcript = new ArrayList<>(); + Thread readerThread = startReader(process, transcript); + TestClient client = new TestClient(process, TestClient.newWriter(process), transcript); + RealDedicatedServerHarness harness = new RealDedicatedServerHarness(root, port, client, readerThread); + client.awaitOutputContaining("For help, type \"help\" or \"?\"", Duration.ofMinutes(3)); + return harness; + } + + public Path root() { + return root; + } + + public int port() { + return port; + } + + public TestClient client() { + return client; + } + + @Override + public void close() throws IOException { + try { + client.close(); + } finally { + try { + readerThread.join(TimeUnit.SECONDS.toMillis(5)); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + } + deleteRecursively(root); + } + } + + private static Process launchServer(Path root, int port) throws IOException { + String javaExe = System.getProperty("java.home"); + Path javaBinary = javaExe == null + ? Paths.get("java.exe") + : Paths.get(javaExe, "bin", "java.exe"); + Path assetsDir = gradleUserHome().resolve("caches").resolve("retro_futura_gradle").resolve("assets"); + + List command = new ArrayList<>(); + command.add(javaBinary.toString()); + command.add("-Djava.awt.headless=true"); + command.add("-Dforge.test.server=true"); + command.add("-cp"); + command.add(Objects.requireNonNull(System.getProperty("java.class.path"), "java.class.path")); + command.add("GradleStartServer"); + command.add("--nogui"); + command.add("--gameDir"); + command.add(root.toAbsolutePath().toString()); + command.add("--assetsDir"); + command.add(assetsDir.toAbsolutePath().toString()); + command.add("--version"); + command.add("FML_DEV"); + command.add("--assetIndex"); + command.add("1.12.2"); + command.add("--username"); + command.add("Developer"); + command.add("--accessToken"); + command.add("FML"); + command.add("--userProperties"); + command.add("{}"); + command.add("--uuid"); + command.add(UUID.randomUUID().toString().replace("-", "")); + command.add("--port"); + command.add(String.valueOf(port)); + command.add("--universe"); + command.add(root.toAbsolutePath().toString()); + command.add("--world"); + command.add("world"); + + ProcessBuilder builder = new ProcessBuilder(command); + builder.directory(root.toFile()); + builder.redirectErrorStream(true); + return builder.start(); + } + + private static Path gradleUserHome() { + String env = System.getenv("GRADLE_USER_HOME"); + if (env != null && !env.trim().isEmpty()) { + return Paths.get(env.trim()); + } + return Paths.get(System.getProperty("user.home"), ".gradle"); + } + + private static int reservePort() throws IOException { + try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) { + socket.setReuseAddress(true); + return socket.getLocalPort(); + } + } + + private static Thread startReader(Process process, List transcript) { + Thread reader = new Thread(() -> { + try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = bufferedReader.readLine()) != null) { + synchronized (transcript) { + transcript.add(line); + transcript.notifyAll(); + } + } + } catch (IOException ignored) { + // The process is terminating or the stream has already been closed. + } + }, "forge-dedicated-server-log-reader"); + reader.setDaemon(true); + reader.start(); + return reader; + } + + private static void bootstrapServerFiles(Path root, int port) throws IOException { + Files.write(root.resolve("eula.txt"), java.util.Collections.singletonList("eula=true"), StandardCharsets.UTF_8); + Files.write(root.resolve("server.properties"), buildServerProperties(port).getBytes(StandardCharsets.UTF_8)); + } + + private static String buildServerProperties(int port) { + String newline = System.lineSeparator(); + StringBuilder builder = new StringBuilder(); + builder.append("enable-command-block=true").append(newline); + builder.append("allow-nether=true").append(newline); + builder.append("difficulty=1").append(newline); + builder.append("gamemode=1").append(newline); + builder.append("generate-structures=false").append(newline); + builder.append("hardcore=false").append(newline); + builder.append("level-name=world").append(newline); + builder.append("level-seed=").append(newline); + builder.append("level-type=DEFAULT").append(newline); + builder.append("max-tick-time=-1").append(newline); + builder.append("motd=Forge Test").append(newline); + builder.append("network-compression-threshold=256").append(newline); + builder.append("online-mode=false").append(newline); + builder.append("op-permission-level=4").append(newline); + builder.append("pvp=false").append(newline); + builder.append("spawn-animals=false").append(newline); + builder.append("spawn-monsters=false").append(newline); + builder.append("spawn-npcs=false").append(newline); + builder.append("spawn-protection=0").append(newline); + builder.append("server-ip=").append(newline); + builder.append("server-port=").append(port).append(newline); + builder.append("snooper-enabled=false").append(newline); + builder.append("use-native-transport=false").append(newline); + builder.append("view-distance=4").append(newline); + return builder.toString(); + } + + private static void deleteRecursively(Path root) throws IOException { + if (root == null || !Files.exists(root)) { + return; + } + Files.walkFileTree(root, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.deleteIfExists(dir); + return FileVisitResult.CONTINUE; + } + }); + } +} + diff --git a/src/main/java/com/github/stannismod/forge/testing/server/TestClient.java b/src/main/java/com/github/stannismod/forge/testing/server/TestClient.java new file mode 100644 index 000000000..8f13b0651 --- /dev/null +++ b/src/main/java/com/github/stannismod/forge/testing/server/TestClient.java @@ -0,0 +1,130 @@ +package com.github.stannismod.forge.testing.server; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +public final class TestClient implements Closeable { + + private final Process process; + private final Writer stdin; + private final List transcript; + + TestClient(Process process, Writer stdin, List transcript) { + this.process = process; + this.stdin = stdin; + this.transcript = transcript; + } + + public List execute(String command) throws IOException, InterruptedException { + String marker = "FORGE_TEST_DONE " + UUID.randomUUID(); + int startIndex = snapshotSize(); + sendRaw(command); + sendRaw("say " + marker); + return awaitMarker(startIndex, marker, Duration.ofSeconds(30)); + } + + public List awaitOutputContaining(String token, Duration timeout) throws InterruptedException { + int startIndex = snapshotSize(); + return awaitMarker(startIndex, token, timeout); + } + + public void sendRaw(String command) throws IOException { + Objects.requireNonNull(command, "command"); + synchronized (stdin) { + stdin.write(command); + stdin.write('\n'); + stdin.flush(); + } + } + + public boolean isAlive() { + return process.isAlive(); + } + + @Override + public void close() throws IOException { + if (!process.isAlive()) { + return; + } + try { + sendRaw("stop"); + } catch (IOException ignored) { + // If stdin is already closed, fall through and destroy the process. + } + try { + process.waitFor(30, TimeUnit.SECONDS); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + } finally { + synchronized (stdin) { + stdin.close(); + } + if (process.isAlive()) { + process.destroyForcibly(); + } + } + } + + List transcriptSnapshot() { + synchronized (transcript) { + return new ArrayList<>(transcript); + } + } + + private int snapshotSize() { + synchronized (transcript) { + return transcript.size(); + } + } + + private List awaitMarker(int startIndex, String token, Duration timeout) throws InterruptedException { + long deadlineNanos = System.nanoTime() + timeout.toNanos(); + int index = startIndex; + List captured = new ArrayList<>(); + + while (System.nanoTime() < deadlineNanos) { + String line = null; + synchronized (transcript) { + if (index < transcript.size()) { + line = transcript.get(index++); + captured.add(line); + if (line.contains(token)) { + captured.remove(captured.size() - 1); + return captured; + } + } else { + long remainingNanos = deadlineNanos - System.nanoTime(); + long waitMillis = Math.max(1L, TimeUnit.NANOSECONDS.toMillis(remainingNanos)); + transcript.wait(Math.min(waitMillis, 250L)); + continue; + } + } + } + + throw new AssertionError("Timed out waiting for marker '" + token + "'. Recent output: " + tail()); + } + + private String tail() { + List snapshot = transcriptSnapshot(); + int from = Math.max(0, snapshot.size() - 25); + StringBuilder builder = new StringBuilder(); + for (int i = from; i < snapshot.size(); i++) { + if (i > from) { + builder.append(System.lineSeparator()); + } + builder.append(snapshot.get(i)); + } + return builder.toString(); + } + + static BufferedWriter newWriter(Process process) { + return new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)); + } +} + diff --git a/src/test/java/com/github/stannismod/forge/testing/TestFrameworkTest.java b/src/test/java/com/github/stannismod/forge/testing/TestFrameworkTest.java new file mode 100644 index 000000000..7abd87a5d --- /dev/null +++ b/src/test/java/com/github/stannismod/forge/testing/TestFrameworkTest.java @@ -0,0 +1,157 @@ +package com.github.stannismod.forge.testing; + + +import org.junit.Assert; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +public class TestFrameworkTest { + + @Test + public void orchestratorRunsTestsAndWritesReports() throws Exception { + Path reportRoot = Files.createTempDirectory("forge-framework-report-"); + TestRegistry registry = new TestRegistry() + .register(new PassingTest()) + .register(new MultiTickTest()) + .register(new FailingTest()); + + List outcomes = new TestBootstrap(registry, new TestReportWriter()).run(reportRoot); + + Assert.assertEquals(3, outcomes.size()); + Assert.assertTrue(outcomes.get(0).passed()); + Assert.assertEquals(TestStatus.PASSED, outcomes.get(0).status()); + Assert.assertEquals(2, outcomes.get(1).ticks()); + Assert.assertEquals(TestStatus.FAILED, outcomes.get(2).status()); + Assert.assertNotNull(outcomes.get(2).failure()); + + Path summaryTxt = reportRoot.resolve("summary.txt"); + Path summaryJson = reportRoot.resolve("summary.json"); + Assert.assertTrue(Files.exists(summaryTxt)); + Assert.assertTrue(Files.exists(summaryJson)); + + String text = new String(Files.readAllBytes(summaryTxt), StandardCharsets.UTF_8); + String json = new String(Files.readAllBytes(summaryJson), StandardCharsets.UTF_8); + Assert.assertTrue(text.contains("total=3")); + Assert.assertTrue(text.contains("PASSED passing_case")); + Assert.assertTrue(json.contains("\"total\":3")); + Assert.assertTrue(json.contains("\"id\":\"failing_case\"")); + } + + private static final class PassingTest implements HeadlessGameTest { + @Override + public String id() { + return "passing_case"; + } + + @Override + public String category() { + return "smoke"; + } + + @Override + public boolean required() { + return true; + } + + @Override + public int timeoutTicks() { + return 4; + } + + @Override + public void setUp(TestContext context) throws Exception { + context.ensureWorkDir(); + context.note("setup"); + } + + @Override + public TestStatus tick(TestContext context) { + context.note("tick"); + return TestStatus.PASSED; + } + + @Override + public void tearDown(TestContext context) { + context.note("teardown"); + } + } + + private static final class MultiTickTest implements HeadlessGameTest { + private int ticks; + + @Override + public String id() { + return "multi_tick_case"; + } + + @Override + public String category() { + return "smoke"; + } + + @Override + public boolean required() { + return true; + } + + @Override + public int timeoutTicks() { + return 4; + } + + @Override + public void setUp(TestContext context) { + ticks = 0; + } + + @Override + public TestStatus tick(TestContext context) { + ticks++; + return ticks >= 2 ? TestStatus.PASSED : TestStatus.RUNNING; + } + + @Override + public void tearDown(TestContext context) { + } + } + + private static final class FailingTest implements HeadlessGameTest { + @Override + public String id() { + return "failing_case"; + } + + @Override + public String category() { + return "smoke"; + } + + @Override + public boolean required() { + return false; + } + + @Override + public int timeoutTicks() { + return 1; + } + + @Override + public void setUp(TestContext context) { + } + + @Override + public TestStatus tick(TestContext context) { + throw new IllegalStateException("boom"); + } + + @Override + public void tearDown(TestContext context) { + } + } +} + From b4cf0587074129189a55ec7c8d876cce974cb466 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Mon, 11 May 2026 19:32:16 +0300 Subject: [PATCH 02/47] Made test framework universal --- build.gradle | 2 +- .../testing/client/RealClientHarness.java | 71 ++++++++++------ .../server/RealDedicatedServerHarness.java | 85 +++++++++++++------ .../forge/testing/server/TestClient.java | 7 ++ 4 files changed, 115 insertions(+), 50 deletions(-) diff --git a/build.gradle b/build.gradle index 6e84e0053..d3ae9849d 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.github.stannismod.forge' -version = '0.1.0' +version = '0.2.0' base { archivesName = 'forge-test-framework' diff --git a/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java b/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java index fd35e56c6..6fac50072 100644 --- a/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java +++ b/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java @@ -85,10 +85,27 @@ public void close() throws IOException { } } + /** + * System property naming the client launcher main class. Default {@code GradleStart} + * (RFG / FG4 layout). Set to {@code mcp.client.Start} for ForgeGradle 6 projects. + * + *

See also {@code forge.test.assets.dir} and {@code forge.test.launcher.legacyArgs} + * documented on {@code RealDedicatedServerHarness}.

+ */ + public static final String PROP_LAUNCHER_CLASS = "forge.test.launcher.class.client"; + + public static final String PROP_ASSETS_DIR = "forge.test.assets.dir"; + public static final String PROP_LEGACY_ARGS = "forge.test.launcher.legacyArgs"; + private static Process launchClient(Path root, int serverPort, int controlPort, Path clientLogFile) throws IOException { Path javaBinary = resolveJavaBinary(); - Path assetsDir = gradleUserHome().resolve("caches").resolve("retro_futura_gradle").resolve("assets"); + String assetsDirProp = System.getProperty(PROP_ASSETS_DIR); + Path assetsDir = assetsDirProp != null + ? Paths.get(assetsDirProp) + : gradleUserHome().resolve("caches").resolve("retro_futura_gradle").resolve("assets"); Path nativesDir = resolveNativesDir(); + String launcherClass = System.getProperty(PROP_LAUNCHER_CLASS, "GradleStart"); + boolean legacyArgs = Boolean.parseBoolean(System.getProperty(PROP_LEGACY_ARGS, "true")); String currentClassPath = Objects.requireNonNull(System.getProperty("java.class.path"), "java.class.path"); Path libDir = Files.createTempDirectory(root, "client-libs-"); @@ -103,35 +120,41 @@ private static Process launchClient(Path root, int serverPort, int controlPort, javaArgs.add("-Dforge.test.client.logFile=" + clientLogFile.toAbsolutePath()); javaArgs.add("-cp"); javaArgs.add(launcherClassPath); - javaArgs.add("GradleStart"); + javaArgs.add(launcherClass); javaArgs.add("--server"); javaArgs.add("127.0.0.1"); javaArgs.add("--port"); javaArgs.add(String.valueOf(serverPort)); javaArgs.add("--gameDir"); javaArgs.add(root.toAbsolutePath().toString()); - javaArgs.add("--assetsDir"); - javaArgs.add(assetsDir.toAbsolutePath().toString()); - javaArgs.add("--resourcePackDir"); - javaArgs.add(root.resolve("resourcepacks").toAbsolutePath().toString()); - javaArgs.add("--version"); - javaArgs.add("FML_DEV"); - javaArgs.add("--assetIndex"); - javaArgs.add("1.12.2"); - javaArgs.add("--username"); - javaArgs.add(CLIENT_USERNAME); - javaArgs.add("--accessToken"); - javaArgs.add("FML"); - javaArgs.add("--userProperties"); - javaArgs.add("{}"); - javaArgs.add("--profileProperties"); - javaArgs.add("{}"); - javaArgs.add("--uuid"); - javaArgs.add(UUID.nameUUIDFromBytes(("OfflinePlayer:" + CLIENT_USERNAME).getBytes(StandardCharsets.UTF_8)).toString().replace("-", "")); - javaArgs.add("--width"); - javaArgs.add("640"); - javaArgs.add("--height"); - javaArgs.add("480"); + + if (legacyArgs) { + javaArgs.add("--assetsDir"); + javaArgs.add(assetsDir.toAbsolutePath().toString()); + javaArgs.add("--resourcePackDir"); + javaArgs.add(root.resolve("resourcepacks").toAbsolutePath().toString()); + javaArgs.add("--version"); + javaArgs.add("FML_DEV"); + javaArgs.add("--assetIndex"); + javaArgs.add("1.12.2"); + javaArgs.add("--username"); + javaArgs.add(CLIENT_USERNAME); + javaArgs.add("--accessToken"); + javaArgs.add("FML"); + javaArgs.add("--userProperties"); + javaArgs.add("{}"); + javaArgs.add("--profileProperties"); + javaArgs.add("{}"); + javaArgs.add("--uuid"); + javaArgs.add(UUID.nameUUIDFromBytes(("OfflinePlayer:" + CLIENT_USERNAME).getBytes(StandardCharsets.UTF_8)).toString().replace("-", "")); + javaArgs.add("--width"); + javaArgs.add("640"); + javaArgs.add("--height"); + javaArgs.add("480"); + } + // FG6's mcp.client.Start prepends its own --version/--accessToken/--assetsDir/ + // --assetIndex/--userProperties defaults; only --server/--port/--gameDir + // (above) need to be supplied externally. List command = new ArrayList<>(); command.add(javaBinary.toString()); diff --git a/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java b/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java index 89be6f031..c5c2ea64e 100644 --- a/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java +++ b/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java @@ -67,12 +67,35 @@ public void close() throws IOException { } } + /** + * System property naming the launcher main class. Default {@code GradleStartServer} + * (RFG / FG4 layout). Set to e.g. {@code net.minecraftforge.legacydev.MainServer} + * for ForgeGradle 6 projects. + */ + public static final String PROP_LAUNCHER_CLASS = "forge.test.launcher.class.server"; + + /** + * System property naming the assets dir passed via {@code --assetsDir}. Default + * resolves to {@code /caches/retro_futura_gradle/assets} for + * RFG. Set to {@code /caches/forge_gradle/assets} for FG6. + * Ignored when {@link #PROP_LEGACY_ARGS} is {@code false}. + */ + public static final String PROP_ASSETS_DIR = "forge.test.assets.dir"; + + /** + * System property toggling the RFG-style {@code --version / --assetsDir / --username / ...} + * arg list. Default {@code true} (RFG behavior). Set to {@code false} for + * launchers that take no args (e.g. FG6's {@code MainServer} which reads cwd). + */ + public static final String PROP_LEGACY_ARGS = "forge.test.launcher.legacyArgs"; + private static Process launchServer(Path root, int port) throws IOException { String javaExe = System.getProperty("java.home"); Path javaBinary = javaExe == null ? Paths.get("java.exe") : Paths.get(javaExe, "bin", "java.exe"); - Path assetsDir = gradleUserHome().resolve("caches").resolve("retro_futura_gradle").resolve("assets"); + String launcherClass = System.getProperty(PROP_LAUNCHER_CLASS, "GradleStartServer"); + boolean legacyArgs = Boolean.parseBoolean(System.getProperty(PROP_LEGACY_ARGS, "true")); List command = new ArrayList<>(); command.add(javaBinary.toString()); @@ -80,30 +103,42 @@ private static Process launchServer(Path root, int port) throws IOException { command.add("-Dforge.test.server=true"); command.add("-cp"); command.add(Objects.requireNonNull(System.getProperty("java.class.path"), "java.class.path")); - command.add("GradleStartServer"); - command.add("--nogui"); - command.add("--gameDir"); - command.add(root.toAbsolutePath().toString()); - command.add("--assetsDir"); - command.add(assetsDir.toAbsolutePath().toString()); - command.add("--version"); - command.add("FML_DEV"); - command.add("--assetIndex"); - command.add("1.12.2"); - command.add("--username"); - command.add("Developer"); - command.add("--accessToken"); - command.add("FML"); - command.add("--userProperties"); - command.add("{}"); - command.add("--uuid"); - command.add(UUID.randomUUID().toString().replace("-", "")); - command.add("--port"); - command.add(String.valueOf(port)); - command.add("--universe"); - command.add(root.toAbsolutePath().toString()); - command.add("--world"); - command.add("world"); + command.add(launcherClass); + + if (legacyArgs) { + String assetsDirProp = System.getProperty(PROP_ASSETS_DIR); + Path assetsDir = assetsDirProp != null + ? Paths.get(assetsDirProp) + : gradleUserHome().resolve("caches").resolve("retro_futura_gradle").resolve("assets"); + command.add("--nogui"); + command.add("--gameDir"); + command.add(root.toAbsolutePath().toString()); + command.add("--assetsDir"); + command.add(assetsDir.toAbsolutePath().toString()); + command.add("--version"); + command.add("FML_DEV"); + command.add("--assetIndex"); + command.add("1.12.2"); + command.add("--username"); + command.add("Developer"); + command.add("--accessToken"); + command.add("FML"); + command.add("--userProperties"); + command.add("{}"); + command.add("--uuid"); + command.add(UUID.randomUUID().toString().replace("-", "")); + command.add("--port"); + command.add(String.valueOf(port)); + command.add("--universe"); + command.add(root.toAbsolutePath().toString()); + command.add("--world"); + command.add("world"); + } else { + // FG6's net.minecraftforge.legacydev.MainServer takes no args — it reads + // working directory + server.properties. Port comes from server.properties + // (already written by bootstrapServerFiles) and gameDir is the cwd. + command.add("--nogui"); + } ProcessBuilder builder = new ProcessBuilder(command); builder.directory(root.toFile()); diff --git a/src/main/java/com/github/stannismod/forge/testing/server/TestClient.java b/src/main/java/com/github/stannismod/forge/testing/server/TestClient.java index 8f13b0651..49f3a5162 100644 --- a/src/main/java/com/github/stannismod/forge/testing/server/TestClient.java +++ b/src/main/java/com/github/stannismod/forge/testing/server/TestClient.java @@ -99,6 +99,13 @@ private List awaitMarker(int startIndex, String token, Duration timeout) return captured; } } else { + // Short-circuit: if the underlying process died before printing the + // marker, no amount of waiting will help. Return the captured tail + // immediately so callers see the actual crash instead of a timeout. + if (!process.isAlive()) { + throw new AssertionError("Server process exited (code=" + process.exitValue() + + ") before marker '" + token + "' appeared. Recent output: " + tail()); + } long remainingNanos = deadlineNanos - System.nanoTime(); long waitMillis = Math.max(1L, TimeUnit.NANOSECONDS.toMillis(remainingNanos)); transcript.wait(Math.min(waitMillis, 250L)); From 8c42e54dfcd8da80873e9ded9c6776818f75e019 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Tue, 12 May 2026 09:27:09 +0300 Subject: [PATCH 03/47] - --- build.gradle | 2 +- .../server/RealDedicatedServerHarness.java | 56 +++++++++++++++++-- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/build.gradle b/build.gradle index d3ae9849d..701faba05 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.github.stannismod.forge' -version = '0.2.0' +version = '0.2.1' base { archivesName = 'forge-test-framework' diff --git a/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java b/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java index c5c2ea64e..9acea13f7 100644 --- a/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java +++ b/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java @@ -19,24 +19,70 @@ public final class RealDedicatedServerHarness implements AutoCloseable { private final int port; private final TestClient client; private final Thread readerThread; + private final boolean cleanupOnClose; - private RealDedicatedServerHarness(Path root, int port, TestClient client, Thread readerThread) { + private RealDedicatedServerHarness(Path root, int port, TestClient client, Thread readerThread, + boolean cleanupOnClose) { this.root = root; this.port = port; this.client = client; this.readerThread = readerThread; + this.cleanupOnClose = cleanupOnClose; } + /** + * Starts a fresh server in a temporary work directory. The directory is + * deleted recursively when {@link #close()} is called — use this for + * scenarios that don't need to inspect or reuse the world after close. + */ public static RealDedicatedServerHarness start() throws IOException, InterruptedException { Path root = Files.createTempDirectory("forge-dedicated-server-"); + return startInternal(root, /*bootstrap=*/true, /*cleanupOnClose=*/true); + } + + /** + * Starts a server using the supplied work directory. + * + *

Useful for persistence-restart scenarios: start a fresh server, mutate + * world state, close it, then start again with the same {@code root} to + * verify the state survived save/load.

+ * + * @param root directory to use as the server's gameDir / world root. + * If empty, framework files (eula.txt, server.properties) + * are bootstrapped automatically. If it contains a + * {@code server.properties} from a previous run, the + * file is rewritten with a fresh port; the rest of the + * directory (world, config, mods) is preserved. + * @param cleanupOnClose if {@code true}, recursively deletes {@code root} on + * {@link #close()}. Pass {@code false} when you intend + * to restart against the same dir. + */ + public static RealDedicatedServerHarness startWith(Path root, boolean cleanupOnClose) + throws IOException, InterruptedException { + Files.createDirectories(root); + boolean bootstrap = !Files.exists(root.resolve("eula.txt")); + return startInternal(root, bootstrap, cleanupOnClose); + } + + private static RealDedicatedServerHarness startInternal(Path root, boolean bootstrap, + boolean cleanupOnClose) + throws IOException, InterruptedException { int port = reservePort(); - bootstrapServerFiles(root, port); + if (bootstrap) { + bootstrapServerFiles(root, port); + } else { + // Reuse existing world/config; rewrite server.properties with a fresh + // port so we don't collide with any other running test JVM. + Files.write(root.resolve("server.properties"), + buildServerProperties(port).getBytes(StandardCharsets.UTF_8)); + } Process process = launchServer(root, port); List transcript = new ArrayList<>(); Thread readerThread = startReader(process, transcript); TestClient client = new TestClient(process, TestClient.newWriter(process), transcript); - RealDedicatedServerHarness harness = new RealDedicatedServerHarness(root, port, client, readerThread); + RealDedicatedServerHarness harness = new RealDedicatedServerHarness( + root, port, client, readerThread, cleanupOnClose); client.awaitOutputContaining("For help, type \"help\" or \"?\"", Duration.ofMinutes(3)); return harness; } @@ -63,7 +109,9 @@ public void close() throws IOException { } catch (InterruptedException interruptedException) { Thread.currentThread().interrupt(); } - deleteRecursively(root); + if (cleanupOnClose) { + deleteRecursively(root); + } } } From 9380bd9d6b4d7381949339ce4791231588834526 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Tue, 12 May 2026 12:31:04 +0300 Subject: [PATCH 04/47] Moved test framework to mavenLocal --- README.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++-- build.gradle | 25 ++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dc5d3e610..5c20acff4 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,17 @@ # Forge Test Framework -Reusable testing infrastructure for Forge 1.12.2 mods. The project is intended to be consumed as a Gradle composite build by a mod project. +Reusable testing infrastructure for Forge 1.12.2 mods. The framework can be +consumed in three ways (in order of preference for downstream mods): -See [TEST_FRAMEWORK.md](TEST_FRAMEWORK.md) for the framework summary and extension points. +1. **Maven artifact from `mavenLocal()`** — locally published; works for any + developer who has cloned this repo. See [Publishing](#publishing). +2. **Gradle composite build** (`includeBuild '../ForgeTestFramework'`) — handy + when you are iterating on the framework and a consumer mod in lockstep. +3. **Pre-built jar** — fallback for environments without the source repo. + Less hygienic; prefer one of the above. + +See [TEST_FRAMEWORK.md](TEST_FRAMEWORK.md) for the framework summary and +extension points. ## Local commands @@ -12,3 +21,50 @@ Use Java 8. ./gradlew test ./gradlew build ``` + +## Publishing + +The framework publishes three jars under +`com.github.stannismod.forge:forge-test-framework:`: + +| Classifier | Purpose | +|---|---| +| (none) | reobfuscated jar — SRG names, for production Forge runtimes | +| `dev` | deobfuscated jar — MCP names, for Forge dev workspaces (consumed by tests) | +| `sources` | source jar | + +### Publish to `mavenLocal()` (`~/.m2/repository`) + +```bash +./gradlew publishToMavenLocal +``` + +Then in the consumer mod's `build.gradle(.kts)`: + +```kotlin +repositories { + mavenLocal() +} + +dependencies { + // dev classifier is required for Forge dev workspaces (MCP-named MC classes). + testImplementation("com.github.stannismod.forge:forge-test-framework:0.2.1:dev") +} +``` + +### Verify a publication + +```bash +ls ~/.m2/repository/com/github/stannismod/forge/forge-test-framework/ +``` + +Expected layout for version `0.2.1`: + +``` +0.2.1/ +├── forge-test-framework-0.2.1.jar # reobf +├── forge-test-framework-0.2.1-dev.jar # dev (Forge dev workspace consumes this) +├── forge-test-framework-0.2.1-sources.jar # sources +├── forge-test-framework-0.2.1.module # Gradle module metadata +└── forge-test-framework-0.2.1.pom # Maven POM +``` diff --git a/build.gradle b/build.gradle index 701faba05..973f16af9 100644 --- a/build.gradle +++ b/build.gradle @@ -51,7 +51,32 @@ test { publishing { publications { mavenJava(MavenPublication) { + // Pin coordinates explicitly so the artifactId matches `archivesName` + // (kebab-case) instead of `rootProject.name` (CamelCase). Consumers + // depend on `com.github.stannismod.forge:forge-test-framework:`. + groupId = project.group + artifactId = base.archivesName.get() + version = project.version + from components.java + + pom { + name = 'Forge Test Framework' + description = 'Reusable headless/dedicated-server + real-client test framework for Forge 1.12.2 mods.' + url = 'https://github.com/StannisMod/ForgeTestFramework' + // TODO: add block once a LICENSE file is added to the repo. + developers { + developer { + id = 'stannismod' + name = 'StannisMod' + } + } + scm { + url = 'https://github.com/StannisMod/ForgeTestFramework' + connection = 'scm:git:https://github.com/StannisMod/ForgeTestFramework.git' + developerConnection = 'scm:git:ssh://git@github.com/StannisMod/ForgeTestFramework.git' + } + } } } } From 5532e59ad3a0639aff69a6321c326508577f31a7 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Tue, 12 May 2026 15:49:42 +0300 Subject: [PATCH 05/47] Moved test framework to JUnit --- README.md | 71 ++++++++-- TEST_FRAMEWORK.md | 3 +- build.gradle | 2 +- dependencies.gradle | 5 + .../testing/junit/AbstractClientE2ETest.java | 130 ++++++++++++++++++ .../junit/AbstractHeadlessServerTest.java | 85 ++++++++++++ 6 files changed, 286 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/github/stannismod/forge/testing/junit/AbstractClientE2ETest.java create mode 100644 src/main/java/com/github/stannismod/forge/testing/junit/AbstractHeadlessServerTest.java diff --git a/README.md b/README.md index 5c20acff4..560ed8cad 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,61 @@ consumed in three ways (in order of preference for downstream mods): See [TEST_FRAMEWORK.md](TEST_FRAMEWORK.md) for the framework summary and extension points. +## Primary usage — JUnit-native (since 0.2.2) + +For Forge dev workspaces with JUnit on the test classpath, extend one of the +two base classes that wrap the harness lifecycle in `@Before` / `@After`: + +```java +import com.github.stannismod.forge.testing.junit.AbstractHeadlessServerTest; +import org.junit.Test; +import static org.junit.Assert.assertTrue; + +public class ServerSmokeTest extends AbstractHeadlessServerTest { + @Test + public void worldGenerates() throws Exception { + // server is already up; `client()` is the TestClient + assertTrue(client().execute("list").size() > 0); + } +} +``` + +For E2E tests that need a real Minecraft client connected to the server: + +```java +import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; +import org.junit.Test; + +public class GuiSmokeTest extends AbstractClientE2ETest { + @Test + public void guiOpens() throws Exception { + bot().openInventory(); + // serverClient() drives the server, bot() drives the client + } +} +``` + +Each test method gets its own harness — perfect for parallel execution via +Gradle's `maxParallelForks`. Scenarios are independent (each harness picks a +free port via `ServerSocket(0)`, uses its own tempDir) so cross-fork +interference is impossible. + +Opt-in via system properties (defaults are `false` → tests SKIP via +`org.junit.Assume`): + +| Property | Default | Purpose | +|---|---|---| +| `forge.test.harness.enabled` | `false` | enable `AbstractHeadlessServerTest` | +| `forge.test.client.enabled` | `false` | enable `AbstractClientE2ETest` (needs OpenGL display) | + +## Legacy `HeadlessGameTest` runner + +The original `TestRegistry` / `TestOrchestrator` / `TestBootstrap` classes +remain for standalone (non-JUnit) usage — e.g. invoking the suite from +`main()` in a CI script. For Forge dev workspaces with JUnit, **prefer the +JUnit base classes above**: they get parallelism, per-test reporting, +`--tests` filtering and IDE integration for free. + ## Local commands Use Java 8. @@ -48,7 +103,7 @@ repositories { dependencies { // dev classifier is required for Forge dev workspaces (MCP-named MC classes). - testImplementation("com.github.stannismod.forge:forge-test-framework:0.2.1:dev") + testImplementation("com.github.stannismod.forge:forge-test-framework:0.2.2:dev") } ``` @@ -58,13 +113,13 @@ dependencies { ls ~/.m2/repository/com/github/stannismod/forge/forge-test-framework/ ``` -Expected layout for version `0.2.1`: +Expected layout for version `0.2.2`: ``` -0.2.1/ -├── forge-test-framework-0.2.1.jar # reobf -├── forge-test-framework-0.2.1-dev.jar # dev (Forge dev workspace consumes this) -├── forge-test-framework-0.2.1-sources.jar # sources -├── forge-test-framework-0.2.1.module # Gradle module metadata -└── forge-test-framework-0.2.1.pom # Maven POM +0.2.2/ +├── forge-test-framework-0.2.2.jar # reobf +├── forge-test-framework-0.2.2-dev.jar # dev (Forge dev workspace consumes this) +├── forge-test-framework-0.2.2-sources.jar # sources +├── forge-test-framework-0.2.2.module # Gradle module metadata +└── forge-test-framework-0.2.2.pom # Maven POM ``` diff --git a/TEST_FRAMEWORK.md b/TEST_FRAMEWORK.md index b491005c0..2c30bdbf5 100644 --- a/TEST_FRAMEWORK.md +++ b/TEST_FRAMEWORK.md @@ -4,10 +4,11 @@ This project contains reusable testing infrastructure for Forge 1.12.2 mod verif ## Layers -- `com.github.stannismod.forge.testing` contains the generic scenario runner. +- `com.github.stannismod.forge.testing.junit` — **primary API**: JUnit 4 base classes (`AbstractHeadlessServerTest`, `AbstractClientE2ETest`) that wrap the harness lifecycle in `@Before` / `@After`. Each test method gets a fresh harness; parallelism is delegated to Gradle's `maxParallelForks`. - `com.github.stannismod.forge.testing.server` starts and controls a real dedicated server process. - `com.github.stannismod.forge.testing.client` starts and controls a real client process through a socket bridge. - `com.github.stannismod.forge.testing.client.bridge` runs inside the client JVM and translates test commands into real client-thread actions. +- `com.github.stannismod.forge.testing` — **legacy standalone runner** (`HeadlessGameTest` / `TestRegistry` / `TestOrchestrator` / `TestBootstrap` / `TestReportWriter`). Kept for non-JUnit use cases (CI `main()` invocations, custom runners). New consumers should prefer the JUnit base classes. ## Generic Scenario Runner diff --git a/build.gradle b/build.gradle index 973f16af9..b9b868307 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.github.stannismod.forge' -version = '0.2.1' +version = '0.2.2' base { archivesName = 'forge-test-framework' diff --git a/dependencies.gradle b/dependencies.gradle index 36f0ab163..89f56bf28 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -3,5 +3,10 @@ dependencies { api 'com.google.code.gson:gson:2.8.0' api 'net.java.dev.jna:jna:4.4.0' + // JUnit base classes (AbstractHeadlessServerTest / AbstractClientE2ETest) compile + // against JUnit 4 annotations but the framework jar itself doesn't bundle JUnit — + // consumers add it on their own test classpath. + compileOnly 'junit:junit:4.13.2' + testImplementation 'junit:junit:4.13.2' } diff --git a/src/main/java/com/github/stannismod/forge/testing/junit/AbstractClientE2ETest.java b/src/main/java/com/github/stannismod/forge/testing/junit/AbstractClientE2ETest.java new file mode 100644 index 000000000..06955167a --- /dev/null +++ b/src/main/java/com/github/stannismod/forge/testing/junit/AbstractClientE2ETest.java @@ -0,0 +1,130 @@ +package com.github.stannismod.forge.testing.junit; + +import com.github.stannismod.forge.testing.client.ClientBot; +import com.github.stannismod.forge.testing.client.RealClientHarness; +import com.github.stannismod.forge.testing.server.RealDedicatedServerHarness; +import com.github.stannismod.forge.testing.server.TestClient; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; + +/** + * JUnit 4 base class for end-to-end scenarios that need both a real Forge + * dedicated server AND a real Minecraft client connected to it. + * + *

Lifecycle:

+ *
    + *
  • {@code @Before} — checks {@link #PROP_HARNESS_ENABLED} AND + * {@link #PROP_CLIENT_ENABLED}. If either is missing, the test is marked + * SKIPPED via {@link Assume#assumeTrue}. Otherwise spawns the server + * harness, then a client JVM connected to it via + * {@link RealClientHarness#start(RealDedicatedServerHarness)}, then waits + * for the in-world handshake.
  • + *
  • {@code @Test} — your scenario body. Use {@link #server()} for + * server-side commands and {@link #bot()} for client interactions + * (right-click, GUI button clicks, hotbar selection, etc.).
  • + *
  • {@code @After} — closes client and server in order.
  • + *
+ * + *

Client E2E tests are significantly heavier than headless server + * tests: each takes ~60-90s (server JVM + client JVM + GL + bridge handshake) + * and consumes ~3-4 GB RAM. Plan {@code maxParallelForks} accordingly — typical + * dev workstations can run 2-3 concurrent client tests; CI may need a single + * worker.

+ * + *

Typical usage:

+ *
{@code
+ *   public class PlanetSelectorGuiE2ETest extends AbstractClientE2ETest {
+ *       @Test
+ *       public void clickingPlanetUpdatesServerSelection() throws Exception {
+ *           bot().openInventory();
+ *           // … GUI interactions …
+ *           List state = server().client().execute("artest selector info Player");
+ *           assertTrue(String.join("\n", state).contains("\"selected\":\"earth\""));
+ *       }
+ *   }
+ * }
+ */ +public abstract class AbstractClientE2ETest { + + /** Same as {@link AbstractHeadlessServerTest#PROP_HARNESS_ENABLED}. */ + public static final String PROP_HARNESS_ENABLED = AbstractHeadlessServerTest.PROP_HARNESS_ENABLED; + + /** + * System property opting IN to client harness invocation. Defaults to + * {@code false} because the client needs an OpenGL-capable display, which + * isn't present on headless CI runners. Set to {@code true} on desktop + * environments OR on CI with Xvfb / equivalent virtual display. + */ + public static final String PROP_CLIENT_ENABLED = "forge.test.client.enabled"; + + private RealDedicatedServerHarness serverHarness; + private RealClientHarness clientHarness; + + @Before + public final void startBoth() throws Exception { + Assume.assumeTrue( + "Server harness disabled — set -D" + PROP_HARNESS_ENABLED + "=true to enable", + Boolean.parseBoolean(System.getProperty(PROP_HARNESS_ENABLED, "false"))); + Assume.assumeTrue( + "Client harness disabled — set -D" + PROP_CLIENT_ENABLED + "=true to enable", + Boolean.parseBoolean(System.getProperty(PROP_CLIENT_ENABLED, "false"))); + + serverHarness = RealDedicatedServerHarness.start(); + try { + clientHarness = RealClientHarness.start(serverHarness); + } catch (Exception startupException) { + // Don't leak a running server JVM if client startup fails. + try { + serverHarness.close(); + } catch (Exception cleanupException) { + startupException.addSuppressed(cleanupException); + } + serverHarness = null; + throw startupException; + } + } + + @After + public final void stopBoth() throws Exception { + Exception deferred = null; + if (clientHarness != null) { + try { + clientHarness.close(); + } catch (Exception e) { + deferred = e; + } + clientHarness = null; + } + if (serverHarness != null) { + try { + serverHarness.close(); + } catch (Exception e) { + if (deferred == null) deferred = e; + else deferred.addSuppressed(e); + } + serverHarness = null; + } + if (deferred != null) throw deferred; + } + + /** The active server harness. */ + protected final RealDedicatedServerHarness server() { + return serverHarness; + } + + /** Shortcut for {@code server().client()} — issues server commands. */ + protected final TestClient serverClient() { + return serverHarness.client(); + } + + /** The active client harness. */ + protected final RealClientHarness clientHarness() { + return clientHarness; + } + + /** Shortcut for {@code clientHarness().bot()} — drives client UI. */ + protected final ClientBot bot() { + return clientHarness.bot(); + } +} diff --git a/src/main/java/com/github/stannismod/forge/testing/junit/AbstractHeadlessServerTest.java b/src/main/java/com/github/stannismod/forge/testing/junit/AbstractHeadlessServerTest.java new file mode 100644 index 000000000..2e626be55 --- /dev/null +++ b/src/main/java/com/github/stannismod/forge/testing/junit/AbstractHeadlessServerTest.java @@ -0,0 +1,85 @@ +package com.github.stannismod.forge.testing.junit; + +import com.github.stannismod.forge.testing.server.RealDedicatedServerHarness; +import com.github.stannismod.forge.testing.server.TestClient; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; + +/** + * JUnit 4 base class for scenarios that need a real Forge dedicated server + * running for the duration of one test method. + * + *

Lifecycle:

+ *
    + *
  • {@code @Before} — checks {@link #PROP_HARNESS_ENABLED}; if disabled, the + * test is marked SKIPPED via {@link Assume#assumeTrue}. Otherwise spawns a + * fresh dedicated server JVM via + * {@link RealDedicatedServerHarness#start()} and waits for the boot + * marker.
  • + *
  • {@code @Test} — your scenario body. Use {@link #harness()} or + * {@link #client()} to drive the server.
  • + *
  • {@code @After} — closes the harness if it was started.
  • + *
+ * + *

Each test method gets its own harness (one server JVM per test). This is + * intentional: scenarios are designed to be independent, and Gradle's + * {@code maxParallelForks} can multiply this across worker JVMs for parallel + * execution.

+ * + *

Typical usage:

+ *
{@code
+ *   public class WeatherBaselineTest extends AbstractHeadlessServerTest {
+ *       @Test
+ *       public void rainIsolatedPerDimension() throws Exception {
+ *           client().execute("artest weather set 0 rain 12000");
+ *           List after = client().execute("artest weather get 0");
+ *           assertTrue(String.join("\n", after).contains("\"isRaining\":true"));
+ *       }
+ *   }
+ * }
+ * + *

For scenarios that need to boot two harnesses against the same workDir + * (e.g. persistence-restart tests), do NOT extend this class — call + * {@link RealDedicatedServerHarness#startWith} directly from a plain {@code @Test} + * method, since this base class manages exactly one harness.

+ */ +public abstract class AbstractHeadlessServerTest { + + /** + * System property opting IN to real server harness invocation. When unset or + * {@code false}, every test extending this class is reported as SKIPPED via + * {@link Assume}. Set to {@code true} when running with a properly + * configured dev classpath (ForgeGradle runServer-style: launchwrapper, + * tweakClass, mcLocation system properties on the parent JVM, etc.). + */ + public static final String PROP_HARNESS_ENABLED = "forge.test.harness.enabled"; + + private RealDedicatedServerHarness harness; + + @Before + public final void startHarness() throws Exception { + Assume.assumeTrue( + "Server harness disabled — set -D" + PROP_HARNESS_ENABLED + "=true to enable", + Boolean.parseBoolean(System.getProperty(PROP_HARNESS_ENABLED, "false"))); + harness = RealDedicatedServerHarness.start(); + } + + @After + public final void stopHarness() throws Exception { + if (harness != null) { + harness.close(); + harness = null; + } + } + + /** The active harness. Available inside {@code @Test} methods. */ + protected final RealDedicatedServerHarness harness() { + return harness; + } + + /** Shortcut for {@code harness().client()}. */ + protected final TestClient client() { + return harness.client(); + } +} From c7102a66faa0bdaebfeee7e099604a3cec552b8e Mon Sep 17 00:00:00 2001 From: StannisMod Date: Tue, 12 May 2026 16:04:25 +0300 Subject: [PATCH 06/47] Moved test framework to JUnit --- README.md | 39 +++-- TEST_FRAMEWORK.md | 18 +- build.gradle | 2 +- .../forge/testing/HeadlessGameTest.java | 19 --- .../forge/testing/TestAssertions.java | 35 ---- .../forge/testing/TestBootstrap.java | 23 --- .../stannismod/forge/testing/TestContext.java | 58 ------- .../forge/testing/TestOrchestrator.java | 79 --------- .../stannismod/forge/testing/TestOutcome.java | 72 -------- .../forge/testing/TestRegistry.java | 20 --- .../forge/testing/TestReportWriter.java | 116 ------------- .../stannismod/forge/testing/TestStatus.java | 9 - .../forge/testing/TestFrameworkTest.java | 157 ------------------ 13 files changed, 33 insertions(+), 614 deletions(-) delete mode 100644 src/main/java/com/github/stannismod/forge/testing/HeadlessGameTest.java delete mode 100644 src/main/java/com/github/stannismod/forge/testing/TestAssertions.java delete mode 100644 src/main/java/com/github/stannismod/forge/testing/TestBootstrap.java delete mode 100644 src/main/java/com/github/stannismod/forge/testing/TestContext.java delete mode 100644 src/main/java/com/github/stannismod/forge/testing/TestOrchestrator.java delete mode 100644 src/main/java/com/github/stannismod/forge/testing/TestOutcome.java delete mode 100644 src/main/java/com/github/stannismod/forge/testing/TestRegistry.java delete mode 100644 src/main/java/com/github/stannismod/forge/testing/TestReportWriter.java delete mode 100644 src/main/java/com/github/stannismod/forge/testing/TestStatus.java delete mode 100644 src/test/java/com/github/stannismod/forge/testing/TestFrameworkTest.java diff --git a/README.md b/README.md index 560ed8cad..cb5ea70c0 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ consumed in three ways (in order of preference for downstream mods): See [TEST_FRAMEWORK.md](TEST_FRAMEWORK.md) for the framework summary and extension points. -## Primary usage — JUnit-native (since 0.2.2) +## Usage — JUnit-native For Forge dev workspaces with JUnit on the test classpath, extend one of the two base classes that wrap the harness lifecycle in `@Before` / `@After`: @@ -60,13 +60,22 @@ Opt-in via system properties (defaults are `false` → tests SKIP via | `forge.test.harness.enabled` | `false` | enable `AbstractHeadlessServerTest` | | `forge.test.client.enabled` | `false` | enable `AbstractClientE2ETest` (needs OpenGL display) | -## Legacy `HeadlessGameTest` runner +## Scope -The original `TestRegistry` / `TestOrchestrator` / `TestBootstrap` classes -remain for standalone (non-JUnit) usage — e.g. invoking the suite from -`main()` in a CI script. For Forge dev workspaces with JUnit, **prefer the -JUnit base classes above**: they get parallelism, per-test reporting, -`--tests` filtering and IDE integration for free. +The framework is a **library**, not a runner. It provides: + +- `RealDedicatedServerHarness` / `TestClient` — spawn a Forge 1.12.2 dedicated + server and talk to it. +- `RealClientHarness` / `ClientBot` — spawn a real MC client connected to a + server, drive it via a socket bridge. +- `ForgeTestClientBootstrap` — the bridge endpoint that runs inside the + client JVM. +- `AbstractHeadlessServerTest` / `AbstractClientE2ETest` — JUnit 4 base + classes wrapping the harness lifecycle. + +There is **no** built-in test orchestrator, registry, or report writer. +Consumers wire scenarios as plain JUnit tests and rely on Gradle / JUnit's +own runner for execution, parallelism, filtering and reporting. ## Local commands @@ -103,7 +112,7 @@ repositories { dependencies { // dev classifier is required for Forge dev workspaces (MCP-named MC classes). - testImplementation("com.github.stannismod.forge:forge-test-framework:0.2.2:dev") + testImplementation("com.github.stannismod.forge:forge-test-framework:0.3.0:dev") } ``` @@ -113,13 +122,13 @@ dependencies { ls ~/.m2/repository/com/github/stannismod/forge/forge-test-framework/ ``` -Expected layout for version `0.2.2`: +Expected layout for version `0.3.0`: ``` -0.2.2/ -├── forge-test-framework-0.2.2.jar # reobf -├── forge-test-framework-0.2.2-dev.jar # dev (Forge dev workspace consumes this) -├── forge-test-framework-0.2.2-sources.jar # sources -├── forge-test-framework-0.2.2.module # Gradle module metadata -└── forge-test-framework-0.2.2.pom # Maven POM +0.3.0/ +├── forge-test-framework-0.3.0.jar # reobf +├── forge-test-framework-0.3.0-dev.jar # dev (Forge dev workspace consumes this) +├── forge-test-framework-0.3.0-sources.jar # sources +├── forge-test-framework-0.3.0.module # Gradle module metadata +└── forge-test-framework-0.3.0.pom # Maven POM ``` diff --git a/TEST_FRAMEWORK.md b/TEST_FRAMEWORK.md index 2c30bdbf5..5a968d07c 100644 --- a/TEST_FRAMEWORK.md +++ b/TEST_FRAMEWORK.md @@ -4,24 +4,22 @@ This project contains reusable testing infrastructure for Forge 1.12.2 mod verif ## Layers -- `com.github.stannismod.forge.testing.junit` — **primary API**: JUnit 4 base classes (`AbstractHeadlessServerTest`, `AbstractClientE2ETest`) that wrap the harness lifecycle in `@Before` / `@After`. Each test method gets a fresh harness; parallelism is delegated to Gradle's `maxParallelForks`. +- `com.github.stannismod.forge.testing.junit` — JUnit 4 base classes (`AbstractHeadlessServerTest`, `AbstractClientE2ETest`) that wrap the harness lifecycle in `@Before` / `@After`. Each test method gets a fresh harness; parallelism is delegated to Gradle's `maxParallelForks`. - `com.github.stannismod.forge.testing.server` starts and controls a real dedicated server process. - `com.github.stannismod.forge.testing.client` starts and controls a real client process through a socket bridge. - `com.github.stannismod.forge.testing.client.bridge` runs inside the client JVM and translates test commands into real client-thread actions. -- `com.github.stannismod.forge.testing` — **legacy standalone runner** (`HeadlessGameTest` / `TestRegistry` / `TestOrchestrator` / `TestBootstrap` / `TestReportWriter`). Kept for non-JUnit use cases (CI `main()` invocations, custom runners). New consumers should prefer the JUnit base classes. -## Generic Scenario Runner +The framework is a library — there is no built-in scenario runner, registry, +or report writer. Consumers use JUnit's runner (via Gradle's `Test` task) for +discovery, execution, parallelism, filtering and reporting. -A reusable headless scenario implements `HeadlessGameTest`: +## JUnit Base Classes -- `setUp(TestContext)` prepares per-case state. -- `tick(TestContext)` advances the scenario and returns `RUNNING`, `PASSED`, `FAILED`, or `SKIPPED`. -- `tearDown(TestContext)` releases per-case state. -- `timeoutTicks()` prevents stuck scenarios. +`AbstractHeadlessServerTest` provides a single `RealDedicatedServerHarness` for each test method via `@Before` / `@After`. `harness()` and `client()` are exposed to subclass methods. The class is opt-in gated by the `forge.test.harness.enabled` system property — when unset, every test SKIPS via `org.junit.Assume`. -`TestRegistry` stores scenarios. `TestOrchestrator` runs them one by one in isolated work directories. `TestBootstrap` combines orchestration and report writing. `TestReportWriter` writes `summary.txt` and `summary.json`. +`AbstractClientE2ETest` does the same for a paired server + `RealClientHarness`, exposing `server()`, `serverClient()`, `clientHarness()` and `bot()`. Gated by both `forge.test.harness.enabled` and `forge.test.client.enabled`. -`TestContext` provides a per-scenario work directory, notes, and an attribute map for sharing state between setup, ticks, and teardown. +Scenarios that need to manage two harness lifecycles against the same workDir (persistence-restart tests, fixture-write-before-start tests) skip the base classes and call `RealDedicatedServerHarness.startWith(workDir, cleanupOnClose)` directly from a plain `@Test` method with manual `@Before` / `@After`. ## Dedicated Server Harness diff --git a/build.gradle b/build.gradle index b9b868307..b12731815 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.github.stannismod.forge' -version = '0.2.2' +version = '0.3.0' base { archivesName = 'forge-test-framework' diff --git a/src/main/java/com/github/stannismod/forge/testing/HeadlessGameTest.java b/src/main/java/com/github/stannismod/forge/testing/HeadlessGameTest.java deleted file mode 100644 index bba053bd0..000000000 --- a/src/main/java/com/github/stannismod/forge/testing/HeadlessGameTest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.github.stannismod.forge.testing; - -public interface HeadlessGameTest { - - String id(); - - String category(); - - boolean required(); - - int timeoutTicks(); - - void setUp(TestContext context) throws Exception; - - TestStatus tick(TestContext context) throws Exception; - - void tearDown(TestContext context) throws Exception; -} - diff --git a/src/main/java/com/github/stannismod/forge/testing/TestAssertions.java b/src/main/java/com/github/stannismod/forge/testing/TestAssertions.java deleted file mode 100644 index b76b60b1a..000000000 --- a/src/main/java/com/github/stannismod/forge/testing/TestAssertions.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.github.stannismod.forge.testing; - -import io.netty.buffer.ByteBuf; -import io.netty.buffer.Unpooled; - -public final class TestAssertions { - - private TestAssertions() { - } - - public static ByteBuf newBuffer() { - return Unpooled.buffer(); - } - - public static T roundTrip(T value, BufferWriter writer, BufferReader reader) { - ByteBuf buffer = newBuffer(); - writer.write(value, buffer); - return reader.read(buffer); - } - - public static void assertFullyConsumed(ByteBuf buffer) { - if (buffer.isReadable()) { - throw new AssertionError("Expected buffer to be fully consumed but still had " + buffer.readableBytes() + " readable bytes"); - } - } - - public interface BufferWriter { - void write(T value, ByteBuf buffer); - } - - public interface BufferReader { - T read(ByteBuf buffer); - } -} - diff --git a/src/main/java/com/github/stannismod/forge/testing/TestBootstrap.java b/src/main/java/com/github/stannismod/forge/testing/TestBootstrap.java deleted file mode 100644 index 66cd2233c..000000000 --- a/src/main/java/com/github/stannismod/forge/testing/TestBootstrap.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.github.stannismod.forge.testing; - -import java.io.IOException; -import java.nio.file.Path; -import java.util.List; - -public final class TestBootstrap { - - private final TestRegistry registry; - private final TestReportWriter reportWriter; - - public TestBootstrap(TestRegistry registry, TestReportWriter reportWriter) { - this.registry = registry; - this.reportWriter = reportWriter; - } - - public List run(Path reportRoot) throws IOException { - List outcomes = new TestOrchestrator(registry).runAll(reportRoot); - reportWriter.write(reportRoot, outcomes); - return outcomes; - } -} - diff --git a/src/main/java/com/github/stannismod/forge/testing/TestContext.java b/src/main/java/com/github/stannismod/forge/testing/TestContext.java deleted file mode 100644 index ec43ce820..000000000 --- a/src/main/java/com/github/stannismod/forge/testing/TestContext.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.github.stannismod.forge.testing; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.*; - -public final class TestContext implements AutoCloseable { - - private final String testId; - private final Path workDir; - private final Map attributes = new LinkedHashMap<>(); - private final List notes = new ArrayList<>(); - - public TestContext(String testId, Path workDir) { - this.testId = testId; - this.workDir = workDir; - } - - public String testId() { - return testId; - } - - public Path workDir() { - return workDir; - } - - public void ensureWorkDir() throws IOException { - Files.createDirectories(workDir); - } - - public void note(String message) { - notes.add(message); - } - - public List notes() { - return Collections.unmodifiableList(notes); - } - - public void put(String key, Object value) { - attributes.put(key, value); - } - - @SuppressWarnings("unchecked") - public T get(String key) { - return (T) attributes.get(key); - } - - public Map attributes() { - return Collections.unmodifiableMap(attributes); - } - - @Override - public void close() { - // The harness keeps cleanup explicit to stay predictable in tests. - } -} - diff --git a/src/main/java/com/github/stannismod/forge/testing/TestOrchestrator.java b/src/main/java/com/github/stannismod/forge/testing/TestOrchestrator.java deleted file mode 100644 index 01ddc282b..000000000 --- a/src/main/java/com/github/stannismod/forge/testing/TestOrchestrator.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.github.stannismod.forge.testing; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; - -public final class TestOrchestrator { - - private final TestRegistry registry; - - public TestOrchestrator(TestRegistry registry) { - this.registry = registry; - } - - public List runAll(Path reportRoot) throws IOException { - Files.createDirectories(reportRoot); - List outcomes = new ArrayList<>(); - for (HeadlessGameTest test : registry.tests()) { - outcomes.add(runOne(test, reportRoot.resolve(safeId(test.id())))); - } - return outcomes; - } - - private TestOutcome runOne(HeadlessGameTest test, Path workDir) throws IOException { - Files.createDirectories(workDir); - TestContext context = new TestContext(test.id(), workDir); - long startedAt = System.nanoTime(); - TestStatus status = TestStatus.RUNNING; - Throwable failure = null; - int ticks = 0; - - try { - test.setUp(context); - while (status == TestStatus.RUNNING) { - if (ticks >= Math.max(1, test.timeoutTicks())) { - status = TestStatus.FAILED; - failure = new AssertionError("Timed out after " + ticks + " ticks"); - break; - } - - TestStatus nextStatus = test.tick(context); - ticks++; - status = nextStatus == null ? TestStatus.RUNNING : nextStatus; - } - } catch (Throwable t) { - status = TestStatus.FAILED; - failure = t; - } finally { - try { - test.tearDown(context); - } catch (Throwable t) { - if (failure == null) { - failure = t; - } - status = TestStatus.FAILED; - } - context.close(); - } - - long duration = System.nanoTime() - startedAt; - return new TestOutcome( - test.id(), - test.category(), - test.required(), - status, - ticks, - duration, - failure, - new ArrayList<>(context.notes()) - ); - } - - private static String safeId(String id) { - return id.replaceAll("[^a-zA-Z0-9._-]", "_"); - } -} - diff --git a/src/main/java/com/github/stannismod/forge/testing/TestOutcome.java b/src/main/java/com/github/stannismod/forge/testing/TestOutcome.java deleted file mode 100644 index d3049cd1d..000000000 --- a/src/main/java/com/github/stannismod/forge/testing/TestOutcome.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.github.stannismod.forge.testing; - -import java.util.Collections; -import java.util.List; - -public final class TestOutcome { - - private final String id; - private final String category; - private final boolean required; - private final TestStatus status; - private final int ticks; - private final long durationNanos; - private final Throwable failure; - private final List notes; - - public TestOutcome( - String id, - String category, - boolean required, - TestStatus status, - int ticks, - long durationNanos, - Throwable failure, - List notes) { - this.id = id; - this.category = category; - this.required = required; - this.status = status; - this.ticks = ticks; - this.durationNanos = durationNanos; - this.failure = failure; - this.notes = notes == null ? Collections.emptyList() : Collections.unmodifiableList(notes); - } - - public String id() { - return id; - } - - public String category() { - return category; - } - - public boolean required() { - return required; - } - - public TestStatus status() { - return status; - } - - public int ticks() { - return ticks; - } - - public long durationNanos() { - return durationNanos; - } - - public Throwable failure() { - return failure; - } - - public List notes() { - return notes; - } - - public boolean passed() { - return status == TestStatus.PASSED; - } -} - diff --git a/src/main/java/com/github/stannismod/forge/testing/TestRegistry.java b/src/main/java/com/github/stannismod/forge/testing/TestRegistry.java deleted file mode 100644 index 9abbaf294..000000000 --- a/src/main/java/com/github/stannismod/forge/testing/TestRegistry.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.github.stannismod.forge.testing; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -public final class TestRegistry { - - private final List tests = new ArrayList<>(); - - public TestRegistry register(HeadlessGameTest test) { - tests.add(test); - return this; - } - - public List tests() { - return Collections.unmodifiableList(tests); - } -} - diff --git a/src/main/java/com/github/stannismod/forge/testing/TestReportWriter.java b/src/main/java/com/github/stannismod/forge/testing/TestReportWriter.java deleted file mode 100644 index eb629ee5c..000000000 --- a/src/main/java/com/github/stannismod/forge/testing/TestReportWriter.java +++ /dev/null @@ -1,116 +0,0 @@ -package com.github.stannismod.forge.testing; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -public final class TestReportWriter { - - public void write(Path root, List outcomes) throws IOException { - Files.createDirectories(root); - Files.write(root.resolve("summary.txt"), buildText(outcomes).getBytes(StandardCharsets.UTF_8)); - Files.write(root.resolve("summary.json"), buildJson(outcomes).getBytes(StandardCharsets.UTF_8)); - } - - private static String buildText(List outcomes) { - StringBuilder builder = new StringBuilder(); - int passed = 0; - int failed = 0; - int skipped = 0; - for (TestOutcome outcome : outcomes) { - if (outcome.status() == TestStatus.PASSED) { - passed++; - } else if (outcome.status() == TestStatus.SKIPPED) { - skipped++; - } else if (outcome.status() == TestStatus.FAILED) { - failed++; - } - } - - builder.append("total=").append(outcomes.size()) - .append(", passed=").append(passed) - .append(", failed=").append(failed) - .append(", skipped=").append(skipped) - .append(System.lineSeparator()); - for (TestOutcome outcome : outcomes) { - builder.append(outcome.status()) - .append(' ') - .append(outcome.id()) - .append(" [") - .append(outcome.category()) - .append("] ticks=") - .append(outcome.ticks()) - .append(" durationNanos=") - .append(outcome.durationNanos()); - if (outcome.failure() != null) { - builder.append(" failure=").append(outcome.failure().getClass().getSimpleName()); - } - builder.append(System.lineSeparator()); - } - return builder.toString(); - } - - private static String buildJson(List outcomes) { - StringBuilder builder = new StringBuilder(); - int passed = 0; - int failed = 0; - int skipped = 0; - for (TestOutcome outcome : outcomes) { - if (outcome.status() == TestStatus.PASSED) { - passed++; - } else if (outcome.status() == TestStatus.SKIPPED) { - skipped++; - } else if (outcome.status() == TestStatus.FAILED) { - failed++; - } - } - - builder.append("{"); - builder.append("\"total\":").append(outcomes.size()).append(","); - builder.append("\"passed\":").append(passed).append(","); - builder.append("\"failed\":").append(failed).append(","); - builder.append("\"skipped\":").append(skipped).append(","); - builder.append("\"tests\":["); - for (int i = 0; i < outcomes.size(); i++) { - if (i > 0) { - builder.append(","); - } - TestOutcome outcome = outcomes.get(i); - builder.append("{") - .append("\"id\":\"").append(escape(outcome.id())).append("\",") - .append("\"category\":\"").append(escape(outcome.category())).append("\",") - .append("\"status\":\"").append(outcome.status()).append("\",") - .append("\"required\":").append(outcome.required()).append(",") - .append("\"ticks\":").append(outcome.ticks()).append(",") - .append("\"durationNanos\":").append(outcome.durationNanos()).append(",") - .append("\"notes\":["); - List notes = outcome.notes(); - for (int j = 0; j < notes.size(); j++) { - if (j > 0) { - builder.append(","); - } - builder.append("\"").append(escape(notes.get(j))).append("\""); - } - builder.append("]"); - if (outcome.failure() != null) { - builder.append(",\"failure\":\"") - .append(escape(outcome.failure().toString())) - .append("\""); - } - builder.append("}"); - } - builder.append("]}"); - return builder.toString(); - } - - private static String escape(String value) { - return value - .replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r"); - } -} - diff --git a/src/main/java/com/github/stannismod/forge/testing/TestStatus.java b/src/main/java/com/github/stannismod/forge/testing/TestStatus.java deleted file mode 100644 index 5aff7db6d..000000000 --- a/src/main/java/com/github/stannismod/forge/testing/TestStatus.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.github.stannismod.forge.testing; - -public enum TestStatus { - RUNNING, - PASSED, - FAILED, - SKIPPED -} - diff --git a/src/test/java/com/github/stannismod/forge/testing/TestFrameworkTest.java b/src/test/java/com/github/stannismod/forge/testing/TestFrameworkTest.java deleted file mode 100644 index 7abd87a5d..000000000 --- a/src/test/java/com/github/stannismod/forge/testing/TestFrameworkTest.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.github.stannismod.forge.testing; - - -import org.junit.Assert; -import org.junit.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -public class TestFrameworkTest { - - @Test - public void orchestratorRunsTestsAndWritesReports() throws Exception { - Path reportRoot = Files.createTempDirectory("forge-framework-report-"); - TestRegistry registry = new TestRegistry() - .register(new PassingTest()) - .register(new MultiTickTest()) - .register(new FailingTest()); - - List outcomes = new TestBootstrap(registry, new TestReportWriter()).run(reportRoot); - - Assert.assertEquals(3, outcomes.size()); - Assert.assertTrue(outcomes.get(0).passed()); - Assert.assertEquals(TestStatus.PASSED, outcomes.get(0).status()); - Assert.assertEquals(2, outcomes.get(1).ticks()); - Assert.assertEquals(TestStatus.FAILED, outcomes.get(2).status()); - Assert.assertNotNull(outcomes.get(2).failure()); - - Path summaryTxt = reportRoot.resolve("summary.txt"); - Path summaryJson = reportRoot.resolve("summary.json"); - Assert.assertTrue(Files.exists(summaryTxt)); - Assert.assertTrue(Files.exists(summaryJson)); - - String text = new String(Files.readAllBytes(summaryTxt), StandardCharsets.UTF_8); - String json = new String(Files.readAllBytes(summaryJson), StandardCharsets.UTF_8); - Assert.assertTrue(text.contains("total=3")); - Assert.assertTrue(text.contains("PASSED passing_case")); - Assert.assertTrue(json.contains("\"total\":3")); - Assert.assertTrue(json.contains("\"id\":\"failing_case\"")); - } - - private static final class PassingTest implements HeadlessGameTest { - @Override - public String id() { - return "passing_case"; - } - - @Override - public String category() { - return "smoke"; - } - - @Override - public boolean required() { - return true; - } - - @Override - public int timeoutTicks() { - return 4; - } - - @Override - public void setUp(TestContext context) throws Exception { - context.ensureWorkDir(); - context.note("setup"); - } - - @Override - public TestStatus tick(TestContext context) { - context.note("tick"); - return TestStatus.PASSED; - } - - @Override - public void tearDown(TestContext context) { - context.note("teardown"); - } - } - - private static final class MultiTickTest implements HeadlessGameTest { - private int ticks; - - @Override - public String id() { - return "multi_tick_case"; - } - - @Override - public String category() { - return "smoke"; - } - - @Override - public boolean required() { - return true; - } - - @Override - public int timeoutTicks() { - return 4; - } - - @Override - public void setUp(TestContext context) { - ticks = 0; - } - - @Override - public TestStatus tick(TestContext context) { - ticks++; - return ticks >= 2 ? TestStatus.PASSED : TestStatus.RUNNING; - } - - @Override - public void tearDown(TestContext context) { - } - } - - private static final class FailingTest implements HeadlessGameTest { - @Override - public String id() { - return "failing_case"; - } - - @Override - public String category() { - return "smoke"; - } - - @Override - public boolean required() { - return false; - } - - @Override - public int timeoutTicks() { - return 1; - } - - @Override - public void setUp(TestContext context) { - } - - @Override - public TestStatus tick(TestContext context) { - throw new IllegalStateException("boom"); - } - - @Override - public void tearDown(TestContext context) { - } - } -} - From b5248a4733bf20c0331000f22f166253bba1fd6f Mon Sep 17 00:00:00 2001 From: StannisMod Date: Thu, 14 May 2026 11:53:34 +0300 Subject: [PATCH 07/47] Made test framework more FG6-friendly --- build.gradle | 2 +- .../forge/testing/TestAssertions.java | 35 ++++ .../testing/client/RealClientHarness.java | 179 ++++++++++++++++-- 3 files changed, 199 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/github/stannismod/forge/testing/TestAssertions.java diff --git a/build.gradle b/build.gradle index b12731815..ed5a9393a 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.github.stannismod.forge' -version = '0.3.0' +version = '0.4.0' base { archivesName = 'forge-test-framework' diff --git a/src/main/java/com/github/stannismod/forge/testing/TestAssertions.java b/src/main/java/com/github/stannismod/forge/testing/TestAssertions.java new file mode 100644 index 000000000..b76b60b1a --- /dev/null +++ b/src/main/java/com/github/stannismod/forge/testing/TestAssertions.java @@ -0,0 +1,35 @@ +package com.github.stannismod.forge.testing; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; + +public final class TestAssertions { + + private TestAssertions() { + } + + public static ByteBuf newBuffer() { + return Unpooled.buffer(); + } + + public static T roundTrip(T value, BufferWriter writer, BufferReader reader) { + ByteBuf buffer = newBuffer(); + writer.write(value, buffer); + return reader.read(buffer); + } + + public static void assertFullyConsumed(ByteBuf buffer) { + if (buffer.isReadable()) { + throw new AssertionError("Expected buffer to be fully consumed but still had " + buffer.readableBytes() + " readable bytes"); + } + } + + public interface BufferWriter { + void write(T value, ByteBuf buffer); + } + + public interface BufferReader { + T read(ByteBuf buffer); + } +} + diff --git a/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java b/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java index 6fac50072..34e979956 100644 --- a/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java +++ b/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java @@ -13,6 +13,7 @@ import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -57,8 +58,26 @@ public static RealClientHarness start(RealDedicatedServerHarness serverHarness) return new RealClientHarness(root, process, bot, clientLogFile); } catch (Exception exception) { shutdownProcess(process); + // Capture the client log tail BEFORE deleting the temp dir — + // otherwise the diagnostic is always empty. + String logTail = tailFile(clientLogFile); + // Preserve the FULL client log at a stable location so the whole + // startup (FML mod discovery, resource-pack registration, …) can + // be inspected after the temp dir is gone. + Path preservedLog = null; + try { + if (Files.isRegularFile(clientLogFile)) { + preservedLog = Paths.get(System.getProperty("java.io.tmpdir"), + "forge-test-client-last.log"); + Files.copy(clientLogFile, preservedLog, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException ignored) { + // Best-effort only. + } deleteRecursively(root); - throw new IOException("Failed to start real client harness. Recent client log:\n" + tailFile(clientLogFile), exception); + throw new IOException("Failed to start real client harness." + + (preservedLog != null ? " Full log: " + preservedLog : "") + + "\nRecent client log:\n" + logTail, exception); } } @@ -97,6 +116,44 @@ public void close() throws IOException { public static final String PROP_ASSETS_DIR = "forge.test.assets.dir"; public static final String PROP_LEGACY_ARGS = "forge.test.launcher.legacyArgs"; + /** + * System property naming the directory that holds the extracted LWJGL + * natives ({@code lwjgl64.dll} / {@code lwjgl.dll} / jinput, openal …). + * + *

Default resolution scans the RFG / FG4 cache layout + * ({@code ~/.gradle/caches/minecraft/net/minecraft/natives/1.12.2}). FG6 + * does not populate that path — it extracts natives into the project's + * {@code build/natives} directory instead. FG6 projects must set this + * property to {@code /build/natives}.

+ */ + public static final String PROP_NATIVES_DIR = "forge.test.client.nativesDir"; + + /** + * Prefix for per-child environment variable overrides applied to the + * spawned client JVM. + * + *

Every system property named {@code forge.test.client.env.} is + * applied as the environment variable {@code } on the client + * process, overriding whatever the test JVM inherited.

+ * + *

This exists because {@code AbstractClientE2ETest} runs a dedicated + * server harness AND a client in the same test JVM. The server harness + * inherits the test JVM's environment (which a FG6 build script populates + * from the {@code runServer} run-config — {@code mainClass}, {@code tweakClass}, + * etc.). The client needs the {@code runClient} run-config's values for + * those same variables. Since both can't inherit one environment, the + * build script forwards the client's variables through this prefixed + * property channel and the harness applies them only to the client + * process.

+ * + *

Example (build script): + * {@code systemProperty("forge.test.client.env.mainClass", "net.minecraft.client.main.Main")}

+ * + *

RFG projects typically need none of this — RFG sets a single + * project-wide environment that works for both server and client.

+ */ + public static final String PROP_CLIENT_ENV_PREFIX = "forge.test.client.env."; + private static Process launchClient(Path root, int serverPort, int controlPort, Path clientLogFile) throws IOException { Path javaBinary = resolveJavaBinary(); String assetsDirProp = System.getProperty(PROP_ASSETS_DIR); @@ -118,6 +175,10 @@ private static Process launchClient(Path root, int serverPort, int controlPort, javaArgs.add("-Djava.library.path=" + nativesDir.toAbsolutePath()); javaArgs.add("-Dorg.lwjgl.librarypath=" + nativesDir.toAbsolutePath()); javaArgs.add("-Dforge.test.client.logFile=" + clientLogFile.toAbsolutePath()); + // Allow LWJGL to fall back to a software GL pipeline if the vendor + // driver can't provide a stable context — keeps the harness alive on + // machines whose GL driver crashes on legacy MC 1.12 rendering. + javaArgs.add("-Dorg.lwjgl.opengl.Display.allowSoftwareOpenGL=true"); javaArgs.add("-cp"); javaArgs.add(launcherClassPath); javaArgs.add(launcherClass); @@ -160,22 +221,56 @@ private static Process launchClient(Path root, int serverPort, int controlPort, command.add(javaBinary.toString()); command.addAll(javaArgs); - if (WINDOWS) { + // Always spawn via ProcessBuilder + LoggedProcess so the client's + // stdout/stderr is pumped into clientLogFile. The earlier native + // CreateProcessW path (launchWindowsClient) gave process-group + // isolation but discarded all client output — which makes any + // early-startup crash (classpath, natives, launchwrapper) completely + // undiagnosable. A test child dying with its parent is correct cleanup + // anyway, so the native path is no longer worth its blind spot. + // + // Set -Dforge.test.client.nativeLaunch=true to opt back into the old + // native path (no stdout capture). + boolean nativeLaunch = WINDOWS + && Boolean.parseBoolean(System.getProperty("forge.test.client.nativeLaunch", "false")); + if (nativeLaunch) { try { return launchWindowsClient(root, javaBinary, javaArgs); } catch (IOException nativeLaunchFailure) { - ProcessBuilder fallback = new ProcessBuilder(command); - fallback.directory(root.toFile()); - fallback.redirectErrorStream(true); - Process process = fallback.start(); - return new LoggedProcess(process, clientLogFile); + // fall through to the logged ProcessBuilder path } } ProcessBuilder builder = new ProcessBuilder(command); builder.directory(root.toFile()); builder.redirectErrorStream(true); - return builder.start(); + applyClientEnvOverrides(builder); + Process process = builder.start(); + return new LoggedProcess(process, clientLogFile); + } + + /** + * Applies every {@code -Dforge.test.client.env.=} system + * property as the environment variable {@code } on the client + * process. Used by FG6 build scripts to feed the client its own + * {@code runClient} run-config (mainClass / tweakClass / asset paths) + * instead of inheriting the server harness's environment. + * + *

A {@code JAVA_TOOL_OPTIONS} override is honoured here too — a FG6 + * build script that forwards the {@code runClient} {@code -D} flags packs + * them into {@code forge.test.client.env.JAVA_TOOL_OPTIONS}, which then + * cleanly replaces the inherited (server) {@code JAVA_TOOL_OPTIONS}.

+ */ + private static void applyClientEnvOverrides(ProcessBuilder builder) { + Map childEnv = builder.environment(); + for (String name : System.getProperties().stringPropertyNames()) { + if (name.startsWith(PROP_CLIENT_ENV_PREFIX)) { + String envName = name.substring(PROP_CLIENT_ENV_PREFIX.length()); + if (!envName.isEmpty()) { + childEnv.put(envName, System.getProperty(name)); + } + } + } } private static ClientBot awaitClientBot(java.net.ServerSocket serverSocket) throws IOException { @@ -304,11 +399,39 @@ private static String quoteForCommandLine(String value) { } private static void bootstrapClientFiles(Path root) throws IOException { + // Conservative GL settings — the test client only needs to reach the + // in-world handshake, never to render anything pretty. Aggressive GL + // features (VBOs, FBOs, fancy graphics) are the usual trigger for + // EXCEPTION_ACCESS_VIOLATION crashes inside flaky vendor GL drivers + // (notably Intel integrated GPUs running legacy MC 1.12 OpenGL). List options = new ArrayList<>(); options.add("pauseOnLostFocus:false"); - options.add("fboEnable:true"); - options.add("renderDistance:8"); + options.add("fboEnable:false"); + options.add("useVbo:false"); + options.add("renderDistance:2"); + options.add("fancyGraphics:false"); + options.add("ao:0"); + options.add("enableVsync:false"); + options.add("maxFps:30"); + options.add("particles:2"); + options.add("mipmapLevels:0"); Files.write(root.resolve("options.txt"), options, StandardCharsets.UTF_8); + + // Disable FML's splash-screen progress window. Two reasons: + // 1. SplashProgress. → createResourcePack NPEs during + // Minecraft.init() in stripped-down test runtimes (the resource + // pack discovery path assumes a fully-populated mods/ layout that + // a harness game dir doesn't have) — a hard crash before the + // client ever reaches the title screen. + // 2. The splash window spins up its own GL context on a second + // thread, doubling the surface area for vendor-driver crashes. + // The test client never needs the splash; turning it off is strictly + // an improvement. + Path configDir = root.resolve("config"); + Files.createDirectories(configDir); + List splash = new ArrayList<>(); + splash.add("enabled=false"); + Files.write(configDir.resolve("splash.properties"), splash, StandardCharsets.UTF_8); } private static int reservePort() throws IOException { @@ -334,10 +457,29 @@ private static Path gradleUserHome() { } private static Path resolveNativesDir() throws IOException { - Path[] candidates = new Path[] { - gradleUserHome().resolve("caches").resolve("minecraft").resolve("net").resolve("minecraft").resolve("natives").resolve("1.12.2"), - Paths.get(System.getProperty("user.home"), ".gradle").resolve("caches").resolve("minecraft").resolve("net").resolve("minecraft").resolve("natives").resolve("1.12.2") - }; + List candidates = new ArrayList<>(); + + // 1. Explicit override — highest priority. Any project can point this + // at the exact directory holding lwjgl64.dll. + String override = System.getProperty(PROP_NATIVES_DIR); + if (override != null && !override.trim().isEmpty()) { + candidates.add(Paths.get(override.trim())); + } + + // 2. Project-relative auto-scan. The test JVM's working directory is the + // consuming project's root (Gradle's default for Test tasks), so we + // can find the natives the build plugin extracted without any config: + // - ForgeGradle 6 extracts to /build/natives + // - RetroFuturaGradle extracts to /run/natives/lwjgl2 + // - older FG layouts sometimes used /natives + Path projectDir = Paths.get(System.getProperty("user.dir", ".")); + candidates.add(projectDir.resolve("build").resolve("natives")); + candidates.add(projectDir.resolve("run").resolve("natives").resolve("lwjgl2")); + candidates.add(projectDir.resolve("natives")); + + // 3. RFG / FG4 shared-cache layout fallback. + candidates.add(gradleUserHome().resolve("caches").resolve("minecraft").resolve("net").resolve("minecraft").resolve("natives").resolve("1.12.2")); + candidates.add(Paths.get(System.getProperty("user.home"), ".gradle").resolve("caches").resolve("minecraft").resolve("net").resolve("minecraft").resolve("natives").resolve("1.12.2")); for (Path candidate : candidates) { if (Files.isRegularFile(candidate.resolve("lwjgl64.dll")) || Files.isRegularFile(candidate.resolve("lwjgl.dll"))) { @@ -345,7 +487,9 @@ private static Path resolveNativesDir() throws IOException { } } - throw new IOException("Unable to locate LWJGL natives directory in any known Gradle cache location"); + throw new IOException("Unable to locate LWJGL natives directory. Checked " + + candidates + ". Set -D" + PROP_NATIVES_DIR + + "= to point the harness at it explicitly."); } private static Path findCachedJar(String fileName) throws IOException { @@ -426,7 +570,10 @@ private static String tailFile(Path file) { if (lines.isEmpty()) { return ""; } - int from = Math.max(0, lines.size() - 40); + // Grab a generous window — a full MC crash report (Description + + // exception + stacktrace + System Details) easily exceeds 40 lines, + // and the actionable part (the exception header) sits near the top. + int from = Math.max(0, lines.size() - 300); StringBuilder builder = new StringBuilder(); for (int i = from; i < lines.size(); i++) { if (i > from) { From 08ee660f321251069173c2db664827b91183116e Mon Sep 17 00:00:00 2001 From: StannisMod Date: Fri, 15 May 2026 11:41:52 +0300 Subject: [PATCH 08/47] Button-calling functions --- .../forge/testing/client/ClientBot.java | 46 +++++ .../bridge/ForgeTestClientBootstrap.java | 190 ++++++++++++++++++ 2 files changed, 236 insertions(+) diff --git a/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java b/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java index ee61601da..010c83d0a 100644 --- a/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java +++ b/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java @@ -77,6 +77,52 @@ public void clickButtonAtRatio(int index, double ratio) throws IOException { assertOk(execute(command)); } + /** + * Lists every {@link net.minecraft.client.gui.GuiButton} on the open GUI: + * each entry carries {@code id}, {@code text}, {@code x}/{@code y}/{@code width}/ + * {@code height}, {@code enabled} and {@code visible}. Use the stable + * {@code id} (assigned by the mod, not the list position) to drive + * {@link #clickButtonById(int)}. + */ + public JsonObject reportButtons() throws IOException { + return assertOk(execute(command("report_buttons"))); + } + + /** + * Clicks the GUI button whose {@code GuiButton.id} equals {@code id} — + * robust against button-list ordering. Fails if no such button exists or it + * is hidden / disabled. + */ + public void clickButtonById(int id) throws IOException { + JsonObject command = command("click_button_id"); + command.addProperty("id", id); + assertOk(execute(command)); + } + + /** + * Lists every slot of the open {@link net.minecraft.client.gui.inventory.GuiContainer}: + * each entry carries {@code slot} (the container slot number), {@code x}/ + * {@code y}, {@code playerSlot} (true for the player-inventory portion), + * {@code hasStack}, {@code item} (registry name) and {@code count}. + */ + public JsonObject reportSlots() throws IOException { + return assertOk(execute(command("report_slots"))); + } + + /** + * Performs a container slot interaction, mirroring + * {@code GuiContainer.handleMouseClick}. {@code mode} is a + * {@link net.minecraft.inventory.ClickType} name — {@code PICKUP} for a + * normal click, {@code QUICK_MOVE} for shift-click, etc. + */ + public void clickSlot(int slot, int button, String mode) throws IOException { + JsonObject command = command("click_slot"); + command.addProperty("slot", slot); + command.addProperty("button", button); + command.addProperty("mode", mode); + assertOk(execute(command)); + } + public void dragScreenPoint(int startX, int startY, int endX, int endY, int button) throws IOException { JsonObject command = command("drag_screen_point"); command.addProperty("startX", startX); diff --git a/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java b/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java index 739310708..72668ec11 100644 --- a/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java +++ b/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java @@ -1,5 +1,6 @@ package com.github.stannismod.forge.testing.client.bridge; +import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -10,6 +11,9 @@ import net.minecraft.client.gui.GuiTextField; import net.minecraft.client.gui.inventory.GuiContainer; import net.minecraft.client.multiplayer.PlayerControllerMP; +import net.minecraft.inventory.ClickType; +import net.minecraft.inventory.Slot; +import net.minecraft.item.ItemStack; import net.minecraft.network.play.client.CPacketHeldItemChange; import net.minecraft.util.EnumFacing; import net.minecraft.util.EnumHand; @@ -24,6 +28,7 @@ import java.net.InetSocketAddress; import java.net.Socket; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.concurrent.Callable; @@ -224,6 +229,118 @@ private static JsonObject handleCommand(JsonObject request) { invokeMouseClicked(screen, x, y, 0); return ok(); }); + case "report_buttons": + return runOnClientThread(() -> { + GuiScreen screen = Minecraft.getMinecraft().currentScreen; + if (screen == null) { + throw new IllegalStateException("No current GUI to inspect"); + } + JsonArray buttons = new JsonArray(); + for (GuiButton button : collectAllButtons(screen)) { + JsonObject entry = new JsonObject(); + entry.addProperty("id", button.id); + entry.addProperty("text", button.displayString == null ? "" : button.displayString); + entry.addProperty("x", button.x); + entry.addProperty("y", button.y); + entry.addProperty("width", button.width); + entry.addProperty("height", button.height); + entry.addProperty("enabled", button.enabled); + entry.addProperty("visible", button.visible); + buttons.add(entry); + } + JsonObject response = ok(); + response.add("buttons", buttons); + return response; + }); + case "click_button_id": + return runOnClientThread(() -> { + GuiScreen screen = Minecraft.getMinecraft().currentScreen; + if (screen == null) { + throw new IllegalStateException("No current GUI to click"); + } + int targetId = requireInt(request, "id"); + GuiButton match = null; + for (GuiButton button : collectAllButtons(screen)) { + if (button.id == targetId) { + match = button; + break; + } + } + if (match == null) { + throw new IllegalArgumentException("No GUI button with id " + targetId); + } + if (!match.visible || !match.enabled) { + throw new IllegalStateException("GUI button id " + targetId + + " is not clickable (visible=" + match.visible + + ", enabled=" + match.enabled + ")"); + } + // Dispatch through actionPerformed rather than a synthetic + // mouse click: coordinate-free, and libVulpes' GuiModular + // forwards actionPerformed to every module — so this hits + // module-local buttons (planet selector grid, …) that never + // land in GuiScreen.buttonList. + invokeActionPerformed(screen, match); + return ok(); + }); + case "report_slots": + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + if (!(mc.currentScreen instanceof GuiContainer)) { + throw new IllegalStateException("Current GUI is not a container screen"); + } + net.minecraft.inventory.Container container = + ((GuiContainer) mc.currentScreen).inventorySlots; + JsonArray slots = new JsonArray(); + for (Slot slot : container.inventorySlots) { + JsonObject entry = new JsonObject(); + entry.addProperty("slot", slot.slotNumber); + entry.addProperty("x", slot.xPos); + entry.addProperty("y", slot.yPos); + entry.addProperty("playerSlot", + mc.player != null && slot.inventory == mc.player.inventory); + ItemStack stack = slot.getStack(); + entry.addProperty("hasStack", !stack.isEmpty()); + entry.addProperty("item", stack.isEmpty() + ? "" : String.valueOf(stack.getItem().getRegistryName())); + entry.addProperty("count", stack.isEmpty() ? 0 : stack.getCount()); + slots.add(entry); + } + JsonObject response = ok(); + response.add("slots", slots); + return response; + }); + case "click_slot": + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + if (!(mc.currentScreen instanceof GuiContainer)) { + throw new IllegalStateException("Current GUI is not a container screen"); + } + GuiContainer containerScreen = (GuiContainer) mc.currentScreen; + int slotId = requireInt(request, "slot"); + int mouseButton = boundedInt(request, "button", 0, 2); + String modeName = request.has("mode") + ? request.get("mode").getAsString() : "PICKUP"; + ClickType clickType; + try { + clickType = ClickType.valueOf(modeName.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException invalid) { + throw new IllegalArgumentException("Unknown click mode '" + modeName + + "' — expected one of PICKUP, QUICK_MOVE, SWAP, CLONE, THROW," + + " QUICK_CRAFT, PICKUP_ALL"); + } + Slot slot = null; + for (Slot candidate : containerScreen.inventorySlots.inventorySlots) { + if (candidate.slotNumber == slotId) { + slot = candidate; + break; + } + } + if (slot == null) { + throw new IllegalArgumentException("No container slot with id " + slotId); + } + invokeHandleMouseClick(containerScreen, slot, slotId, mouseButton, clickType); + return ok(); + }); case "drag_screen_point": return runOnClientThread(() -> { Minecraft mc = Minecraft.getMinecraft(); @@ -454,6 +571,79 @@ private static List buttonList(GuiScreen screen) { } } + /** + * Every {@link GuiButton} reachable from {@code screen}: the standard + * {@code GuiScreen.buttonList}, plus — for libVulpes-style modular GUIs — + * any per-module button lists. libVulpes {@code GuiModular} keeps its + * sub-modules in a {@code modules} field, and container modules + * ({@code ModuleContainerPan}, the planet-selector grid) keep their buttons + * in their own {@code buttonList}/{@code staticButtonList} fields that never + * reach {@code GuiScreen.buttonList}. Discovered purely reflectively, so the + * framework keeps no compile dependency on libVulpes. + */ + private static List collectAllButtons(GuiScreen screen) { + List all = new ArrayList<>(buttonList(screen)); + Object modules = readFieldOrNull(screen, "modules"); + if (modules instanceof List) { + for (Object module : (List) modules) { + collectModuleButtons(module, all); + } + } + return all; + } + + private static void collectModuleButtons(Object module, List out) { + if (module == null) { + return; + } + for (String fieldName : new String[] {"buttonList", "staticButtonList"}) { + Object value = readFieldOrNull(module, fieldName); + if (value instanceof List) { + for (Object element : (List) value) { + if (element instanceof GuiButton) { + out.add((GuiButton) element); + } + } + } + } + } + + private static Object readFieldOrNull(Object target, String fieldName) { + try { + java.lang.reflect.Field field = findField(target.getClass(), fieldName); + field.setAccessible(true); + return field.get(target); + } catch (ReflectiveOperationException ignored) { + return null; + } + } + + /** + * Dispatches a button through the screen's {@code actionPerformed} — the + * same entry point MC invokes on a real click. libVulpes {@code GuiModular} + * forwards it to every module, so module-local buttons are handled too. + */ + private static void invokeActionPerformed(GuiScreen screen, GuiButton button) { + try { + java.lang.reflect.Method method = findMethod(screen.getClass(), "actionPerformed", GuiButton.class); + method.setAccessible(true); + method.invoke(screen, button); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("Failed to dispatch GUI button action", exception); + } + } + + private static void invokeHandleMouseClick(GuiContainer screen, Slot slot, int slotId, int mouseButton, ClickType type) { + try { + java.lang.reflect.Method method = findMethod(screen.getClass(), "handleMouseClick", + Slot.class, int.class, int.class, ClickType.class); + method.setAccessible(true); + method.invoke(screen, slot, slotId, mouseButton, type); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("Failed to click container slot", exception); + } + } + private static void invokeMouseClicked(GuiScreen screen, int x, int y, int button) { try { java.lang.reflect.Method method = findMethod(screen.getClass(), "mouseClicked", int.class, int.class, int.class); From 4f8196ccfc861ac3f9cc2569a447dbb3dc211f10 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Fri, 15 May 2026 13:28:34 +0300 Subject: [PATCH 09/47] Starting test clients in taskbar --- .../testing/client/RealClientHarness.java | 22 +++++- .../bridge/ForgeTestClientBootstrap.java | 77 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java b/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java index 34e979956..3b3982cfc 100644 --- a/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java +++ b/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java @@ -29,6 +29,18 @@ public final class RealClientHarness implements AutoCloseable { private static final int STILL_ACTIVE = 259; private static final int STARTF_USESHOWWINDOW = 0x00000001; private static final int SW_SHOWNOACTIVATE = 4; + private static final int SW_SHOWMINNOACTIVE = 7; + + /** + * Controls how the LWJGL client window first appears. Default + * {@code minimized} — the window is iconified to the taskbar without + * stealing focus, so concurrent local work is not disrupted. Set to + * {@code normal} to restore the previous behaviour ({@code SW_SHOWNOACTIVATE}, + * window visible at requested geometry but without keyboard focus). Honoured + * on the Windows {@code CreateProcessW} launch path only — other platforms + * inherit the default desktop behaviour. + */ + private static final String PROP_WINDOW_START_STATE = "forge.test.client.window.startState"; private final Path root; private final Process process; @@ -320,7 +332,15 @@ private static Process launchWindowsClient(Path root, Path javaBinary, ListControlled by system property {@code forge.test.client.window.startState} + * (default {@code minimized}). Set to {@code normal} to keep the window + * visible. No-op on non-Windows hosts.

+ */ + private static void applyInitialWindowState() { + if (!WINDOW_STATE_APPLIED.compareAndSet(false, true)) { + return; + } + String state = System.getProperty("forge.test.client.window.startState", "minimized") + .toLowerCase(Locale.ROOT); + if (!"minimized".equals(state)) { + return; + } + if (!System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win")) { + return; + } + try { + Class displayClass = Class.forName("org.lwjgl.opengl.Display"); + java.lang.reflect.Method isCreated = displayClass.getMethod("isCreated"); + if (!Boolean.TRUE.equals(isCreated.invoke(null))) { + return; + } + // Move the window completely off-screen first, so the visible + // "flash" between Display.create() and our minimize call doesn't + // pop up over the user's other monitors. setLocation(int,int) is + // public LWJGL2 API and is honoured immediately by the native side. + try { + java.lang.reflect.Method setLocation = + displayClass.getMethod("setLocation", int.class, int.class); + setLocation.invoke(null, -32000, -32000); + } catch (Throwable ignored) { + // Older/newer LWJGL2 variants — fall back to minimize-only. + } + java.lang.reflect.Field implField = displayClass.getDeclaredField("display_impl"); + implField.setAccessible(true); + Object impl = implField.get(null); + java.lang.reflect.Method getHwndMethod = impl.getClass().getDeclaredMethod("getHwnd"); + getHwndMethod.setAccessible(true); + Object hwndObject = getHwndMethod.invoke(impl); + long hwnd = ((Number) hwndObject).longValue(); + if (hwnd == 0L) { + return; + } + // SW_FORCEMINIMIZE rather than SW_MINIMIZE so the call still works + // if some future Forge change moves ClientTickEvent off the LWJGL- + // owning thread (MSDN: "use when minimizing windows from a + // different thread"). On the same-thread path it behaves identically + // to SW_MINIMIZE. + final int SW_FORCEMINIMIZE = 11; + User32Native.INSTANCE.ShowWindow(new com.sun.jna.Pointer(hwnd), SW_FORCEMINIMIZE); + } catch (Throwable t) { + // Best-effort — never break the test run because the cosmetic + // minimise call failed. + System.err.println("[forge-test] applyInitialWindowState failed: " + t); + } + } + + private interface User32Native extends com.sun.jna.Library { + User32Native INSTANCE = (User32Native) com.sun.jna.Native.loadLibrary("user32", User32Native.class); + + boolean ShowWindow(com.sun.jna.Pointer hwnd, int nCmdShow); + } + private static final class TeeOutputStream extends OutputStream { private final OutputStream first; private final OutputStream second; From 0657dc052b258364c63522f3c17f61e275b5e7a6 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Sat, 16 May 2026 12:38:20 +0300 Subject: [PATCH 10/47] Weather methods --- build.gradle | 2 +- .../forge/testing/client/ClientBot.java | 11 ++++++++ .../testing/client/RealClientHarness.java | 15 +++++++++++ .../bridge/ForgeTestClientBootstrap.java | 27 +++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index ed5a9393a..d56afd0a8 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.github.stannismod.forge' -version = '0.4.0' +version = '0.4.2' base { archivesName = 'forge-test-framework' diff --git a/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java b/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java index 010c83d0a..f54f45b25 100644 --- a/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java +++ b/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java @@ -156,6 +156,17 @@ public JsonObject reportState() throws IOException { return assertOk(execute(command("report_state"))); } + /** + * Client-side view of vanilla weather state for whatever dim the player is + * currently in. Reports {@code dim}, {@code worldInfoClass}, {@code isRaining}, + * {@code isThundering}, {@code rainTime}, {@code thunderTime}, + * {@code rainStrength} (post-SPacketChangeGameState lerp), {@code thunderStrength}. + * If the client world isn't ready yet, only {@code worldReady=false} is set. + */ + public JsonObject reportWeather() throws IOException { + return assertOk(execute(command("report_weather"))); + } + public JsonObject blockState(int x, int y, int z) throws IOException { JsonObject command = command("block_state"); command.addProperty("x", x); diff --git a/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java b/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java index 34e979956..24658cb7d 100644 --- a/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java +++ b/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java @@ -99,6 +99,21 @@ public void close() throws IOException { try { shutdownProcess(process); } finally { + // Preserve the client log at a stable location BEFORE wiping + // the tmp dir — diagnostics for any test that observed + // unexpected client behaviour (rendered weather, GUI state, + // packet flow) only survive across the deleteRecursively if + // we copy first. Matches the startup-failure preservation + // path so post-mortem looks at one well-known file. + try { + if (clientLogFile != null && Files.isRegularFile(clientLogFile)) { + Path preservedLog = Paths.get(System.getProperty("java.io.tmpdir"), + "forge-test-client-last.log"); + Files.copy(clientLogFile, preservedLog, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException ignored) { + // Best-effort only — never block close on preserve failure. + } deleteRecursively(root); } } diff --git a/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java b/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java index 72668ec11..e3d6334e0 100644 --- a/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java +++ b/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java @@ -436,6 +436,33 @@ private static JsonObject handleCommand(JsonObject request) { } return response; }); + case "report_weather": + // Client-side view of vanilla weather state for whatever + // dimension the client is currently in. Reports what the + // PLAYER is seeing — different from a server-side query + // because vanilla syncs weather via SPacketChangeGameState + // (begin/end raining + strength edges), so this is the + // canonical way to assert that those packets reached the + // rendered frame after a server-side weather change or a + // cross-dimension teleport. + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + JsonObject response = ok(); + if (mc.world == null) { + response.addProperty("worldReady", false); + return response; + } + response.addProperty("worldReady", true); + response.addProperty("dim", mc.world.provider.getDimension()); + response.addProperty("worldInfoClass", mc.world.getWorldInfo().getClass().getName()); + response.addProperty("isRaining", mc.world.getWorldInfo().isRaining()); + response.addProperty("isThundering", mc.world.getWorldInfo().isThundering()); + response.addProperty("rainTime", mc.world.getWorldInfo().getRainTime()); + response.addProperty("thunderTime", mc.world.getWorldInfo().getThunderTime()); + response.addProperty("rainStrength", mc.world.getRainStrength(1.0f)); + response.addProperty("thunderStrength", mc.world.getThunderStrength(1.0f)); + return response; + }); case "block_state": return runOnClientThread(() -> { Minecraft mc = Minecraft.getMinecraft(); From 0afef62949b91a39586d1a23c23e79375b1b58ae Mon Sep 17 00:00:00 2001 From: StannisMod Date: Mon, 18 May 2026 07:05:12 +0200 Subject: [PATCH 11/47] fix cross-platform native binary resolution - pick java/java.exe by os.name in server harness - accept .so/.dylib LWJGL markers in client native scan, not only .dll - mark gradlew executable Co-Authored-By: Claude Opus 4.7 (1M context) --- gradlew | 0 .../forge/testing/client/RealClientHarness.java | 11 +++++++++-- .../testing/server/RealDedicatedServerHarness.java | 6 ++++-- 3 files changed, 13 insertions(+), 4 deletions(-) mode change 100644 => 100755 gradlew diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java b/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java index dd18b077f..feda9b107 100644 --- a/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java +++ b/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java @@ -516,9 +516,16 @@ private static Path resolveNativesDir() throws IOException { candidates.add(gradleUserHome().resolve("caches").resolve("minecraft").resolve("net").resolve("minecraft").resolve("natives").resolve("1.12.2")); candidates.add(Paths.get(System.getProperty("user.home"), ".gradle").resolve("caches").resolve("minecraft").resolve("net").resolve("minecraft").resolve("natives").resolve("1.12.2")); + String[] markers = { + "lwjgl64.dll", "lwjgl.dll", + "liblwjgl64.so", "liblwjgl.so", + "liblwjgl.dylib" + }; for (Path candidate : candidates) { - if (Files.isRegularFile(candidate.resolve("lwjgl64.dll")) || Files.isRegularFile(candidate.resolve("lwjgl.dll"))) { - return candidate; + for (String marker : markers) { + if (Files.isRegularFile(candidate.resolve(marker))) { + return candidate; + } } } diff --git a/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java b/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java index 9acea13f7..a19941d2e 100644 --- a/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java +++ b/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java @@ -139,9 +139,11 @@ public void close() throws IOException { private static Process launchServer(Path root, int port) throws IOException { String javaExe = System.getProperty("java.home"); + boolean windows = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT).contains("win"); + String javaName = windows ? "java.exe" : "java"; Path javaBinary = javaExe == null - ? Paths.get("java.exe") - : Paths.get(javaExe, "bin", "java.exe"); + ? Paths.get(javaName) + : Paths.get(javaExe, "bin", javaName); String launcherClass = System.getProperty(PROP_LAUNCHER_CLASS, "GradleStartServer"); boolean legacyArgs = Boolean.parseBoolean(System.getProperty(PROP_LEGACY_ARGS, "true")); From 948d5fdff3d40987146bf3e6b2c5dcbd862a04cd Mon Sep 17 00:00:00 2001 From: StannisMod Date: Sun, 24 May 2026 09:31:38 +0200 Subject: [PATCH 12/47] test: port-bind retry in RealDedicatedServerHarness - 3-attempt loop on BindException in child JVM transcript - awaitReadyOrBindFailure polls for ready/failure marker - Split bootstrapServerFiles into writeEula + per-attempt props - destroyAndJoin helper cleans up failed attempts between retries Mitigates TASK-16 shape #1 (port-bind TOCTOU) observed under parallel testServer forks in downstream AdvancedRocketry repo. --- .../server/RealDedicatedServerHarness.java | 120 +++++++++++++++--- 1 file changed, 103 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java b/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java index a19941d2e..e95ab35b8 100644 --- a/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java +++ b/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java @@ -67,24 +67,110 @@ public static RealDedicatedServerHarness startWith(Path root, boolean cleanupOnC private static RealDedicatedServerHarness startInternal(Path root, boolean bootstrap, boolean cleanupOnClose) throws IOException, InterruptedException { - int port = reservePort(); if (bootstrap) { - bootstrapServerFiles(root, port); - } else { - // Reuse existing world/config; rewrite server.properties with a fresh - // port so we don't collide with any other running test JVM. + writeEula(root); + } + IOException lastFailure = null; + for (int attempt = 1; attempt <= MAX_PORT_BIND_ATTEMPTS; attempt++) { + int port = reservePort(); + // Rewrite server.properties with the freshly reserved port on every + // attempt — needed both for the first iteration and for retries + // after a child JVM lost the TOCTOU race to bind it. Files.write(root.resolve("server.properties"), buildServerProperties(port).getBytes(StandardCharsets.UTF_8)); + Process process = launchServer(root, port); + List transcript = new ArrayList<>(); + Thread readerThread = startReader(process, transcript); + TestClient client = new TestClient(process, TestClient.newWriter(process), transcript); + BootOutcome outcome; + try { + outcome = awaitReadyOrBindFailure(process, transcript, Duration.ofMinutes(3)); + } catch (RuntimeException | InterruptedException failure) { + destroyAndJoin(process, readerThread); + throw failure; + } + if (outcome == BootOutcome.READY) { + return new RealDedicatedServerHarness(root, port, client, readerThread, cleanupOnClose); + } + destroyAndJoin(process, readerThread); + lastFailure = new IOException("BindException on port " + port + + " (attempt " + attempt + " of " + MAX_PORT_BIND_ATTEMPTS + ")"); + } + throw new IOException("Failed to start dedicated server after " + + MAX_PORT_BIND_ATTEMPTS + " port-bind attempts", lastFailure); + } + + private static final int MAX_PORT_BIND_ATTEMPTS = 3; + + private enum BootOutcome { READY, BIND_FAILED } + + private static BootOutcome awaitReadyOrBindFailure(Process process, List transcript, + Duration timeout) throws InterruptedException { + final String readyMarker = "For help, type \"help\" or \"?\""; + final String bindMarker = "BindException"; + long deadlineNanos = System.nanoTime() + timeout.toNanos(); + int index = 0; + while (System.nanoTime() < deadlineNanos) { + synchronized (transcript) { + while (index < transcript.size()) { + String line = transcript.get(index++); + if (line.contains(readyMarker)) { + return BootOutcome.READY; + } + if (line.contains(bindMarker)) { + return BootOutcome.BIND_FAILED; + } + } + if (!process.isAlive()) { + // Child exited without printing the ready marker. If a bind + // failure is visible in the tail, treat the attempt as a + // port collision and let the caller retry; otherwise this + // is a real crash and we surface it as before. + for (int i = transcript.size() - 1; + i >= Math.max(0, transcript.size() - 50); i--) { + if (transcript.get(i).contains(bindMarker)) { + return BootOutcome.BIND_FAILED; + } + } + throw new AssertionError("Server process exited (code=" + + process.exitValue() + ") before becoming ready. Recent output: " + + tailOf(transcript)); + } + long remainingNanos = deadlineNanos - System.nanoTime(); + long waitMillis = Math.max(1L, TimeUnit.NANOSECONDS.toMillis(remainingNanos)); + transcript.wait(Math.min(waitMillis, 250L)); + } } - Process process = launchServer(root, port); + throw new AssertionError("Timed out waiting for server to become ready. Recent output: " + + tailOf(transcript)); + } - List transcript = new ArrayList<>(); - Thread readerThread = startReader(process, transcript); - TestClient client = new TestClient(process, TestClient.newWriter(process), transcript); - RealDedicatedServerHarness harness = new RealDedicatedServerHarness( - root, port, client, readerThread, cleanupOnClose); - client.awaitOutputContaining("For help, type \"help\" or \"?\"", Duration.ofMinutes(3)); - return harness; + private static String tailOf(List transcript) { + synchronized (transcript) { + int from = Math.max(0, transcript.size() - 25); + StringBuilder builder = new StringBuilder(); + for (int i = from; i < transcript.size(); i++) { + if (i > from) { + builder.append(System.lineSeparator()); + } + builder.append(transcript.get(i)); + } + return builder.toString(); + } + } + + private static void destroyAndJoin(Process process, Thread readerThread) { + process.destroyForcibly(); + try { + process.waitFor(5, TimeUnit.SECONDS); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + } + try { + readerThread.join(TimeUnit.SECONDS.toMillis(5)); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + } } public Path root() { @@ -186,7 +272,7 @@ private static Process launchServer(Path root, int port) throws IOException { } else { // FG6's net.minecraftforge.legacydev.MainServer takes no args — it reads // working directory + server.properties. Port comes from server.properties - // (already written by bootstrapServerFiles) and gameDir is the cwd. + // (already written above in startInternal) and gameDir is the cwd. command.add("--nogui"); } @@ -230,9 +316,9 @@ private static Thread startReader(Process process, List transcript) { return reader; } - private static void bootstrapServerFiles(Path root, int port) throws IOException { - Files.write(root.resolve("eula.txt"), java.util.Collections.singletonList("eula=true"), StandardCharsets.UTF_8); - Files.write(root.resolve("server.properties"), buildServerProperties(port).getBytes(StandardCharsets.UTF_8)); + private static void writeEula(Path root) throws IOException { + Files.write(root.resolve("eula.txt"), + java.util.Collections.singletonList("eula=true"), StandardCharsets.UTF_8); } private static String buildServerProperties(int port) { From 2e16dea4d3abd1277b7f65099bf8c0172aa5259c Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 27 May 2026 07:31:14 +0200 Subject: [PATCH 13/47] feat: multi-client support in RealClientHarness - New start(server, username) overload for distinct per-client names - Move --username/--uuid out of legacyArgs block (FG6 MainClient also honours them) - Without this fix FG6 legacydev generated random Player### names --- .../testing/client/RealClientHarness.java | 50 ++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java b/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java index feda9b107..d3c69b316 100644 --- a/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java +++ b/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java @@ -20,6 +20,10 @@ public final class RealClientHarness implements AutoCloseable { + /** Default username for the single-client {@link #start(RealDedicatedServerHarness)} + * entry point. Multi-client tests use the {@link #start(RealDedicatedServerHarness, String)} + * overload to supply distinct usernames per client — the server's PlayerList + * keys on username, so two clients sharing this constant would collide. */ private static final String CLIENT_USERNAME = "ForgeTestClient"; private static final boolean WINDOWS = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT).contains("win"); private static final int NORMAL_PRIORITY_CLASS = 0x00000020; @@ -55,6 +59,26 @@ private RealClientHarness(Path root, Process process, ClientBot bot, Path client } public static RealClientHarness start(RealDedicatedServerHarness serverHarness) throws Exception { + return start(serverHarness, CLIENT_USERNAME); + } + + /** + * Spawn a Minecraft client harness with a caller-supplied username. + * + *

Multi-client tests use this overload to bring up several clients + * against the same dedicated server — the server's PlayerList keys on + * username, so concurrent clients MUST pick distinct names or the + * later joiner is kicked as a duplicate. Each client gets its own + * temp gameDir, control port, and JVM, so resource collision between + * clients is limited to the GL display (typically managed by passing + * {@code DISPLAY=:77} or equivalent through to the client JVM).

+ * + *

The username is forwarded to launchwrapper's {@code --username} + * and also seeds the deterministic offline-mode UUID + * ({@code OfflinePlayer:} → {@code UUID.nameUUIDFromBytes}).

+ */ + public static RealClientHarness start(RealDedicatedServerHarness serverHarness, + String clientUsername) throws Exception { Path root = Files.createTempDirectory("forge-client-"); Files.createDirectories(root.resolve("resourcepacks")); bootstrapClientFiles(root); @@ -63,7 +87,8 @@ public static RealClientHarness start(RealDedicatedServerHarness serverHarness) Path clientLogFile = root.resolve("client.log"); Process process = null; try (java.net.ServerSocket controlSocket = openControlSocket(controlPort)) { - process = launchClient(root, serverHarness.port(), controlPort, clientLogFile); + process = launchClient(root, serverHarness.port(), controlPort, clientLogFile, + clientUsername); ClientBot bot = awaitClientBot(controlSocket); bot.waitForWorld(); @@ -181,7 +206,8 @@ public void close() throws IOException { */ public static final String PROP_CLIENT_ENV_PREFIX = "forge.test.client.env."; - private static Process launchClient(Path root, int serverPort, int controlPort, Path clientLogFile) throws IOException { + private static Process launchClient(Path root, int serverPort, int controlPort, Path clientLogFile, + String clientUsername) throws IOException { Path javaBinary = resolveJavaBinary(); String assetsDirProp = System.getProperty(PROP_ASSETS_DIR); Path assetsDir = assetsDirProp != null @@ -215,6 +241,20 @@ private static Process launchClient(Path root, int serverPort, int controlPort, javaArgs.add(String.valueOf(serverPort)); javaArgs.add("--gameDir"); javaArgs.add(root.toAbsolutePath().toString()); + // Username MUST be passed regardless of legacyArgs — both the + // RFG/FG4 GradleStart launcher AND the FG6 legacydev MainClient + // accept --username (FG6's MainClient.getDefaultArguments seeds + // it as null, so an unspecified --username yields a random + // generated "Player###" name and breaks any test that needs to + // resolve a specific known username via the server's PlayerList). + // Multi-client tests rely on this to give each client a distinct + // resolvable name. The --uuid is similarly seeded off the + // username to keep offline-mode UUIDs deterministic per name. + javaArgs.add("--username"); + javaArgs.add(clientUsername); + javaArgs.add("--uuid"); + javaArgs.add(UUID.nameUUIDFromBytes(("OfflinePlayer:" + clientUsername) + .getBytes(StandardCharsets.UTF_8)).toString().replace("-", "")); if (legacyArgs) { javaArgs.add("--assetsDir"); @@ -225,16 +265,14 @@ private static Process launchClient(Path root, int serverPort, int controlPort, javaArgs.add("FML_DEV"); javaArgs.add("--assetIndex"); javaArgs.add("1.12.2"); - javaArgs.add("--username"); - javaArgs.add(CLIENT_USERNAME); javaArgs.add("--accessToken"); javaArgs.add("FML"); javaArgs.add("--userProperties"); javaArgs.add("{}"); javaArgs.add("--profileProperties"); javaArgs.add("{}"); - javaArgs.add("--uuid"); - javaArgs.add(UUID.nameUUIDFromBytes(("OfflinePlayer:" + CLIENT_USERNAME).getBytes(StandardCharsets.UTF_8)).toString().replace("-", "")); + // --username + --uuid are now passed unconditionally above + // (FG6's MainClient also honours them). javaArgs.add("--width"); javaArgs.add("640"); javaArgs.add("--height"); From 7f8ee7f04959a7688b5dc307e43056284a553587 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Mon, 1 Jun 2026 20:22:43 +0200 Subject: [PATCH 14/47] fix: remove vestigial dummy mod container from ASM coremod The coremod's only job is bootstrapping mixins (done in the plugin constructor); the DummyModContainer "advancedrocketrycore" registered empty lifecycle handlers and nothing referenced its modid. It only added a phantom entry to the title-screen "loaded" count without ever becoming "active", producing the off-by-one reported in dercodeKoenig/AdvancedRocketry#71. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../asm/AdvancedRocketryPlugin.java | 2 +- .../advancedRocketry/asm/ModContainer.java | 59 ------------------- 2 files changed, 1 insertion(+), 60 deletions(-) delete mode 100644 src/main/java/zmaster587/advancedRocketry/asm/ModContainer.java diff --git a/src/main/java/zmaster587/advancedRocketry/asm/AdvancedRocketryPlugin.java b/src/main/java/zmaster587/advancedRocketry/asm/AdvancedRocketryPlugin.java index f83adeb48..e5a59afa8 100644 --- a/src/main/java/zmaster587/advancedRocketry/asm/AdvancedRocketryPlugin.java +++ b/src/main/java/zmaster587/advancedRocketry/asm/AdvancedRocketryPlugin.java @@ -34,7 +34,7 @@ public String[] getASMTransformerClass() { @Override public String getModContainerClass() { - return "zmaster587.advancedRocketry.asm.ModContainer"; + return null; } @Override diff --git a/src/main/java/zmaster587/advancedRocketry/asm/ModContainer.java b/src/main/java/zmaster587/advancedRocketry/asm/ModContainer.java deleted file mode 100644 index 36819717c..000000000 --- a/src/main/java/zmaster587/advancedRocketry/asm/ModContainer.java +++ /dev/null @@ -1,59 +0,0 @@ -package zmaster587.advancedRocketry.asm; - -import net.minecraftforge.fml.common.DummyModContainer; -import net.minecraftforge.fml.common.LoadController; -import net.minecraftforge.fml.common.Mod.EventHandler; -import net.minecraftforge.fml.common.ModMetadata; -import net.minecraftforge.fml.common.event.FMLConstructionEvent; -import net.minecraftforge.fml.common.event.FMLInitializationEvent; -import net.minecraftforge.fml.common.event.FMLPostInitializationEvent; -import net.minecraftforge.fml.common.event.FMLPreInitializationEvent; -import net.minecraftforge.fml.common.eventhandler.EventBus; - -import java.util.Collections; - - -public class ModContainer extends DummyModContainer { - - //ModContainer Class adapted from SackCastellon - public ModContainer() { - super(new ModMetadata()); - - System.out.println("********* CoreDummyContainer. OK"); - - ModMetadata meta = getMetadata(); - - meta.modId = "advancedrocketrycore"; - meta.name = "Advanced Rocketry Core"; - meta.version = "1"; - meta.credits = "Created by Zmaster587"; - meta.authorList = Collections.singletonList("Zmaster587"); - meta.description = "ASM handler for AR"; - meta.url = ""; - meta.updateUrl = ""; - meta.screenshots = new String[0]; - meta.logoFile = ""; - } - - public boolean registerBus(EventBus bus, LoadController controller) { - System.out.println("********* registerBus. OK"); - bus.register(this); - return true; - } - - @EventHandler - public void modConstruction(FMLConstructionEvent event) { - } - - @EventHandler - public void preInit(FMLPreInitializationEvent event) { - } - - @EventHandler - public void load(FMLInitializationEvent event) { - } - - @EventHandler - public void postInit(FMLPostInitializationEvent event) { - } -} From ae379cac3806b1e025e7deea657f6124034c2948 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Mon, 1 Jun 2026 20:52:08 +0200 Subject: [PATCH 15/47] fix: tolerate bad planet configs and crash with a report, not silently Loading planetDefs.xml with a subset of mods crashed at the path: doesOreNameExist is true for a reserved ore name even when no items are registered under it, so getOres(...).get(0) hit an empty list. Resolve the ore name once (trimmed), guard the empty list, and copy() the prototype stack before mutating its count. Also make config loading fault-tolerant: a single malformed planet is now logged and skipped instead of aborting the whole file (XMLPlanetLoader .readAllPlanets per-planet isolation), and genuinely fatal/structural failures propagate via loadPlanetsOrThrow so Forge produces a normal crash report instead of the old silent FMLCommonHandler.exitJava that closed the window with no report. Fixes dercodeKoenig/AdvancedRocketry#77 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dimension/DimensionManager.java | 22 +-- .../util/XMLPlanetLoader.java | 70 +++++++-- .../test/integration/XMLPlanetLoaderTest.java | 142 +++++++++++++++--- 3 files changed, 187 insertions(+), 47 deletions(-) diff --git a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java index f1e4d2c35..ce916a3e1 100644 --- a/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java +++ b/src/main/java/zmaster587/advancedRocketry/dimension/DimensionManager.java @@ -834,24 +834,12 @@ public void createAndLoadDimensions(boolean resetFromXml) { if (file.exists()) { logger.info("Advanced Planet Config file Found! Loading from file."); loader = new XMLPlanetLoader(); - boolean loadSuccessful = true; - try { - if (loader.loadFile(file)) { - dimCouplingList = loader.readAllPlanets(); - DimensionManager.dimOffset += dimCouplingList.dims.size(); - } else { - loadSuccessful = false; - } - } catch (Exception e) { - e.printStackTrace(); - loadSuccessful = false; - } - - if (!loadSuccessful) { - logger.fatal("A serious error has occurred while loading the planetDefs XML"); - FMLCommonHandler.instance().exitJava(-1, false); - } + // A fatal/structural failure propagates so Forge produces a normal crash + // report (diagnosable) instead of the old silent FMLCommonHandler.exitJava. + // Recoverable per-planet config mistakes are skipped inside readAllPlanets. + dimCouplingList = loader.loadPlanetsOrThrow(file); + DimensionManager.dimOffset += dimCouplingList.dims.size(); } //End load planet files diff --git a/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java b/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java index 0f037d54f..ddc803023 100644 --- a/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java +++ b/src/main/java/zmaster587/advancedRocketry/util/XMLPlanetLoader.java @@ -847,17 +847,27 @@ else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_BIOMEIDS)) { for (String entry : entries) { String[] parts = entry.split(";"); - - if (OreDictionary.doesOreNameExist(parts[0].trim())) { - ItemStack item = OreDictionary.getOres(parts[0]).get(0); - if (parts.length > 1) { - try { - item.setCount(Integer.parseInt(parts[1])); - } catch (NumberFormatException ignored) { + String oreName = parts[0].trim(); + + if (OreDictionary.doesOreNameExist(oreName)) { + // doesOreNameExist returns true for any *reserved* ore name even + // when no items are registered under it (e.g. the providing mod + // isn't installed), so getOres can hand back an empty list. + List ores = OreDictionary.getOres(oreName); + if (ores.isEmpty()) { + AdvancedRocketry.logger.warn(oreName + " is a known ore dictionary name but has no " + + "registered items (providing mod not installed?); skipping laser drill ore entry"); + } else { + ItemStack item = ores.get(0).copy(); + if (parts.length > 1) { + try { + item.setCount(Integer.parseInt(parts[1].trim())); + } catch (NumberFormatException ignored) { + } } + properties.laserDrillOres.add(item); } - properties.laserDrillOres.add(item); - } else if (Item.getByNameOrId(parts[0].trim()) != null) { + } else if (Item.getByNameOrId(oreName) != null) { int quantity = 1; int damage = 0; if (parts.length > 1) { @@ -872,9 +882,9 @@ else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_BIOMEIDS)) { } } } - properties.laserDrillOres.add(new ItemStack(Objects.requireNonNull(Item.getByNameOrId(parts[0].trim())), quantity, damage)); + properties.laserDrillOres.add(new ItemStack(Objects.requireNonNull(Item.getByNameOrId(oreName)), quantity, damage)); } else { - AdvancedRocketry.logger.warn(parts[0] + " is not a valid OreDictionary name or item ID"); + AdvancedRocketry.logger.warn(oreName + " is not a valid OreDictionary name or item ID"); } } } else if (planetPropertyNode.getNodeName().equalsIgnoreCase(ELEMENT_GEODE_ORES)) { @@ -1121,7 +1131,11 @@ public StellarBody readSubStar(Node planetNode) { public DimensionPropertyCoupling readAllPlanets() { DimensionPropertyCoupling coupling = new DimensionPropertyCoupling(); - Node masterNode = doc.getElementsByTagName("galaxy").item(0).getFirstChild(); + NodeList galaxyNodes = doc.getElementsByTagName("galaxy"); + if (galaxyNodes.getLength() == 0) { + throw new RuntimeException("planetDefs XML has no root element"); + } + Node masterNode = galaxyNodes.item(0).getFirstChild(); //readPlanetFromNode changes value //Yes it's hacky but that's another reason why it's private @@ -1142,7 +1156,15 @@ public DimensionPropertyCoupling readAllPlanets() { while (planetNode != null) { if (planetNode.getNodeName().equalsIgnoreCase(ELEMENT_PLANET)) { - coupling.dims.addAll(readPlanetFromNode(planetNode, star)); + // Isolate each planet: a malformed definition (e.g. an ore name + // from a mod that isn't installed) is logged and skipped rather + // than aborting the whole config load. See issue #77. + try { + coupling.dims.addAll(readPlanetFromNode(planetNode, star)); + } catch (RuntimeException e) { + AdvancedRocketry.logger.warn("Skipping malformed planet definition under star '" + + star.getName() + "' — check your planetDefs.xml: " + e, e); + } } if (planetNode.getNodeName().equalsIgnoreCase("star")) { StellarBody star2 = readSubStar(planetNode); @@ -1156,6 +1178,28 @@ public DimensionPropertyCoupling readAllPlanets() { return coupling; } + /** + * Loads {@code file} and parses every planet, throwing a {@link RuntimeException} + * on a fatal/structural failure (unparseable XML, missing {@code } root) + * instead of terminating the JVM. At the call site (server start) Forge turns the + * thrown exception into a normal crash report, which is far more diagnosable than + * the old silent {@link net.minecraftforge.fml.common.FMLCommonHandler#exitJava}. + * Recoverable per-planet config mistakes are skipped-and-warned inside + * {@link #readAllPlanets()} and never reach here. + */ + public DimensionPropertyCoupling loadPlanetsOrThrow(File file) { + try { + if (!loadFile(file)) { + throw new RuntimeException("planetDefs XML at " + file.getAbsolutePath() + + " could not be parsed as valid XML"); + } + } catch (IOException e) { + throw new RuntimeException("planetDefs XML at " + file.getAbsolutePath() + + " could not be read", e); + } + return readAllPlanets(); + } + public static class DimensionPropertyCoupling { public List stars = new LinkedList<>(); diff --git a/src/test/java/zmaster587/advancedRocketry/test/integration/XMLPlanetLoaderTest.java b/src/test/java/zmaster587/advancedRocketry/test/integration/XMLPlanetLoaderTest.java index acf521f55..3b26fabdf 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/integration/XMLPlanetLoaderTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/integration/XMLPlanetLoaderTest.java @@ -1,5 +1,8 @@ package zmaster587.advancedRocketry.test.integration; +import net.minecraft.init.Items; +import net.minecraft.item.ItemStack; +import net.minecraftforge.oredict.OreDictionary; import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; @@ -166,23 +169,18 @@ public void weatherFieldsDefaultWhenMissing() throws Exception { } @Test - public void invalidWeatherMarkerFailsExplicitly() throws Exception { - // ELEMENT_RAIN_MARKER parsing uses Integer.parseInt with no try/catch - // around it (production code). Verify the existing behaviour: parser - // throws NumberFormatException loudly rather than silently accepting - // garbage. Future hardening can replace this assertion with a - // "normalized to 0" check if the parsing path adds a try/catch. - try { - parse(galaxy(star("Sol", - "\n" - + " true\n" - + " NOT_A_NUMBER\n" - + "\n"))); - fail("XMLPlanetLoader must reject non-numeric rainMarker (or be updated to " - + "normalize it — adjust this assertion when production adds the guard)"); - } catch (NumberFormatException expected) { - // OK — current behaviour: parser propagates the exception. - } + public void invalidWeatherMarkerSkipsPlanetInsteadOfCrashing() throws Exception { + // A non-numeric rainMarker makes Integer.parseInt throw deep inside + // readPlanetFromNode. Per-planet isolation (issue #77 fix) must catch + // that, skip the offending planet, and keep loading the rest — rather + // than the old behaviour of propagating up to a fatal exitJava. + DimensionPropertyCoupling coupling = parse(galaxy(star("Sol", + "\n" + + " true\n" + + " NOT_A_NUMBER\n" + + "\n"))); + assertTrue("a planet with a non-numeric rainMarker must be skipped, not crash", + coupling.dims.isEmpty()); } // ---- Clamping ------------------------------------------------------------ @@ -331,6 +329,116 @@ public void gravityClampsBelowMin() throws Exception { props.getGravitationalMultiplier(), 1e-6); } + // ---- laser drill ores: tolerant ore-name resolution ---------------------- + + /** + * Regression for dercodeKoenig/AdvancedRocketry#77 — creating a world with a + * subset of mods crashed with {@code IndexOutOfBoundsException: Index 0 out of + * bounds for length 0} at the {@code } parse path. + * + *

{@link OreDictionary#doesOreNameExist} returns {@code true} for any ore + * name that has merely been reserved in the dictionary, even when no + * items are registered under it (the mod that would provide them isn't + * installed). The old code did {@code getOres(name).get(0)} on that empty + * list → crash that killed the server via {@code FMLCommonHandler.exitJava}. + * The parser must now skip the entry and keep loading.

+ */ + @Test + public void laserDrillOresReservedButEmptyOreNameDoesNotCrash() throws Exception { + String phantom = "arPhantomOreNoItems77"; + OreDictionary.getOreID(phantom); // reserve the name without registering items + assertTrue("precondition: name must be reserved in the dictionary", + OreDictionary.doesOreNameExist(phantom)); + assertTrue("precondition: no items registered under the name", + OreDictionary.getOres(phantom).isEmpty()); + + DimensionPropertyCoupling coupling = parse(galaxy(star("Sol", + "\n" + + " true\n" + + " " + phantom + "\n" + + "\n"))); + DimensionProperties props = coupling.dims.get(0); + assertTrue("unresolved ore name must be skipped, not added and not thrown on", + props.laserDrillOres.isEmpty()); + } + + /** + * Pins the trim + count handling on the {@code } path: + * whitespace around the ore name and the {@code ;count} suffix must be + * tolerated, and the resolved stack must be a {@code copy()} so writing its + * count back does not mutate the shared OreDictionary prototype. + */ + @Test + public void laserDrillOresTrimsWhitespaceParsesCountAndCopiesStack() throws Exception { + String oreName = "arTestDrillOreWithItem77"; + OreDictionary.registerOre(oreName, new ItemStack(Items.IRON_INGOT)); + + DimensionPropertyCoupling coupling = parse(galaxy(star("Sol", + "\n" + + " true\n" + + " " + oreName + " ; 5 \n" + + "\n"))); + DimensionProperties props = coupling.dims.get(0); + assertEquals("whitespace-padded ore name must resolve to exactly 1 entry", + 1, props.laserDrillOres.size()); + assertEquals("count must be parsed from the trimmed second field", + 5, props.laserDrillOres.get(0).getCount()); + assertEquals("copy() must protect the OreDictionary prototype from count mutation", + 1, OreDictionary.getOres(oreName).get(0).getCount()); + } + + // ---- fault tolerance: skip bad planet, crash loudly on broken file ------- + + /** + * Issue #77 broader fix (A) — a single malformed planet must not take down + * the whole config. One well-formed planet plus one with a non-numeric + * {@code rainMarker} (throws deep in {@code readPlanetFromNode}): the bad one + * is skipped, the good one survives, and the loader returns normally instead + * of killing the JVM via {@code FMLCommonHandler.exitJava} — the test + * returning at all proves no silent process exit happened. + */ + @Test + public void malformedPlanetIsSkippedAndOthersStillLoad() throws Exception { + DimensionPropertyCoupling coupling = parse(galaxy(star("Sol", + "\n" + + " true\n" + + "\n" + + "\n" + + " true\n" + + " NOT_A_NUMBER\n" + + "\n"))); + assertEquals("only the well-formed planet must survive", 1, coupling.dims.size()); + assertEquals("GoodWorld", coupling.dims.get(0).getName()); + } + + /** + * Issue #77 broader fix (C) — a completely unparseable planetDefs file is a + * genuinely fatal/structural error. It must throw so that Forge produces a + * normal crash report at server start, rather than the old silent + * {@code FMLCommonHandler.exitJava} that closed the window with no report. + * Catching a {@link RuntimeException} here (instead of the test JVM dying) + * is the testable proxy for "crashes with a report, doesn't exit silently". + */ + @Test + public void completelyMalformedXmlThrowsForCrashReportInsteadOfSilentExit() throws Exception { + File garbage = tempFolder.newFile("garbage-planetDefs.xml"); + Files.write(garbage.toPath(), + "this is not xml <<< &&& >>>".getBytes(StandardCharsets.UTF_8)); + + XMLPlanetLoader loader = new XMLPlanetLoader(); + try { + loader.loadPlanetsOrThrow(garbage); + fail("unparseable planetDefs XML must throw so Forge generates a crash " + + "report — it must not be swallowed or trigger a silent exitJava"); + } catch (RuntimeException expected) { + assertNotNull("fatal load failure must carry a diagnostic message", + expected.getMessage()); + assertTrue("the message should point at the planetDefs XML file: " + + expected.getMessage(), + expected.getMessage().contains("planetDefs XML")); + } + } + // ---- helpers ------------------------------------------------------------- private static DimensionProperties findByName(List list, String name) { From 71c15cfe8e51b9cb09b3d96771ea9e5f69ef4ac8 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Mon, 1 Jun 2026 21:06:41 +0200 Subject: [PATCH 16/47] test: pin per-planet oregen write/read round-trip (issue #73) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The oregen config persists only through the per-world planetDefs.xml round-trip (it is not written to per-dimension NBT), so this regression test guards writeXML -> readAllPlanets preserving — the bug kaduvill traced to 2019, already fixed in the 1.12 base. Also files TASK-45: clumpSize/chancePerChunk clamp to a floor of 1 (and empty falls back to the global default), making "disable this ore per planet" inexpressible — likely the real cause behind the #73 report of zeroed veins still generating ore. Co-Authored-By: Claude Opus 4.7 (1M context) --- .agent/tasks/README.md | 1 + ...gen-clumpsize-clamp-disables-impossible.md | 76 +++++++++++++++++++ .../test/integration/XMLPlanetLoaderTest.java | 60 +++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 .agent/tasks/TASK-45-oregen-clumpsize-clamp-disables-impossible.md diff --git a/.agent/tasks/README.md b/.agent/tasks/README.md index f0302a48c..1b6a60ec1 100644 --- a/.agent/tasks/README.md +++ b/.agent/tasks/README.md @@ -374,6 +374,7 @@ entry is an actionable TASK with a defined plan + acceptance. |---|---|---|---| | [TASK-15](TASK-15-visual-regression.md) | Visual regression infrastructure for Minecraft client | ❌ Not planned | Closed 2026-05-29 — speculative infra with no live trigger and high build cost. Original 4 promotion triggers retained in task file; re-open via a new TASK if any fires. | | [TASK-16](TASK-16-test-stability-flake-watch.md) | Test-stability flake watch — investigation deliverable. Three flake shapes root-caused; shape #3 mitigated in TASK-26 via kit retry; #1+#2 split into TASK-27; #4 (worldgen sampling) confirmed across 3 sightings, promoted to TASK-28 F7. | 🟡 Investigation complete | Investigation done 2026-05-23. | +| [TASK-45](TASK-45-oregen-clumpsize-clamp-disables-impossible.md) | `` `clumpSize`/`chancePerChunk` clamp to a floor of 1 (and empty `` falls through to the global pressure/temp default), so "disable this ore" is inexpressible per planet — likely the real cause behind #73's "zeroing veins still spawns ore". Analysis only; fix-vs-document undecided. | 🟡 Backlog — not started | Found 2026-06-01 alongside the #73 round-trip test. | ## Conscious non-goals diff --git a/.agent/tasks/TASK-45-oregen-clumpsize-clamp-disables-impossible.md b/.agent/tasks/TASK-45-oregen-clumpsize-clamp-disables-impossible.md new file mode 100644 index 000000000..2bbfca2ca --- /dev/null +++ b/.agent/tasks/TASK-45-oregen-clumpsize-clamp-disables-impossible.md @@ -0,0 +1,76 @@ +# TASK-45: `` clumpSize/chancePerChunk clamp to 1 makes "disable ore" impossible + +## Ticket + +- Source: discovered 2026-06-01 while adding the issue #73 oregen + write/read round-trip regression test + (`integration/XMLPlanetLoaderTest.oreGenPropertiesSurviveWriteReadRoundTrip`). + Issue dercodeKoenig/AdvancedRocketry#73 reporter expected setting vein + size / number of veins to **zero** to disable an ore on a planet. +- Status: 🟡 **Backlog — not started.** Analysis only; no production + change made yet. +- Created: 2026-06-01. + +## Context + +`XMLOreLoader.loadOre` (src/main/java/zmaster587/advancedRocketry/util/XMLOreLoader.java) +clamps two `` attributes to a **minimum of 1**: + +- `clumpSize` → `MathHelper.clamp(parseInt(...), 1, 0xFF)` (line ~105) +- `chancePerChunk` → `MathHelper.clamp(parseInt(...), 1, 0xFF)` (line ~121) + +Consequences for a player editing `planetDefs.xml`: + +1. Writing `clumpSize="0"` or `chancePerChunk="0"` to switch an ore + **off** silently becomes `1` — the ore still generates. This is very + likely the behaviour the #73 reporter hit ("set all vein sizes and + number of veins to zero ... continues to spawn them"). +2. There is also no way to disable an ore by supplying an **empty** + ``: `loadOre` returns `null` when it parses zero `` + entries, and `DimensionProperties.getOreGenProperties` then falls + back to the **global** pressure/temp default + (`OreGenProperties.getOresForPressure(...)`, DimensionProperties.java + ~414-416). So an empty/zeroed config does not suppress generation — + it re-enables the global default. + +Net: the only working way to "restrict" ores today is to list exactly +the ores you want per planet (a non-empty `` overrides the +global fallback). You cannot express "this ore: none" per-entry. + +## Why it matters + +The config surface implies per-ore tuning down to zero, but the floor +clamp + null-means-global fallback make "off" inexpressible. This is a +real usability/contract gap, not just impl trivia, because it produces +player-visible behaviour that contradicts the config. + +## Approach options (pick at implement time) + +1. **Allow 0 as "disabled" per entry.** Change the clamp floor to 0 for + `clumpSize`/`chancePerChunk`; have the geode/ore generator skip + entries with a 0 count/clump. Smallest change, but touches the + generation loop (`MapGenGeode` / ore feature) to honour 0. +2. **Sentinel empty-but-present `` = "no ore on this planet".** + Distinguish "no `` element" (use global default) from + "present but empty ``" (generate nothing). Requires + `readPlanetFromNode` to set a non-null empty `OreGenProperties` + instead of leaving `oreProperties` null, and `getOreGenProperties` + to return it rather than the global fallback. +3. **Document-only.** If the maintainer considers per-planet ore + restriction to be "list what you want" by design, document that + `clumpSize`/`chancePerChunk` floor at 1 and that empty `` + falls through to global — and close as a non-goal. + +## Dependencies + +- Independent. Does NOT block the #73 round-trip regression test + (already shipped + green) or the #76/#77 work. +- If implemented, add a coverage pin: an `` entry whose count is + meant to disable generates nothing (server-tier worldgen probe). + +## Notes + +- The 2019-origin "oregen doesn't stick to worldsave" bug (#73) is a + **separate** issue and is already fixed in the `1.12` base (kaduvill, + fully merged). This task is only about the *clamp/disable* semantics + surfaced alongside it. diff --git a/src/test/java/zmaster587/advancedRocketry/test/integration/XMLPlanetLoaderTest.java b/src/test/java/zmaster587/advancedRocketry/test/integration/XMLPlanetLoaderTest.java index 3b26fabdf..8f91e1ab7 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/integration/XMLPlanetLoaderTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/integration/XMLPlanetLoaderTest.java @@ -1,8 +1,11 @@ package zmaster587.advancedRocketry.test.integration; +import net.minecraft.block.Block; import net.minecraft.init.Items; import net.minecraft.item.ItemStack; import net.minecraftforge.oredict.OreDictionary; +import zmaster587.advancedRocketry.util.OreGenProperties; +import zmaster587.advancedRocketry.util.OreGenProperties.OreEntry; import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; @@ -439,6 +442,63 @@ public void completelyMalformedXmlThrowsForCrashReportInsteadOfSilentExit() thro } } + // ---- oregen persistence (issue #73) -------------------------------------- + + /** + * Issue #73 — per-planet {@code } must survive the planetDefs.xml + * write/read round-trip. AR persists a planet's ore generation config only + * through the per-world planetDefs.xml (it is NOT written to the per-dimension + * NBT), so {@code writeXML} → {@code readAllPlanets} losing the {@code } + * block is exactly the "oregen doesn't stick to the worldsave" bug kaduvill + * traced back to 2019. This pins the round-trip so it can't regress. + */ + @Test + public void oreGenPropertiesSurviveWriteReadRoundTrip() throws Exception { + Block ironOre = Block.getBlockFromName("minecraft:iron_ore"); + assertNotNull("precondition: minecraft:iron_ore must be registered", ironOre); + + zmaster587.advancedRocketry.api.dimension.solar.StellarBody star = + new zmaster587.advancedRocketry.api.dimension.solar.StellarBody(); + star.setId(7600); + star.setName("OreGenStar"); + star.setTemperature(120); + star.setSize(1.0f); + star.setBlackHole(false); + + DimensionProperties planet = new DimensionProperties(7601, "OreGenWorld"); + planet.setStar(star); + + OreGenProperties ore = new OreGenProperties(); + ore.addEntry(ironOre.getDefaultState(), 5, 60, 8, 20); + planet.oreProperties = ore; + + star.addPlanet(planet); + + String xml = XMLPlanetLoader.writeXML(new SingleStarGalaxyFixture(star)); + assertTrue("writeXML must emit the block", xml.contains("oreGen")); + assertTrue("writeXML must reference the ore block by registry name", + xml.contains("minecraft:iron_ore")); + + File out = tempFolder.newFile("oregen-planets.xml"); + Files.write(out.toPath(), xml.getBytes(StandardCharsets.UTF_8)); + + XMLPlanetLoader reader = new XMLPlanetLoader(); + assertTrue("loadFile must accept the written XML", reader.loadFile(out)); + DimensionPropertyCoupling restored = reader.readAllPlanets(); + + assertEquals(1, restored.dims.size()); + OreGenProperties restoredOre = restored.dims.get(0).oreProperties; + assertNotNull("oreProperties must round-trip, not be dropped", restoredOre); + assertEquals("exactly one ore entry must survive", 1, restoredOre.getOreEntries().size()); + + OreEntry entry = restoredOre.getOreEntries().get(0); + assertEquals("ore block must round-trip", ironOre, entry.getBlockState().getBlock()); + assertEquals("minHeight must round-trip", 5, entry.getMinHeight()); + assertEquals("maxHeight must round-trip", 60, entry.getMaxHeight()); + assertEquals("clumpSize must round-trip", 8, entry.getClumpSize()); + assertEquals("chancePerChunk must round-trip", 20, entry.getChancePerChunk()); + } + // ---- helpers ------------------------------------------------------------- private static DimensionProperties findByName(List list, String name) { From cac31155f7be8c0ea5e0a548494c1d0f8a067a70 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Mon, 1 Jun 2026 21:19:13 +0200 Subject: [PATCH 17/47] fix: guard the JEI gas-giant-refresh call so it loads without JEI (#76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PacketDimInfo.executeClient called ARPlugin.requestGasGiantRefresh() unconditionally. ARPlugin implements mezz.jei.api.IModPlugin, so touching it loads JEI classes and NoClassDefFoundErrors when JEI isn't installed — re-introducing issue #76 via the dimension-sync path (the startup path kaduvill already guarded in ClientProxy). Wrap the call in Loader.isModLoaded("jei") and drop the top-level ARPlugin import. Also files TASK-46: CompatibilityMgr is vestigial (all consumers gone or commented out); left in place pending a revive-vs-remove decision. Co-Authored-By: Claude Opus 4.7 (1M context) --- .agent/tasks/README.md | 1 + .../TASK-46-compatibilitymgr-vestigial.md | 54 +++++++++++++++++++ .../network/PacketDimInfo.java | 8 ++- 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 .agent/tasks/TASK-46-compatibilitymgr-vestigial.md diff --git a/.agent/tasks/README.md b/.agent/tasks/README.md index 1b6a60ec1..1dfbc473d 100644 --- a/.agent/tasks/README.md +++ b/.agent/tasks/README.md @@ -375,6 +375,7 @@ entry is an actionable TASK with a defined plan + acceptance. | [TASK-15](TASK-15-visual-regression.md) | Visual regression infrastructure for Minecraft client | ❌ Not planned | Closed 2026-05-29 — speculative infra with no live trigger and high build cost. Original 4 promotion triggers retained in task file; re-open via a new TASK if any fires. | | [TASK-16](TASK-16-test-stability-flake-watch.md) | Test-stability flake watch — investigation deliverable. Three flake shapes root-caused; shape #3 mitigated in TASK-26 via kit retry; #1+#2 split into TASK-27; #4 (worldgen sampling) confirmed across 3 sightings, promoted to TASK-28 F7. | 🟡 Investigation complete | Investigation done 2026-05-23. | | [TASK-45](TASK-45-oregen-clumpsize-clamp-disables-impossible.md) | `` `clumpSize`/`chancePerChunk` clamp to a floor of 1 (and empty `` falls through to the global pressure/temp default), so "disable this ore" is inexpressible per planet — likely the real cause behind #73's "zeroing veins still spawns ore". Analysis only; fix-vs-document undecided. | 🟡 Backlog — not started | Found 2026-06-01 alongside the #73 round-trip test. | +| [TASK-46](TASK-46-compatibilitymgr-vestigial.md) | `CompatibilityMgr` is vestigial — `compat` instance never read, mod-presence flags written-but-not-read / set-by-uncalled-method, `reloadRecipes()` commented out. Kept on purpose (may regain meaning); decide revive vs remove. | 🟡 Backlog — not started | Found 2026-06-01 during the #76 JEI-ref audit. | ## Conscious non-goals diff --git a/.agent/tasks/TASK-46-compatibilitymgr-vestigial.md b/.agent/tasks/TASK-46-compatibilitymgr-vestigial.md new file mode 100644 index 000000000..ec33d0884 --- /dev/null +++ b/.agent/tasks/TASK-46-compatibilitymgr-vestigial.md @@ -0,0 +1,54 @@ +# TASK-46: `CompatibilityMgr` is currently vestigial — decide revive vs remove + +## Ticket + +- Source: discovered 2026-06-01 while auditing `integration.jei` references + for the issue #76 JEI NoClassDefFoundError guard. +- Status: 🟡 **Backlog — not started.** Deliberately left in place; the + maintainer may want to give it meaning again rather than delete it. +- Created: 2026-06-01. + +## Context + +`integration/CompatibilityMgr.java` holds three static booleans plus a +recipe-reload hook, but every live consumer is gone or commented out: + +- `AdvancedRocketry.compat = new CompatibilityMgr()` (AdvancedRocketry.java:173) + — instance created, **never read** anywhere. +- `isSpongeInstalled` — written at `AdvancedRocketry.java:1145`, its only + read is commented out (`WorldProviderPlanet.java:232`). Written, never read. +- `gregtechLoaded` / `thermalExpansionLoaded` — set only inside + `getLoadedMods()`, which has **no callers**. Never set, never read. +- `getLoadedMods()` — uncalled. +- `reloadRecipes()` — entirely commented out (also the only reference to + `integration.jei.ARPlugin` left in the file — a dead import). + +So the class does nothing observable today. It was historically the +central "which integration mods are present" flag-holder + a JEI +recipe-reload hook. + +## Why keep it for now + +Maintainer call (2026-06-01): not certain it should be removed — the +mod-presence flags + a recipe-reload entry point may be given meaning +again (e.g. real GregTech / ThermalExpansion / Sponge branches, or a +working `/ar reloadrecipes`). Deleting now would just have to be +re-created later. + +## Options (decide later) + +1. **Revive** — wire `getLoadedMods()` into mod init, uncomment the + reads that need the flags, and restore `reloadRecipes()` behind a + `Loader.isModLoaded("jei")` guard (so it can't re-introduce the #76 + class-load crash). Then add coverage for the branches that read it. +2. **Remove** — delete `CompatibilityMgr`, the unused `compat` field, + the dead `import ...jei.ARPlugin`, and the orphaned `isSpongeInstalled` + write. Smallest footprint; loses the scaffolding. +3. **Leave as-is** — keep as a documented placeholder (current state). + +## Dependencies + +- Independent. Does NOT block the #76 guard (already shipped in + `PacketDimInfo`) or any other work. +- If revived, the recipe-reload path MUST stay behind a JEI-loaded guard + — see the #76 fix rationale (touching `ARPlugin` loads JEI classes). diff --git a/src/main/java/zmaster587/advancedRocketry/network/PacketDimInfo.java b/src/main/java/zmaster587/advancedRocketry/network/PacketDimInfo.java index da3738b58..9cf6a397a 100644 --- a/src/main/java/zmaster587/advancedRocketry/network/PacketDimInfo.java +++ b/src/main/java/zmaster587/advancedRocketry/network/PacketDimInfo.java @@ -9,7 +9,6 @@ import zmaster587.advancedRocketry.AdvancedRocketry; import zmaster587.advancedRocketry.dimension.DimensionManager; import zmaster587.advancedRocketry.dimension.DimensionProperties; -import zmaster587.advancedRocketry.integration.jei.ARPlugin; import zmaster587.advancedRocketry.util.SpawnListEntryNBT; import zmaster587.libVulpes.network.BasePacket; @@ -146,7 +145,12 @@ public void executeClient(EntityPlayer thePlayer) { DimensionManager.getInstance().registerDimNoUpdate(dimProperties, true); } } - ARPlugin.requestGasGiantRefresh(); + // Guard the JEI integration: touching ARPlugin (implements mezz.jei.api + // IModPlugin) loads JEI classes, which NoClassDefFoundErrors when JEI + // isn't installed. See issue #76. + if (net.minecraftforge.fml.common.Loader.isModLoaded("jei")) { + zmaster587.advancedRocketry.integration.jei.ARPlugin.requestGasGiantRefresh(); + } } @Override From e9f59fb430a583ddc7e11b6ab52ddf1dc7e70fda Mon Sep 17 00:00:00 2001 From: StannisMod Date: Tue, 2 Jun 2026 08:52:18 +0200 Subject: [PATCH 18/47] docs: file TASK-47 (per-dim time + planet beds, #66) and TASK-48 (per-dim WorldInfo delegation) TASK-47 captures the converged design for issue #66: derived WorldInfo swallows the sleep time-skip and vanilla's 24000-rounding misses planetary dawn. Fix = per-dim time owned by the custom WorldInfo plus a WorldServer sleep-site mixin rounding to rotationalPeriod. TASK-48 spins off the research into a feature request to make other overworld-delegated WorldInfo state (GameRules, spawn, difficulty, terrain type, game type) per-dimension. Co-Authored-By: Claude Opus 4.7 (1M context) --- .agent/tasks/README.md | 2 + .../tasks/TASK-47-per-dim-time-and-sleep.md | 137 ++++++++++++++++++ .../TASK-48-per-dim-worldinfo-delegation.md | 78 ++++++++++ 3 files changed, 217 insertions(+) create mode 100644 .agent/tasks/TASK-47-per-dim-time-and-sleep.md create mode 100644 .agent/tasks/TASK-48-per-dim-worldinfo-delegation.md diff --git a/.agent/tasks/README.md b/.agent/tasks/README.md index 1dfbc473d..47b33dcf0 100644 --- a/.agent/tasks/README.md +++ b/.agent/tasks/README.md @@ -376,6 +376,8 @@ entry is an actionable TASK with a defined plan + acceptance. | [TASK-16](TASK-16-test-stability-flake-watch.md) | Test-stability flake watch — investigation deliverable. Three flake shapes root-caused; shape #3 mitigated in TASK-26 via kit retry; #1+#2 split into TASK-27; #4 (worldgen sampling) confirmed across 3 sightings, promoted to TASK-28 F7. | 🟡 Investigation complete | Investigation done 2026-05-23. | | [TASK-45](TASK-45-oregen-clumpsize-clamp-disables-impossible.md) | `` `clumpSize`/`chancePerChunk` clamp to a floor of 1 (and empty `` falls through to the global pressure/temp default), so "disable this ore" is inexpressible per planet — likely the real cause behind #73's "zeroing veins still spawns ore". Analysis only; fix-vs-document undecided. | 🟡 Backlog — not started | Found 2026-06-01 alongside the #73 round-trip test. | | [TASK-46](TASK-46-compatibilitymgr-vestigial.md) | `CompatibilityMgr` is vestigial — `compat` instance never read, mod-presence flags written-but-not-read / set-by-uncalled-method, `reloadRecipes()` commented out. Kept on purpose (may regain meaning); decide revive vs remove. | 🟡 Backlog — not started | Found 2026-06-01 during the #76 JEI-ref audit. | +| [TASK-47](TASK-47-per-dim-time-and-sleep.md) | Beds skip no time on planets (#66). Root cause: derived `WorldInfo.setWorldTime` is a no-op so the sleep skip is swallowed; `rotationalPeriod ≠ 24000` means vanilla's 24000-rounding misses planetary dawn. Fix: per-dim time owned by the custom WorldInfo (rename → `ARDimensionWorldInfo`) + a `WorldServer` sleep-site mixin rounding to `rotationalPeriod`. Design locked. | 🟡 Planned — not started | Design converged 2026-06-02. | +| [TASK-48](TASK-48-per-dim-worldinfo-delegation.md) | Make other `DerivedWorldInfo` state per-dimension that vanilla forces to the overworld: GameRules (sharpest — `doDaylightCycle`/`doWeatherCycle` still shared after TASK-47), spawn point, difficulty, terrain type, game type. Weather (shipped) + time (TASK-47) are the precedent. | 🟦 Feature request — not urgent, needs design | Research spun off TASK-47 on 2026-06-02. | ## Conscious non-goals diff --git a/.agent/tasks/TASK-47-per-dim-time-and-sleep.md b/.agent/tasks/TASK-47-per-dim-time-and-sleep.md new file mode 100644 index 000000000..6cae3b93f --- /dev/null +++ b/.agent/tasks/TASK-47-per-dim-time-and-sleep.md @@ -0,0 +1,137 @@ +# TASK-47: Per-dimension time + working beds on planets (issue #66) + +## Ticket + +- Source: dercodeKoenig/AdvancedRocketry#66 ("Beds do not work on planets + with modified day-night cycle") — sleeping on an AR planet skips no time. +- Status: 🟡 **Planned — not started.** Design converged 2026-06-02; code + not yet written. +- Created: 2026-06-02. + +## Root cause (confirmed against decompiled MC 1.12.2) + +- `WorldServer.tick()` performs the sleep skip as + `long i = getWorldTime() + 24000L; setWorldTime(i - i % 24000L)` + (lines 196-204), then `wakeAllPlayers()`. +- AR planets are `WorldServerMulti` whose `worldInfo` is a + `DerivedWorldInfo` (or our `ARWeatherWorldInfo`). **`DerivedWorldInfo.setWorldTime` + is an empty no-op** (lines 187-189), and `ARWeatherWorldInfo` does not + override it while `getWorldTime` delegates to the overworld. So derived + worlds do not own the clock — the sleep skip is silently swallowed and + **time never advances** → exactly the reported "no time is skipped". +- Secondary: planets render day/night from `rotationalPeriod` + (`WorldProviderPlanet.calculateCelestialAngle`), and + `rotationalPeriod = (1/gravitationalMultiplier)^3 * 24000` + (`DimensionManager:340`) ≠ 24000 for almost every planet. So even when + time does advance, vanilla's 24000-rounding does not land on the planet's + dawn (`worldTime % rotationalPeriod == 0`). + +This is why the reporter could only repro with a modified day-night cycle, +and why removing SleepingOverhaul (which has its own bed path) exposed the +vanilla path where AR's gap lives. + +## Design — per-dimension time, in the spirit of async weather + +Each dimension owns its own clock and sleeps independently; nothing is +pushed into the overworld. Vanilla already supports this end-to-end: +`areAllPlayersAsleep()` is per-world (WorldServer:318, requires **all** +non-spectator players in that dim — note: the "percentage asleep" rule is +1.13+, not 1.12.2), and `MinecraftServer:821` sends `SPacketTimeUpdate` +**per dimension** every 20 ticks using that world's `getWorldTime()`. So a +per-dim clock renders and syncs correctly with no extra plumbing. + +Two clean concerns on two layers: + +1. **Per-dim time OWNERSHIP — in the custom WorldInfo.** + `ARWeatherWorldInfo` (rename to `ARDimensionWorldInfo` — it is no longer + weather-only) becomes the faithful owner of `worldTime` and + `worldTotalTime`: + - `getWorldTime`/`setWorldTime`/`getWorldTotalTime`/`setWorldTotalTime` + read/write per-dim state in `PlanetWeatherState`/`PlanetWeatherSavedData` + (mirror the existing weather fields, incl. NBT). + - **No business logic in the setters** — `setWorldTime(long)` just stores + the value. (We explicitly rejected detecting "is this a sleep skip" + inside `setWorldTime`: that violates the method contract.) + - The planet's own `WorldServer.tick` `+1` increment now advances its own + clock; the sleep skip now actually writes per-dim time. + - **Seed** the per-dim time from the delegate's current `getWorldTime()` + on first wrap so existing saves don't visibly jump. + +2. **Dawn rounding — at the sleep site, via a mixin.** + The "24000" assumption and the knowledge that "this is a sleep skip" live + in `WorldServer.tick`. New `MixinWorldServer` with an `@Redirect` on the + `setWorldTime` invoke inside the sleep block: for `IPlanetaryProvider` + dims, round to the dim's `rotationalPeriod` instead of 24000: + `cur = getWorldTime(); next = cur + rp; setWorldTime(next - next % rp)` + (→ `worldTime % rp == 0` = planetary dawn). Non-AR worlds keep vanilla + behaviour. This is unambiguous (one call-site), so `/time` and the `+1` + increment flow through untouched and are stored exactly. + +### Wrapper installation (decoupled from weather) +Install the custom WorldInfo on **all** AR planets, independent of +`enableCustomPlanetWeather` (gate only the *weather* behaviour by that +config internally). Otherwise per-dim time / working beds would require +custom weather to be on. Touch `PlanetWeatherManager.shouldWrap` / +`wrapWorldInfoIfNeeded` (pass `dimId` into the ctor, currently line 168). + +## Files to touch + +- `world/weather/PlanetWeatherState.java` — add `worldTime` + `worldTotalTime` + (long) fields, getters/setters, NBT read/write. +- `world/weather/ARWeatherWorldInfo.java` → rename `ARDimensionWorldInfo`; + faithful per-dim time accessors; `dimId` ctor param; seed-from-delegate; + static `computeSleepWakeTime(long current, int rotationalPeriod)` helper + (pure, for unit tests). +- `world/weather/PlanetWeatherManager.java` — pass `dimId`; decouple + `shouldWrap` from `enableCustomPlanetWeather`. +- `mixin/MixinWorldServer.java` (new) + `mixins.advancedrocketry.json` — + `@Redirect` sleep-block `setWorldTime`, round to `rotationalPeriod` for AR + dims. Refmap-in-dev already handled (`mixin.env.disableRefMap=true`, + ledger #6). +- Rename references across the codebase + tests. + +## Test plan (sleep AND weather) + +- **unit**: `computeSleepWakeTime` (rp=24000 ≡ vanilla; rp=13888/46875/128000 + → `result % rp == 0`, `> current`, jump `< 2*rp`; already-at-dawn case); + `PlanetWeatherState` worldTime/totalTime NBT round-trip. +- **integration** (`ARWeatherWorldInfoTest`, rename + invert): `getWorldTime` + now returns per-dim state (was: delegate); `setWorldTime(+1)` advances + per-dim, does not touch delegate; first wrap seeds from delegate; weather + delegation unchanged (regression guard). +- **server** (testServer): independence — sleep/skip on AR dim A does not + change dim B or overworld, and overworld sleep does not change planets; + dawn rounding lands `worldTime % rp == 0`; per-dim time survives reload. + Needs probe verbs (read per-dim worldTime; drive the sleep-skip path). +- Existing weather suites (`PerDimensionWeatherIsolationTest`, + `WeatherBaselineTest`, `WeatherPersistenceTest`) must stay green. + +## Decisions locked (2026-06-02) + +1. Install wrapper on all AR planets, decoupled from the weather toggle. ✅ +2. `worldTotalTime` is also per-dim (not just `worldTime`). ✅ +3. Seed per-dim time from current shared time on first wrap. ✅ +4. Rename `ARWeatherWorldInfo` → `ARDimensionWorldInfo`. ✅ +5. Dawn rounding lives in a `WorldServer` sleep-site mixin, NOT in + `setWorldTime` (keep the setter contract clean). ✅ + +## Out of scope / follow-up + +- **Per-dim GameRules.** Both the `+1` increment and the sleep skip are still + gated by `doDaylightCycle` read from the **shared** overworld GameRules + (`getGameRulesInstance` delegates). So `/gamerule doDaylightCycle false` + freezes every planet. Truly independent day/night/weather needs per-dim + GameRules — a separate, larger task. See research note below. +- Per-dim **spawn point** and **difficulty** are likewise delegated to the + overworld by `DerivedWorldInfo` (spawn setters are no-ops). Candidates for + the same per-dim treatment; not in this task. +- Optional "percentage of players asleep" rule (1.13+ style) — a feature, not + part of this fix. + +## Research note — what else vanilla delegates to overworld but is ideologically per-dim + +From a full read of `DerivedWorldInfo`: GameRules (sharpest — couples to this +fix), spawn point, difficulty, terrain type (`WorldProviderPlanet.init` even +calls `setTerrainType` which is a no-op on the derived info), and game type. +Weather is the precedent AR already fixed via the custom WorldInfo; time is +this task; the rest are deliberately deferred. diff --git a/.agent/tasks/TASK-48-per-dim-worldinfo-delegation.md b/.agent/tasks/TASK-48-per-dim-worldinfo-delegation.md new file mode 100644 index 000000000..e05e18949 --- /dev/null +++ b/.agent/tasks/TASK-48-per-dim-worldinfo-delegation.md @@ -0,0 +1,78 @@ +# TASK-48: Per-dimension WorldInfo state vanilla delegates to overworld (feature request) + +## Ticket + +- Source: research spun off from [[TASK-47]] (per-dim time / beds, #66) on + 2026-06-02 — "what else is ideologically per-world but vanilla shares with + the overworld?". +- Status: 🟦 **Feature request — not urgent. Needs additional design work.** + No implementation planned yet; this is a scoping/research document. +- Created: 2026-06-02. + +## Context + +AR planets are `WorldServerMulti` whose `worldInfo` is a `DerivedWorldInfo`: +every getter delegates to the overworld's `WorldInfo` and every setter is a +no-op. AR has already overridden two slices of this in its custom WorldInfo +(`ARWeatherWorldInfo` → `ARDimensionWorldInfo` after TASK-47): **weather** +(shipped) and **time** (TASK-47). This task catalogues the *remaining* +state that is conceptually per-dimension but is currently forced to the +overworld value, as candidates for the same per-dim treatment. + +This is the natural continuation of the "each planet is its own world" +direction, but each item carries real design questions (persistence, +client sync, command semantics, save migration, mod-compat) — hence +"needs design work", not a ready-to-build plan. + +## Candidates (from a full read of `DerivedWorldInfo`) + +1. **GameRules** (`getGameRulesInstance` → delegate). **Highest value, sharpest + coupling.** Both the time `+1` increment and the sleep skip are gated by + `doDaylightCycle` read from the *shared* overworld GameRules + (`WorldServer.tick:198`), so even after TASK-47 `/gamerule doDaylightCycle + false` freezes every planet. Per-dim `doDaylightCycle`, `doWeatherCycle`, + `keepInventory`, `doMobSpawning`, `mobGriefing`, etc. would make planets + truly independent. Design questions: per-dim GameRules storage + a + command surface to set them per-dim; how to inherit defaults from + overworld; client never reads server GameRules so no sync issue, but the + `/gamerule` command targets the sender's world — needs a per-world + GameRules instance to exist first. +2. **Spawn point** (`getSpawnX/Y/Z`, `setSpawn` — setters are no-ops). Each + dim could have its own world spawn; today compasses and `setSpawn` on a + planet resolve to / are lost against the overworld. AR already has its own + respawn-dimension logic (`WorldProviderPlanet.getRespawnDimension`), so + this overlaps and must be reconciled. +3. **Difficulty** (`getDifficulty`/`isDifficultyLocked`, setters no-op). A + "hard planet" is impossible today. Per-dim difficulty affects mob spawning + / damage. Design question: command + persistence + how it interacts with + the server-global difficulty and peaceful-mode mob purging. +4. **Terrain type** (`getTerrainType`/`setTerrainType` — setter no-op). + Note: `WorldProviderPlanet.init` already calls + `world.getWorldInfo().setTerrainType(planetWorldType)`, which is silently + swallowed by the derived info; `getTerrainType` returns the overworld + type. Low-impact but a concrete example of a lost per-dim setter. +5. **Game type / gamemode** (`getGameType`). Per-dim default gamemode + (e.g. an adventure planet). Niche. + +## Precedent + +Weather (shipped) and time ([[TASK-47]]) are the proof that the +custom-WorldInfo + per-dim saved-data pattern works end-to-end (server tick, +NBT persistence, and vanilla's per-dimension `SPacketTimeUpdate` / +weather-sync). Any item here would follow the same shape. + +## Why not now + +- Each item needs its own design pass (persistence schema, command surface, + client sync where relevant, save migration, mod-compat with anything that + reads these off `WorldInfo`). +- None is required to close #66; TASK-47 is self-contained. +- GameRules in particular is a sizeable subsystem (per-world GameRules + instance + `/gamerule` routing) and should be its own task if promoted. + +## Suggested first step if promoted + +Spike per-dim GameRules (item 1) only, behind a config flag, starting with +`doDaylightCycle` + `doWeatherCycle` since they directly complete the +TASK-47 per-dim day/night story. Everything else stays delegated until a +concrete need appears. From 2727fd61ef3628aa0808fc0676058677443a00ce Mon Sep 17 00:00:00 2001 From: StannisMod Date: Tue, 2 Jun 2026 08:54:38 +0200 Subject: [PATCH 19/47] chore: update Navigator knowledge graph Co-Authored-By: Claude Opus 4.7 (1M context) --- .agent/knowledge/graph.json | 176 +++++++++++++++++++++++++++++++++--- 1 file changed, 165 insertions(+), 11 deletions(-) diff --git a/.agent/knowledge/graph.json b/.agent/knowledge/graph.json index be355556c..0af972eba 100644 --- a/.agent/knowledge/graph.json +++ b/.agent/knowledge/graph.json @@ -1,9 +1,9 @@ { "version": "1.0.0", - "last_updated": "2026-05-31T13:36:42.747342Z", + "last_updated": "2026-06-02T08:46:26.977051Z", "stats": { - "total_nodes": 116, - "total_edges": 400, + "total_nodes": 120, + "total_edges": 418, "memory_count": 8 }, "nodes": { @@ -590,6 +590,52 @@ "authentication", "tom" ] + }, + "TASK-45": { + "path": "/workspace/AdvancedRocketry/.claude/worktrees/from-1.12/.agent/tasks/TASK-45-oregen-clumpsize-clamp-disables-impossible.md", + "title": "`` clumpSize/chancePerChunk clamp to 1 makes \"disable ore\" impossible", + "status": "unknown", + "concepts": [ + "api", + "context", + "workflow", + "deployment", + "testing" + ] + }, + "TASK-46": { + "path": "/workspace/AdvancedRocketry/.claude/worktrees/from-1.12/.agent/tasks/TASK-46-compatibilitymgr-vestigial.md", + "title": "`CompatibilityMgr` is currently vestigial \u2014 decide revive vs remove", + "status": "unknown", + "concepts": [ + "deployment", + "context", + "skills", + "testing", + "api" + ] + }, + "TASK-47": { + "path": "/workspace/AdvancedRocketry/.claude/worktrees/from-1.12/.agent/tasks/TASK-47-per-dim-time-and-sleep.md", + "title": "Per-dimension time + working beds on planets (issue #66)", + "status": "unknown", + "concepts": [ + "tom", + "testing", + "api", + "deployment" + ] + }, + "TASK-48": { + "path": "/workspace/AdvancedRocketry/.claude/worktrees/from-1.12/.agent/tasks/TASK-48-per-dim-worldinfo-delegation.md", + "title": "Per-dimension WorldInfo state vanilla delegates to overworld (feature request)", + "status": "unknown", + "concepts": [ + "tom", + "deployment", + "database", + "context" + ] } }, "system": {}, @@ -3606,6 +3652,96 @@ "from": "TASK-44", "to": "tom", "type": "implements" + }, + { + "from": "TASK-45", + "to": "api", + "type": "implements" + }, + { + "from": "TASK-45", + "to": "context", + "type": "implements" + }, + { + "from": "TASK-45", + "to": "workflow", + "type": "implements" + }, + { + "from": "TASK-45", + "to": "deployment", + "type": "implements" + }, + { + "from": "TASK-45", + "to": "testing", + "type": "implements" + }, + { + "from": "TASK-46", + "to": "deployment", + "type": "implements" + }, + { + "from": "TASK-46", + "to": "context", + "type": "implements" + }, + { + "from": "TASK-46", + "to": "skills", + "type": "implements" + }, + { + "from": "TASK-46", + "to": "testing", + "type": "implements" + }, + { + "from": "TASK-46", + "to": "api", + "type": "implements" + }, + { + "from": "TASK-47", + "to": "tom", + "type": "implements" + }, + { + "from": "TASK-47", + "to": "testing", + "type": "implements" + }, + { + "from": "TASK-47", + "to": "api", + "type": "implements" + }, + { + "from": "TASK-47", + "to": "deployment", + "type": "implements" + }, + { + "from": "TASK-48", + "to": "tom", + "type": "implements" + }, + { + "from": "TASK-48", + "to": "deployment", + "type": "implements" + }, + { + "from": "TASK-48", + "to": "database", + "type": "implements" + }, + { + "from": "TASK-48", + "to": "context", + "type": "implements" } ], "concept_index": { @@ -3692,7 +3828,10 @@ "TASK-37", "TASK-38", "TASK-39", - "TASK-40" + "TASK-40", + "TASK-45", + "TASK-46", + "TASK-48" ], "knowledge": [ "TASK-09", @@ -3751,7 +3890,8 @@ "TASK-38", "TASK-39", "TASK-40", - "TASK-44" + "TASK-44", + "TASK-45" ], "testing": [ "TASK-09", @@ -3851,7 +3991,10 @@ "TASK-41", "TASK-42", "TASK-43", - "TASK-44" + "TASK-44", + "TASK-45", + "TASK-46", + "TASK-47" ], "backend": [ "TASK-09", @@ -3934,7 +4077,8 @@ "2026-05-14-1150_client-e2e-fg6-harness", "2026-05-20-2030_task04-terraformer-orbitallaser", "TASK-42", - "TASK-43" + "TASK-43", + "TASK-48" ], "markers": [ "TASK-09", @@ -4098,7 +4242,8 @@ "2026-05-15-1733_task01-session2-phase1-planet-depth", "2026-05-19-2030_multiblock-fixtures-bhg-beacon", "TASK-41", - "TASK-43" + "TASK-43", + "TASK-46" ], "api": [ "TASK-11", @@ -4132,7 +4277,10 @@ "TASK-40", "TASK-42", "TASK-43", - "TASK-44" + "TASK-44", + "TASK-45", + "TASK-46", + "TASK-47" ], "deployment": [ "TASK-03", @@ -4167,7 +4315,11 @@ "TASK-41", "TASK-42", "TASK-43", - "TASK-44" + "TASK-44", + "TASK-45", + "TASK-46", + "TASK-47", + "TASK-48" ], "frontend": [ "TASK-03", @@ -4251,7 +4403,9 @@ "TASK-41", "TASK-42", "TASK-43", - "TASK-44" + "TASK-44", + "TASK-47", + "TASK-48" ], "diagnosis": [ "mem-006" From d1eb47944e602e7405030aed2a2697ae88600613 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Tue, 2 Jun 2026 10:22:37 +0200 Subject: [PATCH 20/47] fix: per-dimension time so beds work on planets (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AR planets are WorldServerMulti whose WorldInfo is a DerivedWorldInfo — setWorldTime is a no-op there, so the vanilla sleep skip was silently swallowed and time never advanced ("beds do nothing"). And planets render day/night from rotationalPeriod (= (1/gravity)^3 * 24000, != 24000 for almost every planet), so vanilla's 24000-rounded wake misses planetary dawn. The custom WorldInfo (ARWeatherWorldInfo) now owns per-dimension worldTime and worldTotalTime in PlanetWeatherState (NBT-persisted, seeded from the delegate on first wrap), so each dimension's clock and sleep are independent of the overworld. The wrapper is now installed on all AR planets regardless of the custom-weather toggle (weather behaviour stays gated by config via weatherManaged). MixinWorldServer @Redirects the sleep-block setWorldTime to round to the dimension's rotationalPeriod (planetary dawn) for AR worlds. Tests: SleepWakeTimeTest (dawn-rounding math), PlanetWeatherState per-dim time NBT round-trip + seeding, ARWeatherWorldInfoTest rewritten for per-dim time ownership + unmanaged-weather delegation. Server weather isolation suites stay green (mixin applies; decoupling doesn't regress weather). TASK-47. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mixin/MixinWorldServer.java | 44 ++++++ .../world/weather/ARWeatherWorldInfo.java | 125 +++++++++++++----- .../world/weather/PlanetWeatherManager.java | 21 ++- .../world/weather/PlanetWeatherState.java | 52 ++++++++ .../resources/mixins.advancedrocketry.json | 1 + .../integration/ARWeatherWorldInfoTest.java | 80 ++++++++--- .../test/unit/PlanetWeatherStateTest.java | 38 ++++++ .../test/unit/SleepWakeTimeTest.java | 60 +++++++++ 8 files changed, 373 insertions(+), 48 deletions(-) create mode 100644 src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServer.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/unit/SleepWakeTimeTest.java diff --git a/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServer.java b/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServer.java new file mode 100644 index 000000000..03e477636 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServer.java @@ -0,0 +1,44 @@ +package zmaster587.advancedRocketry.mixin; + +import net.minecraft.world.WorldServer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; +import zmaster587.advancedRocketry.api.IPlanetaryProvider; +import zmaster587.advancedRocketry.world.weather.ARWeatherWorldInfo; + +/** + * Makes beds bring the planet's morning. Vanilla's sleep skip in + * {@link WorldServer#tick()} rounds the new time to the next multiple of + * 24000, but AR planets render day/night from {@code rotationalPeriod} + * (≈ {@code (1/gravity)^3 * 24000}, ≠ 24000 for almost every planet), so the + * vanilla rounding lands at an arbitrary phase — usually still night + * (issue #66 / TASK-47). + * + *

We {@code @Redirect} the FIRST {@code setWorldTime} call in {@code tick()} + * (ordinal 0 = the sleep-skip block; ordinal 1 is the per-tick +1 increment) + * and, for {@link IPlanetaryProvider} dimensions, round to the dimension's + * {@code rotationalPeriod} instead. The rounding math lives in + * {@link ARWeatherWorldInfo#computeSleepWakeTime(long, int)} so it is unit + * tested. Non-AR worlds keep vanilla behaviour untouched.

+ * + *

The per-dimension clock this writes into is owned by the + * {@link ARWeatherWorldInfo} wrapper (per-dim time, not the swallowed + * {@code DerivedWorldInfo} no-op), so the skip actually takes effect.

+ */ +@Mixin(WorldServer.class) +public abstract class MixinWorldServer { + + @Redirect(method = "tick", + at = @At(value = "INVOKE", + target = "Lnet/minecraft/world/WorldServer;setWorldTime(J)V", + ordinal = 0)) + private void ar$roundSleepWakeToRotationalPeriod(WorldServer self, long vanillaRounded) { + if (self.provider instanceof IPlanetaryProvider) { + int rotationalPeriod = ((IPlanetaryProvider) self.provider).getRotationalPeriod(null); + self.setWorldTime(ARWeatherWorldInfo.computeSleepWakeTime(self.getWorldTime(), rotationalPeriod)); + } else { + self.setWorldTime(vanillaRounded); + } + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/world/weather/ARWeatherWorldInfo.java b/src/main/java/zmaster587/advancedRocketry/world/weather/ARWeatherWorldInfo.java index 1584f89b4..c6645e6e3 100644 --- a/src/main/java/zmaster587/advancedRocketry/world/weather/ARWeatherWorldInfo.java +++ b/src/main/java/zmaster587/advancedRocketry/world/weather/ARWeatherWorldInfo.java @@ -37,8 +37,17 @@ public final class ARWeatherWorldInfo extends WorldInfo { private final WorldInfo delegate; private final PlanetWeatherState weatherState; private final Runnable dirtyMarker; - - public ARWeatherWorldInfo(WorldInfo delegate, PlanetWeatherState weatherState, Runnable dirtyMarker) { + /** + * When {@code true} weather is served from the per-dim {@link PlanetWeatherState} + * (custom planet weather); when {@code false} weather delegates to the + * underlying {@link WorldInfo} (vanilla shared behaviour). Time-of-day is + * always per-dim regardless of this flag — that is the sleep/day-night fix + * and must work even when custom weather is disabled. + */ + private final boolean weatherManaged; + + public ARWeatherWorldInfo(WorldInfo delegate, PlanetWeatherState weatherState, + Runnable dirtyMarker, boolean weatherManaged) { // Call the WorldInfo no-arg ctor — initialises the (never-read) // internal scaffolding (GameRules, dimensionData, customBossEvents) // to safe defaults. We deliberately do NOT seed from the delegate's @@ -51,6 +60,28 @@ public ARWeatherWorldInfo(WorldInfo delegate, PlanetWeatherState weatherState, R this.delegate = delegate; this.weatherState = weatherState; this.dirtyMarker = dirtyMarker; + this.weatherManaged = weatherManaged; + // Seed the per-dim clock from the delegate on first install so existing + // saves (which shared the overworld clock) don't visibly jump. + weatherState.seedTimeIfNeeded(delegate.getWorldTime(), delegate.getWorldTotalTime()); + } + + /** + * Rounds a sleep wake-up to the next planetary dawn for a world whose day is + * {@code rotationalPeriod} ticks long (vanilla hard-codes 24000). Result is + * the smallest multiple of {@code rotationalPeriod} strictly after + * {@code current}, i.e. {@code result % rotationalPeriod == 0} (dawn). + * + *

Used by {@code MixinWorldServer} at the sleep site so beds bring the + * planet's morning instead of vanilla's 24000-rounded (often still-night) + * time. See issue #66 / TASK-47.

+ */ + public static long computeSleepWakeTime(long current, int rotationalPeriod) { + if (rotationalPeriod <= 0) { + rotationalPeriod = 24000; + } + long next = current + rotationalPeriod; + return next - Math.floorMod(next, (long) rotationalPeriod); } /** Used by {@link PlanetWeatherManager#unwrap} to peel the wrapper off without losing state. */ @@ -62,56 +93,104 @@ public WorldInfo getDelegate() { @Override public int getCleanWeatherTime() { - return weatherState.getCleanWeatherTime(); + return weatherManaged ? weatherState.getCleanWeatherTime() : delegate.getCleanWeatherTime(); } @Override public void setCleanWeatherTime(int cleanWeatherTimeIn) { - weatherState.setCleanWeatherTime(cleanWeatherTimeIn); - dirtyMarker.run(); + if (weatherManaged) { + weatherState.setCleanWeatherTime(cleanWeatherTimeIn); + dirtyMarker.run(); + } else { + delegate.setCleanWeatherTime(cleanWeatherTimeIn); + } } @Override public boolean isRaining() { - return weatherState.isRaining(); + return weatherManaged ? weatherState.isRaining() : delegate.isRaining(); } @Override public void setRaining(boolean isRaining) { - weatherState.setRaining(isRaining); - dirtyMarker.run(); + if (weatherManaged) { + weatherState.setRaining(isRaining); + dirtyMarker.run(); + } else { + delegate.setRaining(isRaining); + } } @Override public int getRainTime() { - return weatherState.getRainTime(); + return weatherManaged ? weatherState.getRainTime() : delegate.getRainTime(); } @Override public void setRainTime(int time) { - weatherState.setRainTime(time); - dirtyMarker.run(); + if (weatherManaged) { + weatherState.setRainTime(time); + dirtyMarker.run(); + } else { + delegate.setRainTime(time); + } } @Override public boolean isThundering() { - return weatherState.isThundering(); + return weatherManaged ? weatherState.isThundering() : delegate.isThundering(); } @Override public void setThundering(boolean thunderingIn) { - weatherState.setThundering(thunderingIn); - dirtyMarker.run(); + if (weatherManaged) { + weatherState.setThundering(thunderingIn); + dirtyMarker.run(); + } else { + delegate.setThundering(thunderingIn); + } } @Override public int getThunderTime() { - return weatherState.getThunderTime(); + return weatherManaged ? weatherState.getThunderTime() : delegate.getThunderTime(); } @Override public void setThunderTime(int time) { - weatherState.setThunderTime(time); + if (weatherManaged) { + weatherState.setThunderTime(time); + dirtyMarker.run(); + } else { + delegate.setThunderTime(time); + } + } + + // ── Time-of-day + world age: per-dimension, always (not gated by weather) ─ + // + // Vanilla DerivedWorldInfo delegates these to the overworld and no-ops the + // setters, so AR planets shared the overworld clock and the sleep skip was + // swallowed (issue #66). We own them in PlanetWeatherState instead. + + @Override + public long getWorldTime() { + return weatherState.getWorldTime(); + } + + @Override + public void setWorldTime(long time) { + weatherState.setWorldTime(time); + dirtyMarker.run(); + } + + @Override + public long getWorldTotalTime() { + return weatherState.getWorldTotalTime(); + } + + @Override + public void setWorldTotalTime(long time) { + weatherState.setWorldTotalTime(time); dirtyMarker.run(); } @@ -147,16 +226,6 @@ public int getSpawnZ() { return delegate.getSpawnZ(); } - @Override - public long getWorldTotalTime() { - return delegate.getWorldTotalTime(); - } - - @Override - public long getWorldTime() { - return delegate.getWorldTime(); - } - @Override @SideOnly(Side.CLIENT) public long getSizeOnDisk() { @@ -199,10 +268,6 @@ public void setSpawnX(int x) { public void setSpawnY(int y) { } - @Override - public void setWorldTotalTime(long time) { - } - @Override @SideOnly(Side.CLIENT) public void setSpawnZ(int z) { diff --git a/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java b/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java index ec52a7204..27a2e58ee 100644 --- a/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java +++ b/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java @@ -120,7 +120,11 @@ public static void markDirty(WorldServer world) { */ public static boolean shouldWrap(WorldServer world) { ARConfiguration cfg = ARConfiguration.getCurrentConfig(); - if (cfg == null || !cfg.enableCustomPlanetWeather) return false; + // NOTE: deliberately NOT gated by enableCustomPlanetWeather. AR planets + // are wrapped regardless so per-dimension time / working beds (issue #66) + // always apply; whether the wrapper *manages weather* is decided + // separately by isWeatherManaged(). + if (cfg == null) return false; if (world == null || world.isRemote) return false; if (world.provider == null) return false; int dim = world.provider.getDimension(); @@ -143,6 +147,19 @@ public static boolean shouldWrap(WorldServer world) { && dim != cfg.spaceDimId; } + /** + * Whether the wrapper installed on {@code world} should serve weather from + * the per-dim {@link PlanetWeatherState} (vs delegating to vanilla). Time is + * always per-dim; only weather is gated by config. Kept separate from + * {@link #shouldWrap} so an AR planet can have per-dim time with vanilla + * (shared) weather when custom weather is disabled. + */ + public static boolean isWeatherManaged(WorldServer world) { + ARConfiguration cfg = ARConfiguration.getCurrentConfig(); + if (cfg == null) return false; + return cfg.enableCustomPlanetWeather || cfg.forcePlanetWeatherWorldInfoWrapper; + } + /** * Idempotent + safe. Installs (or refreshes) {@link ARWeatherWorldInfo} on * the given world. @@ -166,7 +183,7 @@ public static void wrapWorldInfoIfNeeded(WorldServer world) { PlanetWeatherState state = saved.getOrCreate(dim); WorldInfo current = world.getWorldInfo(); ARWeatherWorldInfo wrapped = new ARWeatherWorldInfo(current, state, - () -> markDirty(world)); + () -> markDirty(world), isWeatherManaged(world)); world.worldInfo = wrapped; diff --git a/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherState.java b/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherState.java index d6498dac4..725ce8bc7 100644 --- a/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherState.java +++ b/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherState.java @@ -19,6 +19,14 @@ public final class PlanetWeatherState { private boolean raining; private boolean thundering; + // Per-dimension time-of-day + world age. Vanilla derived worlds delegate + // these to the overworld (and their setters are no-ops), so every AR planet + // shared the overworld clock and the sleep skip was swallowed. Owning them + // here makes each dimension's day/night and sleep independent. + private long worldTime; + private long worldTotalTime; + private boolean timeInitialized; + private transient boolean lastSyncedRaining; private transient boolean lastSyncedThundering; @@ -65,6 +73,41 @@ public void setThundering(boolean value) { this.thundering = value; } + public long getWorldTime() { + return worldTime; + } + + public void setWorldTime(long value) { + this.worldTime = value; + this.timeInitialized = true; + } + + public long getWorldTotalTime() { + return worldTotalTime; + } + + public void setWorldTotalTime(long value) { + this.worldTotalTime = value; + this.timeInitialized = true; + } + + public boolean isTimeInitialized() { + return timeInitialized; + } + + /** + * Seed the per-dim clock from the delegate's current value the first time + * this dimension is wrapped, so existing saves don't visibly jump. No-op + * once the clock has been initialised (from a setter or NBT load). + */ + public void seedTimeIfNeeded(long worldTimeIn, long worldTotalTimeIn) { + if (!timeInitialized) { + this.worldTime = worldTimeIn; + this.worldTotalTime = worldTotalTimeIn; + this.timeInitialized = true; + } + } + public boolean wasLastSyncedRaining() { return lastSyncedRaining; } @@ -87,6 +130,11 @@ public void readFromNBT(NBTTagCompound nbt) { this.thunderTime = nbt.getInteger("thunderTime"); this.raining = nbt.getBoolean("raining"); this.thundering = nbt.getBoolean("thundering"); + if (nbt.hasKey("worldTime")) { + this.worldTime = nbt.getLong("worldTime"); + this.worldTotalTime = nbt.getLong("worldTotalTime"); + this.timeInitialized = true; + } } public void writeToNBT(NBTTagCompound nbt) { @@ -95,5 +143,9 @@ public void writeToNBT(NBTTagCompound nbt) { nbt.setInteger("thunderTime", thunderTime); nbt.setBoolean("raining", raining); nbt.setBoolean("thundering", thundering); + if (timeInitialized) { + nbt.setLong("worldTime", worldTime); + nbt.setLong("worldTotalTime", worldTotalTime); + } } } diff --git a/src/main/resources/mixins.advancedrocketry.json b/src/main/resources/mixins.advancedrocketry.json index 138357a86..f9c4ed730 100644 --- a/src/main/resources/mixins.advancedrocketry.json +++ b/src/main/resources/mixins.advancedrocketry.json @@ -9,6 +9,7 @@ "MixinEntityPlayerInventoryAccess", "MixinEntityPlayerMPInventoryAccess", "MixinPlayerList", + "MixinWorldServer", "MixinWorldServerMulti", "MixinWorldSetBlockState" ], diff --git a/src/test/java/zmaster587/advancedRocketry/test/integration/ARWeatherWorldInfoTest.java b/src/test/java/zmaster587/advancedRocketry/test/integration/ARWeatherWorldInfoTest.java index 15742637a..c467e0c52 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/integration/ARWeatherWorldInfoTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/integration/ARWeatherWorldInfoTest.java @@ -22,9 +22,9 @@ *
    *
  • (4) non-weather getters route to the delegate;
  • *
  • (5) weather setters route only to the state, not the delegate;
  • - *
  • (6) {@code getWorldTime} stays on the delegate (day/night must not - * diverge between planet and overworld in this iteration);
  • - *
  • (7) weather mutations fire the dirty callback.
  • + *
  • (6) time-of-day / world age are per-dimension (TASK-47): owned by the + * state, seeded from the delegate, independent of the overworld clock;
  • + *
  • (7) weather + per-dim time mutations fire the dirty callback.
  • *
* * Lives in the integration layer because constructing a vanilla {@link WorldInfo} @@ -50,7 +50,7 @@ private static WorldInfo seededDelegate() { } private static ARWeatherWorldInfo wrap(WorldInfo delegate, PlanetWeatherState state, Runnable dirty) { - return new ARWeatherWorldInfo(delegate, state, dirty); + return new ARWeatherWorldInfo(delegate, state, dirty, /* weatherManaged */ true); } @Test @@ -117,19 +117,68 @@ public void arWeatherWorldInfoOverridesOnlyWeatherFields() { } @Test - public void arWeatherWorldInfoDoesNotOverrideWorldTime() { + public void arWeatherWorldInfoServesPerDimTimeIndependentOfDelegate() { + WorldInfo delegate = seededDelegate(); // DayTime=17000, Time=17000 + PlanetWeatherState state = new PlanetWeatherState(); + ARWeatherWorldInfo wrapper = wrap(delegate, state, () -> {}); + + // (TASK-47) Per-dim time is seeded from the delegate on construction so + // existing saves don't jump... + assertEquals("per-dim worldTime seeded from delegate", 17000L, wrapper.getWorldTime()); + assertEquals("per-dim worldTotalTime seeded from delegate", 17000L, wrapper.getWorldTotalTime()); + + // ...but then it is OWNED by the state, not delegated. + wrapper.setWorldTime(50_000L); + wrapper.setWorldTotalTime(60_000L); + assertEquals(50_000L, wrapper.getWorldTime()); + assertEquals(60_000L, wrapper.getWorldTotalTime()); + assertEquals("state holds per-dim worldTime", 50_000L, state.getWorldTime()); + assertEquals("state holds per-dim worldTotalTime", 60_000L, state.getWorldTotalTime()); + + // The overworld (delegate) clock advancing must NOT leak into the planet. + delegate.setWorldTime(99_000L); + delegate.setWorldTotalTime(99_000L); + assertEquals("planet worldTime independent of overworld", 50_000L, wrapper.getWorldTime()); + assertEquals("planet worldTotalTime independent of overworld", 60_000L, wrapper.getWorldTotalTime()); + } + + @Test + public void perDimTimeSettersMarkDirty() { WorldInfo delegate = seededDelegate(); - ARWeatherWorldInfo wrapper = wrap(delegate, new PlanetWeatherState(), () -> {}); + AtomicInteger dirtyHits = new AtomicInteger(); + ARWeatherWorldInfo wrapper = wrap(delegate, new PlanetWeatherState(), dirtyHits::incrementAndGet); + + wrapper.setWorldTime(1L); + wrapper.setWorldTotalTime(1L); + + assertEquals("per-dim time setters must mark the saved-data dirty", 2, dirtyHits.get()); + } + + @Test + public void unmanagedWeatherDelegatesToVanilla() { + // When custom weather is disabled the wrapper is still installed (for + // per-dim time) but weather must pass through to the delegate, matching + // vanilla shared-weather behaviour. + WorldInfo delegate = seededDelegate(); + PlanetWeatherState state = new PlanetWeatherState(); + ARWeatherWorldInfo wrapper = + new ARWeatherWorldInfo(delegate, state, () -> {}, /* weatherManaged */ false); + + delegate.setRaining(true); + delegate.setRainTime(555); + state.setRaining(false); + state.setRainTime(111); + + assertTrue("unmanaged weather reads the delegate", wrapper.isRaining()); + assertEquals(555, wrapper.getRainTime()); - // Day/night currently must NOT diverge between planet and overworld - // (SMART §10) — getWorldTime / getWorldTotalTime stay on delegate. - assertEquals("worldTime stays on delegate", delegate.getWorldTime(), wrapper.getWorldTime()); - assertEquals("worldTotalTime stays on delegate", - delegate.getWorldTotalTime(), wrapper.getWorldTotalTime()); + wrapper.setRainTime(777); + assertEquals("unmanaged weather writes the delegate", 777, delegate.getRainTime()); + assertEquals("per-dim weather state untouched when unmanaged", 111, state.getRainTime()); - delegate.setWorldTotalTime(50_000L); - assertEquals("delegate worldTotalTime change visible through wrapper", - 50_000L, wrapper.getWorldTotalTime()); + // Time is per-dim even when weather is unmanaged. + wrapper.setWorldTime(40_000L); + assertEquals(40_000L, state.getWorldTime()); } @Test @@ -159,9 +208,8 @@ public void arWeatherWorldInfoDoesNotFireDirtyOnNonWeatherCalls() { wrapper.setWorldName("ignored"); wrapper.setSaveVersion(7); - wrapper.setWorldTotalTime(100L); - assertEquals("non-weather mutations must NOT mark weather saved-data dirty", + assertEquals("non-weather, non-time mutations must NOT mark saved-data dirty", 0, dirtyHits.get()); } diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetWeatherStateTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetWeatherStateTest.java index fecb4fc66..01b8c082c 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetWeatherStateTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/PlanetWeatherStateTest.java @@ -69,6 +69,44 @@ public void planetWeatherStateNbtRoundTripPreservesClearWeather() { assertEquals(20000, round.getCleanWeatherTime()); } + @Test + public void perDimTimeNbtRoundTrip() { + // TASK-47: per-dim worldTime/worldTotalTime persist independently. + PlanetWeatherState source = new PlanetWeatherState(); + source.setWorldTime(123_456L); + source.setWorldTotalTime(789_012L); + + NBTTagCompound tag = new NBTTagCompound(); + source.writeToNBT(tag); + + PlanetWeatherState round = new PlanetWeatherState(); + round.readFromNBT(tag); + + assertEquals(123_456L, round.getWorldTime()); + assertEquals(789_012L, round.getWorldTotalTime()); + assertEquals("time flagged initialised after load", true, round.isTimeInitialized()); + } + + @Test + public void uninitialisedTimeIsNotPersistedAndSeedingApplies() { + // A fresh state has no time keys, so seedTimeIfNeeded must take effect; + // once seeded it is sticky (a second seed is ignored). + PlanetWeatherState fresh = new PlanetWeatherState(); + assertFalse("fresh state has no initialised time", fresh.isTimeInitialized()); + + NBTTagCompound tag = new NBTTagCompound(); + fresh.writeToNBT(tag); + assertFalse("uninitialised time must not be written to NBT", tag.hasKey("worldTime")); + + fresh.seedTimeIfNeeded(1000L, 2000L); + assertEquals(1000L, fresh.getWorldTime()); + assertEquals(2000L, fresh.getWorldTotalTime()); + + // Second seed is a no-op (clock already owned). + fresh.seedTimeIfNeeded(9999L, 9999L); + assertEquals("seed is sticky", 1000L, fresh.getWorldTime()); + } + @Test public void lastSyncedFlagsAreSettable() { // lastSynced* are transient (not in NBT) and only used by the manager diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/SleepWakeTimeTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/SleepWakeTimeTest.java new file mode 100644 index 000000000..2e0bbb912 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/SleepWakeTimeTest.java @@ -0,0 +1,60 @@ +package zmaster587.advancedRocketry.test.unit; + +import org.junit.Test; +import zmaster587.advancedRocketry.world.weather.ARWeatherWorldInfo; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * TASK-47 / issue #66 — pure-math unit pins for + * {@link ARWeatherWorldInfo#computeSleepWakeTime(long, int)}: the sleep wake-up + * must land on the planet's dawn (a multiple of {@code rotationalPeriod}), + * strictly forward, by at most one planetary day. + */ +public class SleepWakeTimeTest { + + private static void assertDawnInvariants(long current, int rp) { + long wake = ARWeatherWorldInfo.computeSleepWakeTime(current, rp); + assertEquals("wake must land on a multiple of rotationalPeriod (dawn) for rp=" + rp + + ", current=" + current, 0L, Math.floorMod(wake, (long) rp)); + assertTrue("wake must move strictly forward (current=" + current + ", wake=" + wake + ")", + wake > current); + assertTrue("wake must skip less than a full extra day (current=" + current + + ", wake=" + wake + ", rp=" + rp + ")", wake - current <= rp); + } + + @Test + public void rp24000MatchesVanillaRounding() { + // With a 24000 day, the helper must reproduce vanilla's i - i%24000. + for (long t : new long[]{0L, 1L, 12345L, 23999L, 24000L, 50000L}) { + long vanilla = (t + 24000L) - (t + 24000L) % 24000L; + assertEquals("rp=24000 must equal vanilla rounding at t=" + t, + vanilla, ARWeatherWorldInfo.computeSleepWakeTime(t, 24000)); + } + } + + @Test + public void nonVanillaPeriodsLandOnDawn() { + for (int rp : new int[]{13888, 46875, 128000, 1, 7777}) { + for (long t : new long[]{0L, 1L, 5000L, 99999L, 1_000_000L}) { + assertDawnInvariants(t, rp); + } + } + } + + @Test + public void alreadyAtDawnSkipsToNextDay() { + // current exactly on a dawn boundary → advance a full day, not stay put. + int rp = 13888; + long wake = ARWeatherWorldInfo.computeSleepWakeTime(2L * rp, rp); + assertEquals(3L * rp, wake); + } + + @Test + public void nonPositivePeriodFallsBackTo24000() { + // Defensive: a bad rotationalPeriod must not divide-by-zero or loop. + assertEquals(24000L, ARWeatherWorldInfo.computeSleepWakeTime(0L, 0)); + assertEquals(24000L, ARWeatherWorldInfo.computeSleepWakeTime(0L, -5)); + } +} From 4005f7c37e6e64d7482124a95cdcaf1a93d961b0 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Tue, 2 Jun 2026 10:29:35 +0200 Subject: [PATCH 21/47] refactor: rename ARWeatherWorldInfo -> ARDimensionWorldInfo The custom WorldInfo now owns per-dimension time as well as weather (TASK-47), so the weather-only name no longer fits. Pure mechanical rename of the class, its test, and all references (types + string-literal class-name assertions in the server/client wiring tests + probe comments). Marks TASK-47 shipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- .agent/tasks/README.md | 2 +- .../tasks/TASK-47-per-dim-time-and-sleep.md | 9 ++++-- .../command/test/TestProbeCommand.java | 4 +-- .../mixin/MixinWorldServer.java | 8 +++--- .../mixin/MixinWorldServerMulti.java | 2 +- .../world/provider/WorldProviderPlanet.java | 6 ++-- ...rldInfo.java => ARDimensionWorldInfo.java} | 4 +-- .../world/weather/PlanetWeatherManager.java | 12 ++++---- .../world/weather/PlanetWeatherState.java | 2 +- .../test/client/WeatherClientSyncE2ETest.java | 12 ++++---- ...est.java => ARDimensionWorldInfoTest.java} | 28 +++++++++---------- .../test/server/EventHandlerWiringTest.java | 6 ++-- .../server/NonARDimensionIsolationTest.java | 8 +++--- .../PerDimensionWeatherIsolationTest.java | 8 +++--- .../server/PlayerEventHandlerWiringTest.java | 10 +++---- .../test/server/WeatherBaselineTest.java | 6 ++-- .../test/server/WeatherPersistenceTest.java | 6 ++-- .../test/unit/SleepWakeTimeTest.java | 14 +++++----- 18 files changed, 76 insertions(+), 71 deletions(-) rename src/main/java/zmaster587/advancedRocketry/world/weather/{ARWeatherWorldInfo.java => ARDimensionWorldInfo.java} (98%) rename src/test/java/zmaster587/advancedRocketry/test/integration/{ARWeatherWorldInfoTest.java => ARDimensionWorldInfoTest.java} (88%) diff --git a/.agent/tasks/README.md b/.agent/tasks/README.md index 47b33dcf0..1673f3ae6 100644 --- a/.agent/tasks/README.md +++ b/.agent/tasks/README.md @@ -376,7 +376,7 @@ entry is an actionable TASK with a defined plan + acceptance. | [TASK-16](TASK-16-test-stability-flake-watch.md) | Test-stability flake watch — investigation deliverable. Three flake shapes root-caused; shape #3 mitigated in TASK-26 via kit retry; #1+#2 split into TASK-27; #4 (worldgen sampling) confirmed across 3 sightings, promoted to TASK-28 F7. | 🟡 Investigation complete | Investigation done 2026-05-23. | | [TASK-45](TASK-45-oregen-clumpsize-clamp-disables-impossible.md) | `` `clumpSize`/`chancePerChunk` clamp to a floor of 1 (and empty `` falls through to the global pressure/temp default), so "disable this ore" is inexpressible per planet — likely the real cause behind #73's "zeroing veins still spawns ore". Analysis only; fix-vs-document undecided. | 🟡 Backlog — not started | Found 2026-06-01 alongside the #73 round-trip test. | | [TASK-46](TASK-46-compatibilitymgr-vestigial.md) | `CompatibilityMgr` is vestigial — `compat` instance never read, mod-presence flags written-but-not-read / set-by-uncalled-method, `reloadRecipes()` commented out. Kept on purpose (may regain meaning); decide revive vs remove. | 🟡 Backlog — not started | Found 2026-06-01 during the #76 JEI-ref audit. | -| [TASK-47](TASK-47-per-dim-time-and-sleep.md) | Beds skip no time on planets (#66). Root cause: derived `WorldInfo.setWorldTime` is a no-op so the sleep skip is swallowed; `rotationalPeriod ≠ 24000` means vanilla's 24000-rounding misses planetary dawn. Fix: per-dim time owned by the custom WorldInfo (rename → `ARDimensionWorldInfo`) + a `WorldServer` sleep-site mixin rounding to `rotationalPeriod`. Design locked. | 🟡 Planned — not started | Design converged 2026-06-02. | +| [TASK-47](TASK-47-per-dim-time-and-sleep.md) | Beds skip no time on planets (#66). Root cause: derived `WorldInfo.setWorldTime` is a no-op so the sleep skip is swallowed; `rotationalPeriod ≠ 24000` means vanilla's 24000-rounding misses planetary dawn. Fix: per-dim time owned by the custom WorldInfo (renamed `ARDimensionWorldInfo`) + `MixinWorldServer` sleep-site rounding to `rotationalPeriod`. | ✅ Shipped 2026-06-02 | Live bot-sleep e2e not covered (no sleeping-player harness). | | [TASK-48](TASK-48-per-dim-worldinfo-delegation.md) | Make other `DerivedWorldInfo` state per-dimension that vanilla forces to the overworld: GameRules (sharpest — `doDaylightCycle`/`doWeatherCycle` still shared after TASK-47), spawn point, difficulty, terrain type, game type. Weather (shipped) + time (TASK-47) are the precedent. | 🟦 Feature request — not urgent, needs design | Research spun off TASK-47 on 2026-06-02. | ## Conscious non-goals diff --git a/.agent/tasks/TASK-47-per-dim-time-and-sleep.md b/.agent/tasks/TASK-47-per-dim-time-and-sleep.md index 6cae3b93f..4783386fc 100644 --- a/.agent/tasks/TASK-47-per-dim-time-and-sleep.md +++ b/.agent/tasks/TASK-47-per-dim-time-and-sleep.md @@ -4,8 +4,13 @@ - Source: dercodeKoenig/AdvancedRocketry#66 ("Beds do not work on planets with modified day-night cycle") — sleeping on an AR planet skips no time. -- Status: 🟡 **Planned — not started.** Design converged 2026-06-02; code - not yet written. +- Status: ✅ **Shipped 2026-06-02.** Per-dim time + dawn-rounding mixin + implemented; `ARWeatherWorldInfo` renamed to `ARDimensionWorldInfo`. unit + + integration green; server weather/wiring suites green (mixin applies, + decoupling no regression). Live "bot sleeps in a bed → time advances to + dawn" e2e is NOT covered (needs a sleeping-player testClient harness) — + dawn math is unit-tested and the mixin application is confirmed by server + boot under `required:true`. - Created: 2026-06-02. ## Root cause (confirmed against decompiled MC 1.12.2) diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index 6a6208425..b346d340a 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -12378,7 +12378,7 @@ private void handleTp(net.minecraft.server.MinecraftServer server, // advance under normal server ticks); a regression in the @Mod init // wiring would silently leave AR running without an event handler. // 2. The dim-side wrap-up effects we DO have a probe surface for - // (ARWeatherWorldInfo install, atmosphere registration, sky-color + // (ARDimensionWorldInfo install, atmosphere registration, sky-color // override) are pinned on a freshly loaded AR dim. // 3. The transition queue size is observable — a counter-test for the // "no leaked transitions when the harness has no players" invariant. @@ -12443,7 +12443,7 @@ private void handleEvent(net.minecraft.server.MinecraftServer server, if ("dim-side-effects".equals(sub) && args.length >= 2) { // For the given AR dim, dump the player-facing side effects // that *would* fire when a player joins: - // - WorldInfo class (ARWeatherWorldInfo wrapper present? — B1) + // - WorldInfo class (ARDimensionWorldInfo wrapper present? — B1) // - AtmosphereHandler registered? (dictates oxygen/vacuum on join) // - DimensionProperties.skyColor (rendered by client on join) // - DimensionProperties.gravity (applied by gravity handler) diff --git a/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServer.java b/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServer.java index 03e477636..f5ca2e093 100644 --- a/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServer.java +++ b/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServer.java @@ -5,7 +5,7 @@ import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Redirect; import zmaster587.advancedRocketry.api.IPlanetaryProvider; -import zmaster587.advancedRocketry.world.weather.ARWeatherWorldInfo; +import zmaster587.advancedRocketry.world.weather.ARDimensionWorldInfo; /** * Makes beds bring the planet's morning. Vanilla's sleep skip in @@ -19,11 +19,11 @@ * (ordinal 0 = the sleep-skip block; ordinal 1 is the per-tick +1 increment) * and, for {@link IPlanetaryProvider} dimensions, round to the dimension's * {@code rotationalPeriod} instead. The rounding math lives in - * {@link ARWeatherWorldInfo#computeSleepWakeTime(long, int)} so it is unit + * {@link ARDimensionWorldInfo#computeSleepWakeTime(long, int)} so it is unit * tested. Non-AR worlds keep vanilla behaviour untouched.

* *

The per-dimension clock this writes into is owned by the - * {@link ARWeatherWorldInfo} wrapper (per-dim time, not the swallowed + * {@link ARDimensionWorldInfo} wrapper (per-dim time, not the swallowed * {@code DerivedWorldInfo} no-op), so the skip actually takes effect.

*/ @Mixin(WorldServer.class) @@ -36,7 +36,7 @@ public abstract class MixinWorldServer { private void ar$roundSleepWakeToRotationalPeriod(WorldServer self, long vanillaRounded) { if (self.provider instanceof IPlanetaryProvider) { int rotationalPeriod = ((IPlanetaryProvider) self.provider).getRotationalPeriod(null); - self.setWorldTime(ARWeatherWorldInfo.computeSleepWakeTime(self.getWorldTime(), rotationalPeriod)); + self.setWorldTime(ARDimensionWorldInfo.computeSleepWakeTime(self.getWorldTime(), rotationalPeriod)); } else { self.setWorldTime(vanillaRounded); } diff --git a/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServerMulti.java b/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServerMulti.java index 2063512e1..49d804436 100644 --- a/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServerMulti.java +++ b/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServerMulti.java @@ -16,7 +16,7 @@ * constructor completes, ask the weather manager whether this dimension is an * AR planet that wants its own vanilla weather; if so, replace the freshly * installed {@link net.minecraft.world.storage.DerivedWorldInfo} with our - * {@code ARWeatherWorldInfo} wrapper. + * {@code ARDimensionWorldInfo} wrapper. * *

The provider may not yet be ready at constructor RETURN — the manager * tolerates that and skips. The {@link net.minecraftforge.event.world.WorldEvent.Load} diff --git a/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java b/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java index 6b1b811f5..915e00407 100644 --- a/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java +++ b/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java @@ -33,7 +33,7 @@ import zmaster587.advancedRocketry.world.ChunkManagerPlanet; import zmaster587.advancedRocketry.world.ChunkProviderCavePlanet; import zmaster587.advancedRocketry.world.ChunkProviderPlanet; -import zmaster587.advancedRocketry.world.weather.ARWeatherWorldInfo; +import zmaster587.advancedRocketry.world.weather.ARDimensionWorldInfo; import zmaster587.advancedRocketry.world.weather.PlanetWeatherManager; import javax.annotation.Nonnull; @@ -125,12 +125,12 @@ public void updateWeather() { if (world.provider.hasSkyLight()) { if (!world.isRemote) { // All weather setters below go through world.getWorldInfo(). On AR - // planets that's an ARWeatherWorldInfo wrapping the per-dim state; + // planets that's an ARDimensionWorldInfo wrapping the per-dim state; // if it isn't (wrap failed for some reason — config off, Mixin not // applied, etc.) we'd silently mutate the shared overworld weather. // Warn once per dim so the issue is visible in logs. if (ARConfiguration.getCurrentConfig().enableCustomPlanetWeather - && !(world.getWorldInfo() instanceof ARWeatherWorldInfo)) { + && !(world.getWorldInfo() instanceof ARDimensionWorldInfo)) { PlanetWeatherManager.warnUnwrappedOnce(world.provider.getDimension()); } boolean flag = world.getGameRules().getBoolean("doWeatherCycle"); diff --git a/src/main/java/zmaster587/advancedRocketry/world/weather/ARWeatherWorldInfo.java b/src/main/java/zmaster587/advancedRocketry/world/weather/ARDimensionWorldInfo.java similarity index 98% rename from src/main/java/zmaster587/advancedRocketry/world/weather/ARWeatherWorldInfo.java rename to src/main/java/zmaster587/advancedRocketry/world/weather/ARDimensionWorldInfo.java index c6645e6e3..8c114e7a9 100644 --- a/src/main/java/zmaster587/advancedRocketry/world/weather/ARWeatherWorldInfo.java +++ b/src/main/java/zmaster587/advancedRocketry/world/weather/ARDimensionWorldInfo.java @@ -32,7 +32,7 @@ * reference (the wrapper outlives world unload during dim flicker — a hard * world reference would leak the entire dimension).

*/ -public final class ARWeatherWorldInfo extends WorldInfo { +public final class ARDimensionWorldInfo extends WorldInfo { private final WorldInfo delegate; private final PlanetWeatherState weatherState; @@ -46,7 +46,7 @@ public final class ARWeatherWorldInfo extends WorldInfo { */ private final boolean weatherManaged; - public ARWeatherWorldInfo(WorldInfo delegate, PlanetWeatherState weatherState, + public ARDimensionWorldInfo(WorldInfo delegate, PlanetWeatherState weatherState, Runnable dirtyMarker, boolean weatherManaged) { // Call the WorldInfo no-arg ctor — initialises the (never-read) // internal scaffolding (GameRules, dimensionData, customBossEvents) diff --git a/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java b/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java index 27a2e58ee..e1a332e46 100644 --- a/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java +++ b/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java @@ -23,7 +23,7 @@ *
  • holds the singleton {@link PlanetWeatherSavedData} (lazy-loaded from * the overworld's {@link MapStorage}),
  • *
  • decides which dimensions are eligible for the wrapper,
  • - *
  • installs / removes {@link ARWeatherWorldInfo} on a {@link WorldServer} + *
  • installs / removes {@link ARDimensionWorldInfo} on a {@link WorldServer} * via direct assignment to {@link World#worldInfo} (widened to public by * AR's access transformer — see {@code META-INF/accessTransformer.cfg}),
  • *
  • syncs weather to clients via vanilla {@link SPacketChangeGameState} @@ -130,7 +130,7 @@ public static boolean shouldWrap(WorldServer world) { int dim = world.provider.getDimension(); if (dim == 0) return false; // overworld: never touch if (dim == cfg.spaceDimId) return false; // space: not a planet - if (world.getWorldInfo() instanceof ARWeatherWorldInfo) return false; // already wrapped + if (world.getWorldInfo() instanceof ARDimensionWorldInfo) return false; // already wrapped if (cfg.forcePlanetWeatherWorldInfoWrapper) return true; @@ -161,7 +161,7 @@ public static boolean isWeatherManaged(WorldServer world) { } /** - * Idempotent + safe. Installs (or refreshes) {@link ARWeatherWorldInfo} on + * Idempotent + safe. Installs (or refreshes) {@link ARDimensionWorldInfo} on * the given world. * *

    "Refresh" — if the world somehow gets a fresh {@link WorldInfo} after @@ -182,7 +182,7 @@ public static void wrapWorldInfoIfNeeded(WorldServer world) { PlanetWeatherState state = saved.getOrCreate(dim); WorldInfo current = world.getWorldInfo(); - ARWeatherWorldInfo wrapped = new ARWeatherWorldInfo(current, state, + ARDimensionWorldInfo wrapped = new ARDimensionWorldInfo(current, state, () -> markDirty(world), isWeatherManaged(world)); world.worldInfo = wrapped; @@ -197,8 +197,8 @@ public static void wrapWorldInfoIfNeeded(WorldServer world) { /** Reverse of {@link #wrapWorldInfoIfNeeded}. Used by tests / debug. */ public static void unwrap(WorldServer world) { WorldInfo current = world.getWorldInfo(); - if (current instanceof ARWeatherWorldInfo) { - ARWeatherWorldInfo wrapped = (ARWeatherWorldInfo) current; + if (current instanceof ARDimensionWorldInfo) { + ARDimensionWorldInfo wrapped = (ARDimensionWorldInfo) current; world.worldInfo = wrapped.getDelegate(); } } diff --git a/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherState.java b/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherState.java index 725ce8bc7..50538896e 100644 --- a/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherState.java +++ b/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherState.java @@ -6,7 +6,7 @@ * Per-dimension weather state pulled out of {@link net.minecraft.world.storage.WorldInfo}. * *

    Held by {@link PlanetWeatherSavedData} keyed by dimension id; mutated only - * via {@link ARWeatherWorldInfo} setters. Mutations flip the {@code dirty} flag + * via {@link ARDimensionWorldInfo} setters. Mutations flip the {@code dirty} flag * — the manager pushes that flip down to the saved-data so vanilla disk save * picks it up. Per-listener "lastSynced" snapshots support the explicit * client sync (begin/end raining edges) emitted on player join / dim change.

    diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/WeatherClientSyncE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/WeatherClientSyncE2ETest.java index 3e92e7cfd..4a79d75b9 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/WeatherClientSyncE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/WeatherClientSyncE2ETest.java @@ -136,7 +136,7 @@ public void weatherIsolatedAcrossDimsThroughRealClient() throws Exception { // Seed deterministic, opposite weather on the two planets. /artest // weather set goes through world.getWorldInfo().setRaining(...), which - // on AR planets is our ARWeatherWorldInfo wrapper. + // on AR planets is our ARDimensionWorldInfo wrapper. String setA = String.join("\n", serverHarness.client().execute( "artest weather set " + DIM_A + " rain 12000")); assertTrue("set rain on dim A failed: " + setA, setA.contains("\"ok\":true")); @@ -152,10 +152,10 @@ public void weatherIsolatedAcrossDimsThroughRealClient() throws Exception { serverHarness.client().execute("artest weather get " + DIM_A)); String getB = String.join("\n", serverHarness.client().execute("artest weather get " + DIM_B)); - assertTrue("dim A WorldInfo class should be ARWeatherWorldInfo: " + getA, - getA.contains("ARWeatherWorldInfo")); - assertTrue("dim B WorldInfo class should be ARWeatherWorldInfo: " + getB, - getB.contains("ARWeatherWorldInfo")); + assertTrue("dim A WorldInfo class should be ARDimensionWorldInfo: " + getA, + getA.contains("ARDimensionWorldInfo")); + assertTrue("dim B WorldInfo class should be ARDimensionWorldInfo: " + getB, + getB.contains("ARDimensionWorldInfo")); assertTrue("dim A should be raining after explicit set: " + getA, getA.contains("\"isRaining\":true")); assertFalse("dim B should NOT be raining after explicit clear: " + getB, @@ -200,7 +200,7 @@ public void weatherIsolatedAcrossDimsThroughRealClient() throws Exception { String getBAgain = String.join("\n", serverHarness.client().execute("artest weather get " + DIM_B)); assertTrue("dim B wrapper must persist across teleports: " + getBAgain, - getBAgain.contains("ARWeatherWorldInfo")); + getBAgain.contains("ARDimensionWorldInfo")); assertFalse("server-side dim B must remain clear: " + getBAgain, getBAgain.contains("\"isRaining\":true")); } diff --git a/src/test/java/zmaster587/advancedRocketry/test/integration/ARWeatherWorldInfoTest.java b/src/test/java/zmaster587/advancedRocketry/test/integration/ARDimensionWorldInfoTest.java similarity index 88% rename from src/test/java/zmaster587/advancedRocketry/test/integration/ARWeatherWorldInfoTest.java rename to src/test/java/zmaster587/advancedRocketry/test/integration/ARDimensionWorldInfoTest.java index c467e0c52..a6bee1885 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/integration/ARWeatherWorldInfoTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/integration/ARDimensionWorldInfoTest.java @@ -5,7 +5,7 @@ import org.junit.BeforeClass; import org.junit.Test; import zmaster587.advancedRocketry.test.MinecraftBootstrap; -import zmaster587.advancedRocketry.world.weather.ARWeatherWorldInfo; +import zmaster587.advancedRocketry.world.weather.ARDimensionWorldInfo; import zmaster587.advancedRocketry.world.weather.PlanetWeatherState; import java.util.concurrent.atomic.AtomicInteger; @@ -17,7 +17,7 @@ import static org.junit.Assert.assertTrue; /** - * SMART §6.10 (4-7) — {@link ARWeatherWorldInfo} delegation contract. + * SMART §6.10 (4-7) — {@link ARDimensionWorldInfo} delegation contract. * *
      *
    • (4) non-weather getters route to the delegate;
    • @@ -30,7 +30,7 @@ * Lives in the integration layer because constructing a vanilla {@link WorldInfo} * touches {@code GameRules} which requires {@code Bootstrap.register()}. */ -public class ARWeatherWorldInfoTest { +public class ARDimensionWorldInfoTest { @BeforeClass public static void bootstrap() { @@ -49,15 +49,15 @@ private static WorldInfo seededDelegate() { return new WorldInfo(nbt); } - private static ARWeatherWorldInfo wrap(WorldInfo delegate, PlanetWeatherState state, Runnable dirty) { - return new ARWeatherWorldInfo(delegate, state, dirty, /* weatherManaged */ true); + private static ARDimensionWorldInfo wrap(WorldInfo delegate, PlanetWeatherState state, Runnable dirty) { + return new ARDimensionWorldInfo(delegate, state, dirty, /* weatherManaged */ true); } @Test public void arWeatherWorldInfoDelegatesNonWeatherFields() { WorldInfo delegate = seededDelegate(); PlanetWeatherState state = new PlanetWeatherState(); - ARWeatherWorldInfo wrapper = wrap(delegate, state, () -> {}); + ARDimensionWorldInfo wrapper = wrap(delegate, state, () -> {}); assertEquals("seed must come from delegate", 4242L, wrapper.getSeed()); assertEquals("worldName must come from delegate", "DelegateLevel", wrapper.getWorldName()); @@ -73,7 +73,7 @@ public void arWeatherWorldInfoDelegatesNonWeatherFields() { public void arWeatherWorldInfoOverridesOnlyWeatherFields() { WorldInfo delegate = seededDelegate(); PlanetWeatherState state = new PlanetWeatherState(); - ARWeatherWorldInfo wrapper = wrap(delegate, state, () -> {}); + ARDimensionWorldInfo wrapper = wrap(delegate, state, () -> {}); // Pre-seed delegate weather to a DIFFERENT value than the wrapper — // proves the wrapper reads state, not delegate. @@ -120,7 +120,7 @@ public void arWeatherWorldInfoOverridesOnlyWeatherFields() { public void arWeatherWorldInfoServesPerDimTimeIndependentOfDelegate() { WorldInfo delegate = seededDelegate(); // DayTime=17000, Time=17000 PlanetWeatherState state = new PlanetWeatherState(); - ARWeatherWorldInfo wrapper = wrap(delegate, state, () -> {}); + ARDimensionWorldInfo wrapper = wrap(delegate, state, () -> {}); // (TASK-47) Per-dim time is seeded from the delegate on construction so // existing saves don't jump... @@ -146,7 +146,7 @@ public void arWeatherWorldInfoServesPerDimTimeIndependentOfDelegate() { public void perDimTimeSettersMarkDirty() { WorldInfo delegate = seededDelegate(); AtomicInteger dirtyHits = new AtomicInteger(); - ARWeatherWorldInfo wrapper = wrap(delegate, new PlanetWeatherState(), dirtyHits::incrementAndGet); + ARDimensionWorldInfo wrapper = wrap(delegate, new PlanetWeatherState(), dirtyHits::incrementAndGet); wrapper.setWorldTime(1L); wrapper.setWorldTotalTime(1L); @@ -161,8 +161,8 @@ public void unmanagedWeatherDelegatesToVanilla() { // vanilla shared-weather behaviour. WorldInfo delegate = seededDelegate(); PlanetWeatherState state = new PlanetWeatherState(); - ARWeatherWorldInfo wrapper = - new ARWeatherWorldInfo(delegate, state, () -> {}, /* weatherManaged */ false); + ARDimensionWorldInfo wrapper = + new ARDimensionWorldInfo(delegate, state, () -> {}, /* weatherManaged */ false); delegate.setRaining(true); delegate.setRainTime(555); @@ -186,7 +186,7 @@ public void arWeatherWorldInfoMarksDirtyOnWeatherMutation() { WorldInfo delegate = seededDelegate(); PlanetWeatherState state = new PlanetWeatherState(); AtomicInteger dirtyHits = new AtomicInteger(); - ARWeatherWorldInfo wrapper = wrap(delegate, state, dirtyHits::incrementAndGet); + ARDimensionWorldInfo wrapper = wrap(delegate, state, dirtyHits::incrementAndGet); wrapper.setRaining(true); wrapper.setRainTime(1); @@ -204,7 +204,7 @@ public void arWeatherWorldInfoDoesNotFireDirtyOnNonWeatherCalls() { // anything happened from the weather subsystem's POV. WorldInfo delegate = seededDelegate(); AtomicInteger dirtyHits = new AtomicInteger(); - ARWeatherWorldInfo wrapper = wrap(delegate, new PlanetWeatherState(), dirtyHits::incrementAndGet); + ARDimensionWorldInfo wrapper = wrap(delegate, new PlanetWeatherState(), dirtyHits::incrementAndGet); wrapper.setWorldName("ignored"); wrapper.setSaveVersion(7); @@ -216,7 +216,7 @@ public void arWeatherWorldInfoDoesNotFireDirtyOnNonWeatherCalls() { @Test public void getDelegateExposesUnderlyingForUnwrap() { WorldInfo delegate = seededDelegate(); - ARWeatherWorldInfo wrapper = wrap(delegate, new PlanetWeatherState(), () -> {}); + ARDimensionWorldInfo wrapper = wrap(delegate, new PlanetWeatherState(), () -> {}); assertSame(delegate, wrapper.getDelegate()); } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/EventHandlerWiringTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/EventHandlerWiringTest.java index ec3acafc0..cfad3677f 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/EventHandlerWiringTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/EventHandlerWiringTest.java @@ -73,7 +73,7 @@ public void loadingArDimImmediatelyTriggersWeatherWrapperInstall() throws Except String weather = String.join("\n", client().execute("artest weather get " + dim)); assertTrue("WeatherEventHandler did not install the B1 wrapper on AR dim load: " + weather, - weather.contains("ARWeatherWorldInfo")); + weather.contains("ARDimensionWorldInfo")); } @Test @@ -83,9 +83,9 @@ public void overworldStaysVanillaAfterLoad() throws Exception { // and this fixes the polarity of that gate.) client().execute("artest dim load 0"); String weather = String.join("\n", client().execute("artest weather get 0")); - // Vanilla overworld WorldInfo class — neither ARWeatherWorldInfo + // Vanilla overworld WorldInfo class — neither ARDimensionWorldInfo // nor anything that contains "ARWeather". assertTrue("overworld was incorrectly wrapped — wrapping gate broken: " + weather, - !weather.contains("ARWeatherWorldInfo")); + !weather.contains("ARDimensionWorldInfo")); } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/NonARDimensionIsolationTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/NonARDimensionIsolationTest.java index 24635c157..b3a6a082e 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/NonARDimensionIsolationTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/NonARDimensionIsolationTest.java @@ -41,18 +41,18 @@ public void netherAndEndAreNotARPlanets() throws Exception { public void overworldAndVanillaDimsAreNotWrapped() throws Exception { String overworld = String.join("\n", client().execute("artest weather get 0")); assertFalse("overworld must NOT have the AR weather wrapper installed: " + overworld, - overworld.contains("ARWeatherWorldInfo")); + overworld.contains("ARDimensionWorldInfo")); String nether = String.join("\n", client().execute("artest weather get -1")); assertFalse("nether must NOT have the AR weather wrapper installed: " + nether, - nether.contains("ARWeatherWorldInfo")); + nether.contains("ARDimensionWorldInfo")); String end = String.join("\n", client().execute("artest weather get 1")); assertFalse("end must NOT have the AR weather wrapper installed: " + end, - end.contains("ARWeatherWorldInfo")); + end.contains("ARDimensionWorldInfo")); // Sanity check — these three vanilla dims still respond and look - // like real WorldInfo (the wrapper would say "ARWeatherWorldInfo", + // like real WorldInfo (the wrapper would say "ARDimensionWorldInfo", // a missing world would say "error", a misconfigured probe would // say neither — make sure we're observing real worldInfoClass data). assertTrue("overworld weather get must return a worldInfoClass field: " + overworld, diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/PerDimensionWeatherIsolationTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/PerDimensionWeatherIsolationTest.java index dec626df0..7c9acaec2 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/PerDimensionWeatherIsolationTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/PerDimensionWeatherIsolationTest.java @@ -109,10 +109,10 @@ public void rainOnPlanetADoesNotLeakToBOrOverworld() throws Exception { // Wrapper must actually be installed — otherwise the isolation above // could pass for the wrong reason (no propagation simply because we // changed nothing on the other dims yet). - assertTrue("planet A WorldInfo class should be ARWeatherWorldInfo: " + wA, - wA.contains("ARWeatherWorldInfo")); - assertTrue("planet B WorldInfo class should be ARWeatherWorldInfo: " + wB, - wB.contains("ARWeatherWorldInfo")); + assertTrue("planet A WorldInfo class should be ARDimensionWorldInfo: " + wA, + wA.contains("ARDimensionWorldInfo")); + assertTrue("planet B WorldInfo class should be ARDimensionWorldInfo: " + wB, + wB.contains("ARDimensionWorldInfo")); } @Test diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/PlayerEventHandlerWiringTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/PlayerEventHandlerWiringTest.java index 94b851e7f..d476f2da9 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/PlayerEventHandlerWiringTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/PlayerEventHandlerWiringTest.java @@ -137,7 +137,7 @@ public void coreEventHandlersAreClassLoaded() throws Exception { @Test public void arDimensionPreJoinSideEffectsAreCoherent() throws Exception { // For an AR dim, the pre-join side-effects MUST all line up: - // - WorldInfo wrapped (ARWeatherWorldInfo) — required for the + // - WorldInfo wrapped (ARDimensionWorldInfo) — required for the // B1 weather isolation chain to fire on player join // - AtmosphereHandler registered — required for vacuum / oxygen // handling the moment the player tick starts @@ -150,8 +150,8 @@ public void arDimensionPreJoinSideEffectsAreCoherent() throws Exception { assertTrue("AR dim must be loaded for side-effect probing: " + resp, resp.contains("\"loaded\":true")); - assertTrue("AR dim WorldInfo must be wrapped by ARWeatherWorldInfo: " + resp, - resp.contains("ARWeatherWorldInfo")); + assertTrue("AR dim WorldInfo must be wrapped by ARDimensionWorldInfo: " + resp, + resp.contains("ARDimensionWorldInfo")); assertTrue("AR dim must have an AtmosphereHandler registered: " + resp, resp.contains("\"hasAtmosphereHandler\":true")); assertTrue("dim must be classified as AR planet: " + resp, @@ -192,10 +192,10 @@ public void nonArDimensionRejectsArPlanetClassification() throws Exception { resp.contains("\"loaded\":true")); assertTrue("non-AR dim " + nonArDim + " must NOT be classified as AR planet: " + resp, resp.contains("\"isARPlanet\":false")); - // ARWeatherWorldInfo wrapping is the per-AR-dim B1 isolation chain; + // ARDimensionWorldInfo wrapping is the per-AR-dim B1 isolation chain; // a non-AR dim must stay vanilla so weather doesn't bleed in/out. assertTrue("non-AR dim " + nonArDim + " WorldInfo must NOT be wrapped: " + resp, - !resp.contains("ARWeatherWorldInfo")); + !resp.contains("ARDimensionWorldInfo")); } @Test diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/WeatherBaselineTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/WeatherBaselineTest.java index 28ef82f5e..244abd53d 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/WeatherBaselineTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/WeatherBaselineTest.java @@ -22,7 +22,7 @@ * AR planets. After the B1 Mixin weather wrapper landed, per-dimension weather * is the only supported behaviour: rain on the overworld must NOT propagate to * AR planets, and each AR planet's {@code WorldInfo} must be the - * {@code ARWeatherWorldInfo} wrapper. + * {@code ARDimensionWorldInfo} wrapper. */ public class WeatherBaselineTest { @@ -109,7 +109,7 @@ public void weatherPropagationMatchesExpectedMode() throws Exception { // AR planet WorldInfo MUST be the B1 wrapper. If it isn't, the // isolation assertion above passed for the wrong reason (e.g. server // tick simply didn't propagate weather yet), and we'd ship a regression. - assertTrue("planet A is NOT wrapped: " + wA, wA.contains("ARWeatherWorldInfo")); - assertTrue("planet B is NOT wrapped: " + wB, wB.contains("ARWeatherWorldInfo")); + assertTrue("planet A is NOT wrapped: " + wA, wA.contains("ARDimensionWorldInfo")); + assertTrue("planet B is NOT wrapped: " + wB, wB.contains("ARDimensionWorldInfo")); } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/WeatherPersistenceTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/WeatherPersistenceTest.java index 9162ae6e9..1cb1879fe 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/WeatherPersistenceTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/WeatherPersistenceTest.java @@ -19,7 +19,7 @@ * * Previously this test exercised the overworld (dim 0), which is intentionally * NOT wrapped by B1 — so it was actually a vanilla persistence test in disguise. - * Rewritten to write rain into an AR planet (where {@code ARWeatherWorldInfo} + * Rewritten to write rain into an AR planet (where {@code ARDimensionWorldInfo} * is installed and {@code PlanetWeatherSavedData} is the actual persistence * target), then verify it survives a clean stop/start cycle on the same * workdir. @@ -90,7 +90,7 @@ public void planetRainSurvivesRestartOnSameWorkDir() throws Exception { assertTrue("rain didn't take effect on first boot: " + beforeStop, beforeStop.contains("\"isRaining\":true")); assertTrue("wrapper not installed on first boot: " + beforeStop, - beforeStop.contains("ARWeatherWorldInfo")); + beforeStop.contains("ARDimensionWorldInfo")); // Stop cleanly — saved-data must flush via vanilla MapStorage save. firstBoot.close(); @@ -105,6 +105,6 @@ public void planetRainSurvivesRestartOnSameWorkDir() throws Exception { assertTrue("planet rain DID NOT persist across restart: " + after, after.contains("\"isRaining\":true")); assertTrue("wrapper should still be installed after restart: " + after, - after.contains("ARWeatherWorldInfo")); + after.contains("ARDimensionWorldInfo")); } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/SleepWakeTimeTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/SleepWakeTimeTest.java index 2e0bbb912..1f72a7506 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/SleepWakeTimeTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/SleepWakeTimeTest.java @@ -1,21 +1,21 @@ package zmaster587.advancedRocketry.test.unit; import org.junit.Test; -import zmaster587.advancedRocketry.world.weather.ARWeatherWorldInfo; +import zmaster587.advancedRocketry.world.weather.ARDimensionWorldInfo; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; /** * TASK-47 / issue #66 — pure-math unit pins for - * {@link ARWeatherWorldInfo#computeSleepWakeTime(long, int)}: the sleep wake-up + * {@link ARDimensionWorldInfo#computeSleepWakeTime(long, int)}: the sleep wake-up * must land on the planet's dawn (a multiple of {@code rotationalPeriod}), * strictly forward, by at most one planetary day. */ public class SleepWakeTimeTest { private static void assertDawnInvariants(long current, int rp) { - long wake = ARWeatherWorldInfo.computeSleepWakeTime(current, rp); + long wake = ARDimensionWorldInfo.computeSleepWakeTime(current, rp); assertEquals("wake must land on a multiple of rotationalPeriod (dawn) for rp=" + rp + ", current=" + current, 0L, Math.floorMod(wake, (long) rp)); assertTrue("wake must move strictly forward (current=" + current + ", wake=" + wake + ")", @@ -30,7 +30,7 @@ public void rp24000MatchesVanillaRounding() { for (long t : new long[]{0L, 1L, 12345L, 23999L, 24000L, 50000L}) { long vanilla = (t + 24000L) - (t + 24000L) % 24000L; assertEquals("rp=24000 must equal vanilla rounding at t=" + t, - vanilla, ARWeatherWorldInfo.computeSleepWakeTime(t, 24000)); + vanilla, ARDimensionWorldInfo.computeSleepWakeTime(t, 24000)); } } @@ -47,14 +47,14 @@ public void nonVanillaPeriodsLandOnDawn() { public void alreadyAtDawnSkipsToNextDay() { // current exactly on a dawn boundary → advance a full day, not stay put. int rp = 13888; - long wake = ARWeatherWorldInfo.computeSleepWakeTime(2L * rp, rp); + long wake = ARDimensionWorldInfo.computeSleepWakeTime(2L * rp, rp); assertEquals(3L * rp, wake); } @Test public void nonPositivePeriodFallsBackTo24000() { // Defensive: a bad rotationalPeriod must not divide-by-zero or loop. - assertEquals(24000L, ARWeatherWorldInfo.computeSleepWakeTime(0L, 0)); - assertEquals(24000L, ARWeatherWorldInfo.computeSleepWakeTime(0L, -5)); + assertEquals(24000L, ARDimensionWorldInfo.computeSleepWakeTime(0L, 0)); + assertEquals(24000L, ARDimensionWorldInfo.computeSleepWakeTime(0L, -5)); } } From 1c18bbe908afcc280d8b726b76d08a8b89965eeb Mon Sep 17 00:00:00 2001 From: StannisMod Date: Tue, 2 Jun 2026 10:29:48 +0200 Subject: [PATCH 22/47] chore: update Navigator knowledge graph Co-Authored-By: Claude Opus 4.7 (1M context) --- .agent/knowledge/graph.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.agent/knowledge/graph.json b/.agent/knowledge/graph.json index 0af972eba..27bb89fce 100644 --- a/.agent/knowledge/graph.json +++ b/.agent/knowledge/graph.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-06-02T08:46:26.977051Z", + "last_updated": "2026-06-02T10:26:34.379623Z", "stats": { "total_nodes": 120, "total_edges": 418, @@ -620,10 +620,10 @@ "title": "Per-dimension time + working beds on planets (issue #66)", "status": "unknown", "concepts": [ - "tom", - "testing", "api", - "deployment" + "tom", + "deployment", + "testing" ] }, "TASK-48": { From 0cbb357dfdc94a14d89a7f4b86393ca14517aa34 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Tue, 2 Jun 2026 15:33:49 +0200 Subject: [PATCH 23/47] =?UTF-8?q?test:=20TASK-49=20=E2=80=94=20repro=20rai?= =?UTF-8?q?lgun=20silent=20fire-failure=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new `artest infra railgun-fire` probe verb + `countItemsInPortList` helper drive the source-side TileRailgun.attemptCargoTransfer path - RailgunFiringContractTest (2 server): same-dim shot fires (cargo input→output); unloaded-dest shot fails silently with cargo preserved - root cause = destination dim unloaded (Forge getWorld→null, railgun force-loads only its own chunk) + zero player feedback on every gate - bug ledger Batch #2 entry #8; README pyramid regenerated to 848 (+2 server) per SOP §2.5; TASK-49 Done row + ledger summary synced - production fix (load dest dim + per-cause feedback) pending Co-Authored-By: Claude Opus 4.8 (1M context) --- .agent/history/known-bugs-ledger.md | 33 +++- .agent/tasks/README.md | 29 ++- .../TASK-49-railgun-silent-fire-failure.md | 105 +++++++++++ .../command/test/TestProbeCommand.java | 171 +++++++++++++++++- .../server/RailgunFiringContractTest.java | 146 +++++++++++++++ 5 files changed, 476 insertions(+), 8 deletions(-) create mode 100644 .agent/tasks/TASK-49-railgun-silent-fire-failure.md create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/RailgunFiringContractTest.java diff --git a/.agent/history/known-bugs-ledger.md b/.agent/history/known-bugs-ledger.md index 74a4974b4..c58fe001e 100644 --- a/.agent/history/known-bugs-ledger.md +++ b/.agent/history/known-bugs-ledger.md @@ -4,8 +4,8 @@ 2026-05-23). Batch #2 below is **live** and is kept in sync with the summary in [`../tasks/README.md`](../tasks/README.md) bug-ledger section. -**Live bug count (as of 2026-05-31)**: 4 live — Batch #2 entries -#1, #3, #5, #7. Entry #2 dropped as impl-trivia, #4 fixed by TASK-41, +**Live bug count (as of 2026-06-02)**: 5 live — Batch #2 entries +#1, #3, #5, #7, #8. Entry #2 dropped as impl-trivia, #4 fixed by TASK-41, #6 fixed by TASK-43 Phase 3 (see per-entry notes below). When a future production bug is uncovered, follow the rule in [`CLAUDE.md`](../../CLAUDE.md#bug-tracking--every-discovered-production-bug-must-be-logged) @@ -255,3 +255,32 @@ authoring that have not yet been fixed. `TilePumpFillsFromAdjacentWaterSourceTest` pins the real contract (drains an AR Forge-fluid source) and documents this in its docstring. **Found**: 2026-05-31 during TASK-44 Gap F.4 un-ignore. + +8. **`TileRailgun.attemptCargoTransfer` fails silently — no player feedback + on any failure branch; the dominant field cause is an unloaded destination + dimension.** The railgun is a paired item-teleport: a source pulls a stack + from its input port and dispatches it to a linked destination railgun. + Firing is gated by ~5 AND-conditions and returns `false` with **no message** + when any fails. The most likely field failure (matching the related + Advanced-Rocketry#1172 "Station→Moon doesn't fire") is the destination being + in an unloaded dimension: production resolves it via + `net.minecraftforge.common.DimensionManager.getWorld(destDim)`, which + returns `null` for an unloaded dim, and the railgun only chunk-loads its OWN + chunk (`onLoad:252`), never the destination's. + File: `src/main/java/zmaster587/advancedRocketry/tile/multiblock/TileRailgun.java:309-364` + (silent `false` branches), `:340` (Forge `getWorld` → null on unloaded dim), + `:252` (own-chunk-only force-load). + **Consequence**: player-visible — "Railgun just does not fire" (#61). Sender + on planet A, receiver on planet B, player on A → B unloaded → nothing + happens, no feedback. Cargo is NOT lost (verified). Same-dimension firing + works. Other silent modes: no output hatch on the destination / output full, + redstone state not satisfied, insufficient RF/t, linker not re-targetable + without a sneak-`resetPosition`. + **Pinned by**: `RailgunFiringContractTest` — + `railgunFiresCargoToLinkedRailgunInSameDimension` (positive same-dim + contract) + `railgunSilentlyFailsWhenDestinationDimensionUnloaded` + (characterizes the silent unloaded-dest no-op + cargo-preservation). New + `artest infra railgun-fire` probe verb drives the source-side path. + Fix candidates (TASK-49): load/resolve the destination dim on fire + + surface a failure message per cause. + **Found**: 2026-06-02 during issue #61 investigation (TASK-49). diff --git a/.agent/tasks/README.md b/.agent/tasks/README.md index 1673f3ae6..76ab974d2 100644 --- a/.agent/tasks/README.md +++ b/.agent/tasks/README.md @@ -14,8 +14,13 @@ Bug-ledger history lives in ## Current state -- **Pyramid**: 856 (testUnit **288** / testIntegration 81 / - testServer **426** / testClient **61**). +1 on 2026-05-29 from +- **Pyramid**: 848 (testUnit 267 / testIntegration 89 / + testServer 431 / testClient 61). Counter regenerated 2026-06-02 + per SOP §2.5 (prior 856/288/81/426/61 headline was stale — trust + the regen, not the "+N" arithmetic). +2 testServer on 2026-06-02 + from TASK-49 (`RailgunFiringContractTest` — issue #61 repro). + Historical "+N" narrative below is retained for provenance only. + +1 on 2026-05-29 from TASK-40b Batch 2 (Gap F.2 GasChargePad — testClient harness fix unlocked it). +1 on 2026-05-29 from TASK-40d Batch 4 (Gap L force field projector). +8 on 2026-05-29 from @@ -142,11 +147,12 @@ Bug-ledger history lives in Counter regenerated via `grep -rc '@Test$' src/test/java/.../{unit,integration,server,client}/`. - **testServer wall time**: 8m 27s (50 % faster than pre-B2). -- **Bug ledger**: 4 live bugs. Arithmetic: 7 entries total minus +- **Bug ledger**: 5 live bugs. Arithmetic: 8 entries total minus #4 (fixed by TASK-41 2026-05-29) minus #6 (fixed by TASK-43 Phase 3 2026-05-30) minus #2 (dropped 2026-05-31 as impl-trivia — see entry) - = 4 live (#1, #3, #5, #7). Batch #2 opened 2026-05-25; entry #5 added - 2026-05-29; entry #7 added 2026-05-31. Batch #1 fully drained by + = 5 live (#1, #3, #5, #7, #8). Batch #2 opened 2026-05-25; entry #5 added + 2026-05-29; entry #7 added 2026-05-31; entry #8 (railgun silent + fire-failure / unloaded-dest, #61) added 2026-06-02 by TASK-49. Batch #1 fully drained by TASK-12 on 2026-05-23. Entries: (1) `SatelliteRegistry.getNewSatellite` returns `null` for unknown types instead of the documented `SatelliteDefunct` fallback — @@ -307,6 +313,18 @@ Bug-ledger history lives in `TilePumpFillsFromAdjacentWaterSourceTest` instead pins the real contract (drains an AR Forge-fluid source) and documents this in its docstring. Found during TASK-44 Gap F.4 un-ignore (2026-05-31). + (8) `TileRailgun.attemptCargoTransfer` fails **silently** on every + failure branch (no player feedback); the dominant field cause is a + destination railgun in an **unloaded dimension** — + `net.minecraftforge.common.DimensionManager.getWorld(destDim)` returns + null and the railgun chunk-loads only its own chunk + (`TileRailgun.java:340`,`:252`,`:309-364`). Player-visible: "Railgun just + does not fire" (#61) when sender and receiver are on different planets and + the player is not at the destination; cargo is NOT lost. Same-dimension + firing works. Pinned by `RailgunFiringContractTest` (positive same-dim + + silent unloaded-dest characterization) via the new `infra railgun-fire` + probe. Fix (load dest dim on fire + per-cause feedback) tracked by + TASK-49. Found 2026-06-02 during #61 investigation. ## Done @@ -363,6 +381,7 @@ Bug-ledger history lives in | [TASK-41](TASK-41-runclient-mixin-accessorworld-bug.md) | `./gradlew runClient` mixin AccessorWorld apply error — fixed 2026-05-29 by swapping `@Accessor` for an access transformer (`public net.minecraft.world.World field_72986_A`) and direct `world.worldInfo = ...` assignment in PlanetWeatherManager. AccessorWorld mixin + mixin-config entry deleted. Added `stageMixinRefmapForRun` build task copying the AP-generated refmap into `build/resources/main/` so future @Inject mixins don't trip the same dev-classpath gap. Option C (`@Mixin(targets="...")`) tried first, failed identically — confirmed root cause was refmap-driven SRG-name lookup, not class-load ordering. Validated: runClient boots to main menu, FML loads 9 mods, testUnit + testIntegration green; testServer 423/427 PASS, 3 pre-existing recipe-registration failures unrelated to TASK-41 (logged as ledger entry #5). | ✅ | | [TASK-42](TASK-42-pre-existing-test-failures-investigation.md) | Triage of 5 pre-existing testServer + testClient failures surfaced during TASK-41 validation. Phase 0 revealed three shape buckets: 1 broken-since-inception (`InventoryBypassRedirectE2ETest` — verified at 149c361e worktree, same failure shape; @Ignore'd 2026-05-30, contract still pinned by `testUnit.RocketInventoryHelperRedirectTest`); 3 parallel-fork flakes (`Electrolyser` / `PrecisionAssembler` / `PrecisionLaserEtcher` recipe tests — PASS in isolation, FAIL only in full suite); 1 stable-isolation failure (`WorldCommandFetchModeratorTest` — fails in 3m 10s even alone, real test-design or production bug). Remaining 4 promoted to TASK-43. | ✅ | | [TASK-43](TASK-43-flaky-and-stable-test-failures.md) | Mitigate the 4 deferred TASK-42 failures across two shapes: Shape A (3 recipe tests, parallel-fork contention — plan: `wait-for-recipe-registry` probe verb + kit hook); Shape B (FetchModerator, stable-fail-in-isolation — plan: per-step bot instrumentation to bisect bridge-drop tick). **Phase 3 shipped** (2026-05-30 — `mixin.env.disableRefMap=true` fix, ledger #6 closed); Shapes A/B still open. | 🟡 Phase 3 done; A/B open | +| [TASK-49](TASK-49-railgun-silent-fire-failure.md) | Railgun silent fire-failure (#61) — repro shipped: `infra railgun-fire` probe + 2 server tests (`RailgunFiringContractTest`) proving same-dim fires and unloaded-dest fails silently with cargo preserved. Root cause = unloaded destination dim + zero feedback (ledger #8). Production fix pending. | 🟡 Repro shipped; fix pending | | [TASK-44](TASK-44-shallow-to-deep-batch.md) | Shallow→deep conversion batch — 4 real contracts + 1 mixin-CI gap shipped: F.4 (TilePump drains Forge IFluidBlock, ledger #7), B (laser-drill MINING dispatch breaks column + drops), C (area-gravity resets fallDistance in-radius only; found controller not machine-enabled by default), N (asteroid worldprovider generates fill blocks), U (un-`@Ignore`'d `InventoryBypassRedirectE2ETest` via server-side `player open-chest` probe, ledger #6 resolved). 5 new probe verbs. Dropped per SOP: G/H/I/K/M/T (impl-only/unwired/wrong-framing). 429/430 full-suite after batch. | ✅ | ## Backlog diff --git a/.agent/tasks/TASK-49-railgun-silent-fire-failure.md b/.agent/tasks/TASK-49-railgun-silent-fire-failure.md new file mode 100644 index 000000000..b2f6b5d96 --- /dev/null +++ b/.agent/tasks/TASK-49-railgun-silent-fire-failure.md @@ -0,0 +1,105 @@ +# TASK-49: Railgun silent fire-failure (issue #61) + +## Ticket + +- Source: dercodeKoenig/AdvancedRocketry#61 ("[BUG] Railgun does not work" — + "Railgun just does not fire with a linker that has the cords of another + railgun"). Reported 2025-07-15 against AR 1.12.2-2.1.8 / LibVulpes + ARLIB-17-09-2024. No comments, no repro detail, no stacktrace. +- Status: 🟡 **In Progress — repro shipped 2026-06-02, fix pending.** + Root cause isolated and characterized by tests; production fix not yet + written. +- Created: 2026-06-02. + +## Context + +The railgun is **not a weapon** — it is a paired-railgun item TELEPORT: a +source railgun pulls a stack from its input port, dispatches it to a linked +destination railgun (same or another dim), and the destination's +`onReceiveCargo` deposits it in its output port. The `EntityItemAbducted` +that spawns is the in-flight visual, not a projectile. + +Firing happens in `TileRailgun.attemptCargoTransfer` +(`src/main/java/zmaster587/advancedRocketry/tile/multiblock/TileRailgun.java:309`), +gated by `useEnergy` (`:290`). It returns `false` (no fire) unless ALL hold: + +1. source input port has a stack ≥ `minStackTransferSize` (`:319`); +2. linker is set → valid dest pos + `dimId != INVALID_PLANET` (`:333`,`:221`); +3. destination dimension is loaded — + `net.minecraftforge.common.DimensionManager.getWorld(dimId)` non-null (`:340`); +4. destination tile is a `TileRailgun` AND `canReceiveCargo` (dest has an + output hatch with a free slot) (`:343`,`:366`); +5. planetary-system gate: same effective dim or + `isTravelAnywhereInPlanetarySystem` (`:344`). + +Every failure branch returns `false` **with zero player feedback** — the +defining defect. The reporter can't tell which gate failed. + +## Root cause (confirmed by repro, 2026-06-02) + +- **Same-dimension firing WORKS.** Two assembled railguns in one dim, linker + programmed at the destination, item in the source input → fires; cargo + leaves the source input and lands in the destination output. So the firing + gate logic is NOT broken for the basic case. +- **The field failure is environmental + silent.** The most likely real cause + is gate (3): the destination railgun is in a dimension that is not currently + loaded (sender on planet A, receiver on planet B, player standing on A). + Production resolves the destination via Forge's + `DimensionManager.getWorld(destDim)`, which returns `null` for an unloaded + dim. The railgun only chunk-loads its OWN chunk (`onLoad:252`), never the + destination's → silent no-op. Confirmed: firing at an unloaded dim returns + `fired=false`, `destLoaded=false`, and **cargo is preserved** (not lost). +- Other real failure modes (all silent): destination lacks an output hatch + (or it is full) → `canReceiveCargo` false; redstone state not satisfied; + insufficient RF/t (cross-planet shots are expensive); the linker cannot be + re-targeted without a sneak-`resetPosition` first + (`ItemLinker.applySettings` → `onLinkComplete` returns `false` on the + railgun, a no-op). + +Upstream: #61 is open, 0 comments, untouched; `TileRailgun` is byte-identical +across dercodeKoenig `1.12` and zmaster587 — no fix to pull. + +## Shipped this task (repro / characterization) + +- **Probe verb** `artest infra railgun-fire + [count]` in `TestProbeCommand.java`: + programs a libVulpes Linker at the destination, drops it in the source + controller slot, loads the cargo into the source's first input port, + reflectively invokes the private `attemptCargoTransfer()`, and reports + `fired` / `linkerSet` / `srcInputRemaining` / `destLoaded` / `destIsRailgun` + / `destMatched`. Dest resolution uses Forge `DimensionManager.getWorld` + (not `server.getWorld`, which auto-inits the dim and would mask the + unloaded-dest mode). New helper `countItemsInPortList`. +- **2 server tests** (`RailgunFiringContractTest`): + - `railgunFiresCargoToLinkedRailgunInSameDimension` — same-dim shot fires; + cargo moves input→output (positive contract / regression guard). + - `railgunSilentlyFailsWhenDestinationDimensionUnloaded` — unloaded dest → + silent no-op, cargo preserved (characterizes the #61 root-cause mode). + +Both green; testServer cache-busted per flake-diagnosis SOP. + +## Fix plan (not yet implemented) + +1. **Resolve/load the destination dimension on fire** so Station→Planet and + Planet→Planet work regardless of player presence — either + `server.getWorld(destDim)` (Forge auto-inits) plus a transient chunk-load + of the destination, or a kept ticket. Then flip the cross-dim test's + expectation to "fires". +2. **Player feedback** on each failure cause (other dim / unloaded / no output + hatch / redstone / power) — turn the silent `false` into a clear message. + +## Out of scope / notes + +- Linker re-target UX (sneak-reset requirement) — separate, minor. +- A live in-world e2e (real `useEnergy` tick with power + redstone) is not + covered; the probe drives `attemptCargoTransfer` directly to isolate the + cargo/linker/planetary gate from the power/enabled/redstone gating. + +## Dependencies + +- Independent. Touches only `TestProbeCommand.java` + a new test file (repro); + the fix (when done) will touch `TileRailgun.attemptCargoTransfer`. + +## Bug ledger + +Logged as Batch #2 entry #8 in `.agent/history/known-bugs-ledger.md`. diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index b346d340a..9eebc2459 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -6326,6 +6326,146 @@ private void handleInfra(MinecraftServer server, ICommandSender sender, String[] + ",\"matchedCount\":" + matchedCount + "}"); return; } + if (args.length >= 10 && "railgun-fire".equalsIgnoreCase(args[0])) { + // Issue #61 repro — the SOURCE-side firing path. Unlike + // railgun-receive-cargo (which probes only the receiver endpoint + // on a solo railgun), this drives the full + // TileRailgun.attemptCargoTransfer() across TWO assembled + // railguns: it programs a libVulpes Linker to point at the + // destination controller, drops it in the source controller's + // slot, loads × into the source's first input + // port, then reflectively invokes attemptCargoTransfer() and + // reports whether it fired plus where the cargo ended up. + // + // Usage: railgun-fire + // [count] + int sDim = parseIntOr(args[1], Integer.MIN_VALUE); + int sx = parseIntOr(args[2], 0); + int sy = parseIntOr(args[3], 0); + int sz = parseIntOr(args[4], 0); + int dDim = parseIntOr(args[5], Integer.MIN_VALUE); + int dx = parseIntOr(args[6], 0); + int dy = parseIntOr(args[7], 0); + int dz = parseIntOr(args[8], 0); + String itemId = args[9]; + int count = args.length >= 11 ? parseIntOr(args[10], 1) : 1; + + net.minecraft.world.WorldServer sWorld = server.getWorld(sDim); + if (sWorld == null) { + send(sender, "{\"error\":\"source world not loaded\",\"dim\":" + sDim + "}"); + return; + } + TileEntity sTile = sWorld.getTileEntity(new BlockPos(sx, sy, sz)); + if (!(sTile instanceof zmaster587.advancedRocketry.tile.multiblock.TileRailgun)) { + send(sender, "{\"error\":\"source not a TileRailgun\",\"tile\":\"" + + (sTile == null ? "null" : sTile.getClass().getName()) + "\"}"); + return; + } + net.minecraft.item.Item item = + ForgeRegistries.ITEMS.getValue(new ResourceLocation(itemId)); + if (item == null) { + send(sender, "{\"error\":\"unknown item id\",\"id\":\"" + + escapeJson(itemId) + "\"}"); + return; + } + zmaster587.advancedRocketry.tile.multiblock.TileRailgun src = + (zmaster587.advancedRocketry.tile.multiblock.TileRailgun) sTile; + + // Program a Linker to point at the destination controller, exactly + // as TileRailgun.onLinkStart would on a right-click. + net.minecraft.item.ItemStack linker = + new net.minecraft.item.ItemStack(zmaster587.libVulpes.api.LibVulpesItems.itemLinker); + zmaster587.libVulpes.items.ItemLinker.setMasterCoords(linker, new BlockPos(dx, dy, dz)); + zmaster587.libVulpes.items.ItemLinker.setDimId(linker, dDim); + boolean linkerSet = zmaster587.libVulpes.items.ItemLinker.isSet(linker); + src.setInventorySlotContents(0, linker); + + // Load the cargo into the source's first input port. + int inPortCount = 0; + boolean loadedInput = false; + try { + java.lang.reflect.Field fin = zmaster587.libVulpes.tile.multiblock + .TileMultiBlock.class.getDeclaredField("itemInPorts"); + fin.setAccessible(true); + Object obj = fin.get(src); + if (obj instanceof java.util.List) { + for (Object inv : (java.util.List) obj) { + if (!(inv instanceof net.minecraft.inventory.IInventory)) continue; + inPortCount++; + if (!loadedInput) { + ((net.minecraft.inventory.IInventory) inv).setInventorySlotContents( + 0, new net.minecraft.item.ItemStack(item, count)); + loadedInput = true; + } + } + } + } catch (ReflectiveOperationException e) { + send(sender, "{\"error\":\"itemInPorts reflection failed\"," + + "\"detail\":\"" + escapeJson( + e.getClass().getSimpleName() + ": " + e.getMessage()) + "\"}"); + return; + } + + // Fire: invoke the private attemptCargoTransfer() directly so the + // result isolates the cargo/linker/planetary gate from the + // enabled/redstone/power gating in useEnergy(). + boolean fired; + try { + java.lang.reflect.Method m = zmaster587.advancedRocketry.tile.multiblock + .TileRailgun.class.getDeclaredMethod("attemptCargoTransfer"); + m.setAccessible(true); + fired = (Boolean) m.invoke(src); + } catch (ReflectiveOperationException e) { + send(sender, "{\"error\":\"attemptCargoTransfer reflection failed\"," + + "\"detail\":\"" + escapeJson( + e.getClass().getSimpleName() + ": " + e.getMessage()) + "\"}"); + return; + } + + // Inspect the aftermath. + int srcInputRemaining; + try { + srcInputRemaining = countItemsInPortList(src, "itemInPorts", item); + } catch (ReflectiveOperationException e) { + send(sender, "{\"error\":\"itemInPorts recount failed\",\"detail\":\"" + + escapeJson(e.getMessage()) + "\"}"); + return; + } + boolean destLoaded = false; + boolean destIsRailgun = false; + int destMatched = 0; + // Mirror production exactly: attemptCargoTransfer resolves the + // destination via Forge's DimensionManager.getWorld (returns null + // when the dim is unloaded). Do NOT use server.getWorld here — it + // auto-inits the dimension, which would mask the unloaded-dest + // silent-failure mode this probe is meant to surface. + net.minecraft.world.WorldServer dWorld = + net.minecraftforge.common.DimensionManager.getWorld(dDim); + if (dWorld != null) { + destLoaded = true; + TileEntity dTile = dWorld.getTileEntity(new BlockPos(dx, dy, dz)); + if (dTile instanceof zmaster587.advancedRocketry.tile.multiblock.TileRailgun) { + destIsRailgun = true; + try { + destMatched = countItemsInPortList( + dTile, "itemOutPorts", item); + } catch (ReflectiveOperationException e) { + send(sender, "{\"error\":\"dest itemOutPorts scan failed\",\"detail\":\"" + + escapeJson(e.getMessage()) + "\"}"); + return; + } + } + } + + send(sender, "{\"ok\":true,\"fired\":" + fired + + ",\"linkerSet\":" + linkerSet + + ",\"inPortCount\":" + inPortCount + + ",\"srcInputRemaining\":" + srcInputRemaining + + ",\"destLoaded\":" + destLoaded + + ",\"destIsRailgun\":" + destIsRailgun + + ",\"destMatched\":" + destMatched + "}"); + return; + } if (args.length >= 5 && "astrobody-set-research".equalsIgnoreCase(args[0])) { // TASK-40 Gap D — reshape note: the audit's "PlanetAnalyser / // SatelliteData scan output" framing was wrong. The actual class @@ -6498,7 +6638,7 @@ private void handleInfra(MinecraftServer server, ICommandSender sender, String[] + "\",\"amount\":" + amount + "}"); return; } - send(sender, "{\"error\":\"unknown infra subcommand — try info | link | unlink | monitor-info | inject-broken-part | service-relink | service-scan-assemblers | railgun-receive-cargo [count] | astrobody-set-research | astrobody-load-chip | astrobody-chip-data | databus-set-data \"}"); + send(sender, "{\"error\":\"unknown infra subcommand — try info | link | unlink | monitor-info | inject-broken-part | service-relink | service-scan-assemblers | railgun-receive-cargo [count] | railgun-fire [count] | astrobody-set-research | astrobody-load-chip | astrobody-chip-data | databus-set-data \"}"); } // §9.2 Fixture-building primitives ----------------------------------------- @@ -12197,6 +12337,35 @@ private static java.lang.reflect.Field findFieldInHierarchy(Class cls, String throw new NoSuchFieldException(name); } + /** Sums the count of {@code item} across every slot of every IInventory in + * a libVulpes {@code TileMultiBlock} port list ({@code itemInPorts} / + * {@code itemOutPorts}), reached reflectively. Used by the railgun-fire + * probe (issue #61) to verify cargo left the source's input and arrived + * at the destination's output. */ + private static int countItemsInPortList(Object tile, String fieldName, + net.minecraft.item.Item item) + throws ReflectiveOperationException { + java.lang.reflect.Field f = zmaster587.libVulpes.tile.multiblock + .TileMultiBlock.class.getDeclaredField(fieldName); + f.setAccessible(true); + Object obj = f.get(tile); + int matched = 0; + if (obj instanceof java.util.List) { + for (Object inv : (java.util.List) obj) { + if (!(inv instanceof net.minecraft.inventory.IInventory)) continue; + net.minecraft.inventory.IInventory ii = + (net.minecraft.inventory.IInventory) inv; + for (int i = 0; i < ii.getSizeInventory(); i++) { + net.minecraft.item.ItemStack s = ii.getStackInSlot(i); + if (!s.isEmpty() && s.getItem() == item) { + matched += s.getCount(); + } + } + } + } + return matched; + } + /** Reads a private static final int field via reflection. Returns * {@code Integer.MIN_VALUE} on reflective failure (caller treats * that as "field missing"). Used by TASK-22 to expose diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/RailgunFiringContractTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/RailgunFiringContractTest.java new file mode 100644 index 000000000..eb939a119 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/RailgunFiringContractTest.java @@ -0,0 +1,146 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertTrue; + +/** + * Issue #61 ("[BUG] Railgun does not work") — source-side firing contract. + * + *

      The reporter said the railgun "just does not fire with a linker that has + * the cords of another railgun". {@link RailgunCargoReceiveContractTest} only + * pins the receiver endpoint on a SOLO railgun; nothing exercised the full + * source-side path + * ({@link zmaster587.advancedRocketry.tile.multiblock.TileRailgun#attemptCargoTransfer}), + * which needs TWO assembled railguns at linked positions.

      + * + *

      This test builds two railguns in the SAME dimension, programs a libVulpes + * Linker on the source pointing at the destination controller, loads a cargo + * stack into the source's input port, and drives {@code attemptCargoTransfer} + * via the {@code artest infra railgun-fire} probe. The basic same-dimension + * case MUST fire: cargo leaves the source input and lands in the destination + * output. If this passes, the field report is an environmental failure + * (destination dimension unloaded, missing output hatch, redstone, or power) — + * not a logic bug in the firing gate; if it fails, the gate itself is broken.

      + * + *

      Position-isolated at x=4900 (source) / x=4960 (destination) — clear of + * RailgunMultiblockTest (x=4500..4560) and RailgunCargoReceiveContractTest + * (x=4700).

      + */ +public class RailgunFiringContractTest extends AbstractSharedServerTest { + + private static final int SX = 4900; + private static final int SY = 64; + private static final int SZ = 4900; + + private static final int DX = 4960; + private static final int DY = 64; + private static final int DZ = 4900; + + // Separate source for the cross-dimension case (shared server JVM). + private static final int UX = 5020; + private static final int UZ = 4900; + + /** An id that is not registered/loaded on the test server, so production's + * {@code net.minecraftforge.common.DimensionManager.getWorld(id)} returns + * null — the exact unloaded-destination condition behind issue #61. */ + private static final int UNLOADED_DIM = 31337; + + private static final int CARGO = 16; + + private static final Pattern FIRED = + Pattern.compile("\"fired\":(true|false)"); + private static final Pattern DEST_MATCHED = + Pattern.compile("\"destMatched\":(\\d+)"); + private static final Pattern SRC_REMAINING = + Pattern.compile("\"srcInputRemaining\":(\\d+)"); + + @Test + public void railgunFiresCargoToLinkedRailgunInSameDimension() throws Exception { + buildAndComplete(SX, SY, SZ); + buildAndComplete(DX, DY, DZ); + + String fire = exec("artest infra railgun-fire 0 " + SX + " " + SY + " " + SZ + + " 0 " + DX + " " + DY + " " + DZ + " minecraft:cobblestone " + CARGO); + assertTrue("railgun-fire probe must succeed: " + fire, + fire.contains("\"ok\":true")); + + assertTrue("railgun MUST fire to a linked railgun in the same dimension " + + "(issue #61 baseline); fire=" + fire, + "true".equals(extractStr(fire, FIRED))); + + int destMatched = extractInt(fire, DEST_MATCHED); + assertTrue("destination output port must contain >= " + CARGO + + " cobblestone after firing; fire=" + fire, + destMatched >= CARGO); + + int srcRemaining = extractInt(fire, SRC_REMAINING); + assertTrue("source input port must be drained after firing " + + "(remaining=" + srcRemaining + "); fire=" + fire, + srcRemaining == 0); + } + + /** + * Issue #61 — the most likely field failure: the destination railgun is in + * a dimension that is not currently loaded (e.g. sender on planet A, + * receiver on planet B, player standing on A). Production resolves the + * destination with {@code net.minecraftforge.common.DimensionManager + * .getWorld(destDim)}, which returns null for an unloaded dim; the railgun + * only chunk-loads its OWN chunk, never the destination's. The result is a + * SILENT no-op: nothing fires, no feedback. This test characterizes that + * behavior — and crucially pins that cargo is NOT lost when the shot fails. + */ + @Test + public void railgunSilentlyFailsWhenDestinationDimensionUnloaded() throws Exception { + buildAndComplete(UX, SY, UZ); + + String fire = exec("artest infra railgun-fire 0 " + UX + " " + SY + " " + UZ + + " " + UNLOADED_DIM + " 0 64 0 minecraft:cobblestone " + CARGO); + assertTrue("railgun-fire probe must succeed: " + fire, + fire.contains("\"ok\":true")); + + assertTrue("railgun must NOT fire when the destination dimension is " + + "unloaded (issue #61 root cause); fire=" + fire, + "false".equals(extractStr(fire, FIRED))); + assertTrue("destination dimension must be reported unloaded " + + "(production getWorld returns null); fire=" + fire, + fire.contains("\"destLoaded\":false")); + + int srcRemaining = extractInt(fire, SRC_REMAINING); + assertTrue("cargo must be preserved in the source input on a failed " + + "shot — never silently consumed (remaining=" + + srcRemaining + " expected " + CARGO + "); fire=" + fire, + srcRemaining == CARGO); + } + + // -- helpers ---------------------------------------------------------- + + private void buildAndComplete(int x, int y, int z) throws Exception { + String fixture = exec("artest fixture multiblock railgun 0 " + + x + " " + y + " " + z); + assertTrue("fixture multiblock railgun failed at " + x + "," + y + "," + z + + ": " + fixture, fixture.contains("\"ok\":true")); + + String tryComplete = exec("artest machine try-complete 0 " + + x + " " + y + " " + z); + assertTrue("railgun must validate at " + x + "," + y + "," + z + + ": " + tryComplete, tryComplete.contains("\"isComplete\":true")); + } + + private static String exec(String cmd) throws Exception { + return String.join("\n", client().execute(cmd)); + } + + private static String extractStr(String src, Pattern pattern) { + Matcher m = pattern.matcher(src); + assertTrue("pattern not found in: " + src, m.find()); + return m.group(1); + } + + private static int extractInt(String src, Pattern pattern) { + return Integer.parseInt(extractStr(src, pattern)); + } +} From b055ea1a7ae9dc64b9bfc0537f99b206f6f1d4c8 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Tue, 2 Jun 2026 20:29:57 +0200 Subject: [PATCH 24/47] =?UTF-8?q?fix:=20don't=20crash=20under=20a=20Mixin?= =?UTF-8?q?=20host=20=E2=80=94=20guard=20AR=20self-bootstrap=20of=20Mixin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdvancedRocketryPlugin's coremod constructor called MixinBootstrap.init() unconditionally. Under a Mixin host (MixinBooter) in a packaged jar, Mixin is already bootstrapped on the LaunchClassLoader and our config comes from the MixinConfigs manifest attribute; re-running init() from this coremod (AppClassLoader) re-initiates loading of GlobalProperties$Keys on a second classloader → LinkageError ("loader constraint violation") → FML crashes at launch. Wrap the self-bootstrap in try/catch: dev (no host) self-registers as before; under a host the error is swallowed and the manifest drives it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../asm/AdvancedRocketryPlugin.java | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/main/java/zmaster587/advancedRocketry/asm/AdvancedRocketryPlugin.java b/src/main/java/zmaster587/advancedRocketry/asm/AdvancedRocketryPlugin.java index e5a59afa8..5f7411974 100644 --- a/src/main/java/zmaster587/advancedRocketry/asm/AdvancedRocketryPlugin.java +++ b/src/main/java/zmaster587/advancedRocketry/asm/AdvancedRocketryPlugin.java @@ -11,20 +11,28 @@ public class AdvancedRocketryPlugin implements IFMLLoadingPlugin { public AdvancedRocketryPlugin() { - // Register our mixin config programmatically. In a packaged production - // jar this is also declared via the `MixinConfigs` manifest attribute - // (set by tasks.jar), but in the dev workspace the mod is loaded from - // build/classes/java/main with no manifest, so MixinBooter would - // otherwise never see our config. Mixins.addConfiguration is - // idempotent on the same file name, so the manifest + programmatic - // paths can both fire harmlessly. + // Register our mixin config programmatically. In the dev workspace the mod + // is loaded from build/classes/java/main with no manifest, so nothing else + // bootstraps Mixin or sees our config — we must do it ourselves. // - // MixinBootstrap.init() is also idempotent — MixinBooter has typically - // run first and called it, but doing it again is a no-op and protects - // against load-order surprises (e.g. coremod scan reaching us before - // MixinBooter on some Forge versions). - MixinBootstrap.init(); - Mixins.addConfiguration("mixins.advancedrocketry.json"); + // In a packaged jar a Mixin host (MixinBooter) is present: it bootstraps + // Mixin on the LaunchClassLoader and registers our config from the + // `MixinConfigs` manifest attribute. Re-running MixinBootstrap.init() from + // this coremod (loaded on the AppClassLoader) then re-initiates loading of + // org.spongepowered.asm.launch.GlobalProperties$Keys on a second classloader + // and the JVM throws a LinkageError ("loader constraint violation"), which + // crashes FML at launch. So guard the self-bootstrap: attempt it, and if a + // host already owns Mixin, swallow the error and let the manifest drive + // registration. The dev path (no host) succeeds and self-registers. + try { + MixinBootstrap.init(); + Mixins.addConfiguration("mixins.advancedrocketry.json"); + } catch (Throwable t) { + org.apache.logging.log4j.LogManager.getLogger("AdvancedRocketry").info( + "Skipping AR self-bootstrap of Mixin — a Mixin host (e.g. MixinBooter) " + + "is present and loads mixins.advancedrocketry.json from the jar " + + "manifest. Cause: " + t); + } } @Override From 28f848d6f51c66c5ff640f6a9ead1fd1b91953c2 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 3 Jun 2026 13:08:53 +0200 Subject: [PATCH 25/47] docs: SOP for bug-report workflow (repro-first, client e2e mandatory) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new sops/development/bug-report-workflow.md: repro-first pipeline (mandatory testClient e2e + testServer when catchable) → trace report → user decides (Path A confirmed-bug task / Path B fix-now) - introduces `Type: Bug report — confirmed` + `Priority: urgent` task header fields (additive to the task-lifecycle status enum) - session-start duty to re-surface open confirmed bugs lives in the SOP (required reading), not in Navigator tooling - linked from DEVELOPMENT-README required-reading section Co-Authored-By: Claude Opus 4.8 (1M context) --- .agent/DEVELOPMENT-README.md | 19 +++ .../sops/development/bug-report-workflow.md | 139 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 .agent/sops/development/bug-report-workflow.md diff --git a/.agent/DEVELOPMENT-README.md b/.agent/DEVELOPMENT-README.md index a52d12623..b3a1a6b6d 100644 --- a/.agent/DEVELOPMENT-README.md +++ b/.agent/DEVELOPMENT-README.md @@ -8,6 +8,25 @@ ## ⚠️ Required reading before any non-trivial work +### At session start + before working any bug report + +**[SOP: Bug-report workflow](./sops/development/bug-report-workflow.md)** — +read at the start of every session, and before fixing any reported +issue/bug. + +**TL;DR**: the pipeline is repro-FIRST → trace report → user decides. +(1) Write reproduction tests against the clean default build before +touching production — **a `testClient` e2e is mandatory** (plus a +`testServer` e2e when the bug is catchable there); they confirm the +bug now and guard regression forever. (2) Only after the behaviour is +confirmed, deliver a structured trace report: cause (`file:line`), +provenance (when it was introduced), and fix options. (3) The user +picks: **Path A** — file a `Type: Bug report — confirmed` / +`Priority: urgent` task (status `Backlog`), fix deferred; **Path B** — +fix now, flip the repro tests to the corrected contract, close the +task. **Session-start duty**: scan `tasks/` for open +`Type: Bug report — confirmed` tasks and offer to fix them. + ### Before writing or auditing tests **[SOP: Testing Principles](./sops/development/testing-principles.md)** — diff --git a/.agent/sops/development/bug-report-workflow.md b/.agent/sops/development/bug-report-workflow.md new file mode 100644 index 000000000..6053fb2ef --- /dev/null +++ b/.agent/sops/development/bug-report-workflow.md @@ -0,0 +1,139 @@ +# SOP: Bug-report workflow — confirm, trace, decide, fix + +## Why this SOP exists + +Every bug the user asks us to fix MUST be (a) reproduced on a clean +build before we touch production, and (b) locked against regression +forever after. Reproduction-first is non-negotiable: a fix we can't +demonstrate failing-then-passing is a guess. This SOP defines the +fixed pipeline from "here's a bug report" to "task closed". + +It composes with `task-lifecycle.md` (status discipline, closure +checklist) and the `CLAUDE.md` bug-tracking rule (every confirmed +bug logged in `known-bugs-ledger.md`). + +## Scope + +Applies to any user-requested fix of a reported issue or bug +(GitHub issue, in-game report, user description). Does NOT apply to +greenfield coverage tasks (those follow `testing-principles.md`). + +## Read this at session start + +This SOP is required reading at the start of any session, and before +working any bug report. When you read it, **scan `.agent/tasks/` for +open tasks with `Type: Bug report — confirmed` and proactively offer +the user to fix them.** A confirmed-but-deferred bug must be +re-surfaced every session until it is fixed or the user explicitly +drops it. (We deliberately do NOT wire this into the Navigator +tooling — the obligation lives here, in required reading.) + +## The pipeline + +### Step 1 — Reproduction tests FIRST (before any production read-for-fix) + +Write reproduction tests that fail against the **clean default build** +(current AR + the pinned libVulpes / ARLib on disk — the same +toolchain the agent builds with by default). Two hard rules: + +1. **A client e2e (`testClient`) reproduction is MANDATORY.** The bug + report describes player-visible behaviour, so the proof must live + at the player-visible layer. Headless dev boxes run it under + `xvfb-run` / a dedicated `DISPLAY` (see the testClient harness + notes + `mcp-intellij-usage.md`). +2. **If the bug is also catchable in a server e2e (`testServer`), + write that too.** The server test is the cheaper, faster + regression guard; the client test is the player-truth guard. When + both are possible, ship both. + +Each reproduction test must: +- **Confirm the bug on the clean build** — it FAILS (or, where the + bug is a silent no-op, asserts the wrong-but-current behaviour as a + characterization) before any fix. Cache-bust per `flake-diagnosis.md`. +- **Survive as a regression guard** — after the fix it is EDITED to + assert the corrected behaviour (Step 3 Path B), never deleted. + +If a client e2e is genuinely impossible (no client-observable surface +at all), that is an EXCEPTION that must be stated explicitly in the +trace report and approved by the user — never skipped silently. + +### Step 2 — Trace report (only after the behaviour is confirmed) + +Once the reproduction confirms the reported behaviour, investigate +and deliver a structured trace report to the user covering: + +- **(a) Cause** — what in the code produces the behaviour, cited to + `file:line`, with the relevant gate / call path. +- **(b) Provenance** — when the bug was introduced: `git log` / blame + archaeology, the commit or change that brought it in (or "present + since inception"). Check upstream (dercodeKoenig / zmaster587) for + existing fixes. +- **(c) Fix options** — there is usually more than one. Enumerate + them with trade-offs (scope, risk, behavioural side-effects), and a + recommendation. + +Do NOT start fixing during Step 2. The report ends at the user's +decision point. + +### Step 3 — User decides + +The user picks one of two paths. + +#### Path A — "form a task" (fix deferred) + +Create a TASK file with these header fields: + +``` +- Type: Bug report — confirmed +- Priority: urgent +- Status: Backlog # confirmed but unfixed = real task, not started, no blocker +- Created: +``` + +- Body assembled from the Step-2 trace report (cause / provenance / + fix options). +- The reproduction tests are already shipped and committed; the task + tracks only the production fix. +- Log the bug in `known-bugs-ledger.md` (Batch #2) and the README + bug-ledger summary, per `CLAUDE.md`. + +The `Type: Bug report — confirmed` header is what the session-start +scan (above) keys on. `Type` and `Priority` are NEW header fields +that sit alongside — not replace — the `task-lifecycle.md` status +enum; the status stays `Backlog` until the fix starts. + +#### Path B — "fix now" + +The user chooses one of the Step-2 fix options (or proposes their +own). Then: + +- Implement the fix in production. +- **Edit the reproduction tests to assert the new, correct + behaviour** — the failing / characterization test flips to a + positive contract. Same tests, inverted polarity; never a parallel + copy. +- Re-run both tiers green (cache-bust per `flake-diagnosis.md`). +- Close the task per the `task-lifecycle.md` closure checklist + (status `Completed`, README + ledger sync, pyramid regen, EOD + marker, commit). The ledger entry flips from "live" to "fixed by + TASK-NN". + +## Anti-patterns + +- ❌ Reading production "to find the fix" before a reproduction test + exists. Repro first, always. +- ❌ Shipping only a server test because the client harness is + awkward. Client e2e is the mandatory player-truth guard; justify + any exception explicitly and get approval. +- ❌ Deleting the reproduction test after the fix. It is the + regression guard — flip its polarity, keep it. +- ❌ A confirmed bug with no ledger entry, or a deferred confirmed + bug that the next session doesn't re-surface. +- ❌ Starting the fix before the user has chosen a fix option. + +## Related + +- `testing-principles.md` — contract-vs-impl test design. +- `flake-diagnosis.md` — cache-bust + distribution diagnosis. +- `task-lifecycle.md` — status values + closure checklist. +- `CLAUDE.md` bug-tracking rule + `history/known-bugs-ledger.md`. From df8b9c8b320bf3bb34d9355f31f7889e1e1b310b Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 3 Jun 2026 13:19:33 +0200 Subject: [PATCH 26/47] =?UTF-8?q?test:=20TASK-49=20=E2=80=94=20client=20e2?= =?UTF-8?q?e=20for=20railgun=20#61=20(SOP=20compliance)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RailgunCargoTransitE2ETest (2 testClient): re-pins the same-dim-fires and unloaded-dest-silent-fail contracts with a REAL client connected, satisfying the new bug-report-workflow SOP's mandatory client e2e - reuses the `infra railgun-fire` probe; bot is the harness anchor, setup+observe via server probes (per AR client-test convention) - ran on DISPLAY :100 / xvfb, skipped=0 (client actually connected) - README pyramid → 850 (+2 testClient); TASK-49 + ledger #8 synced Co-Authored-By: Claude Opus 4.8 (1M context) --- .agent/history/known-bugs-ledger.md | 6 +- .agent/tasks/README.md | 11 +- .../TASK-49-railgun-silent-fire-failure.md | 11 +- .../client/RailgunCargoTransitE2ETest.java | 159 ++++++++++++++++++ 4 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 src/test/java/zmaster587/advancedRocketry/test/client/RailgunCargoTransitE2ETest.java diff --git a/.agent/history/known-bugs-ledger.md b/.agent/history/known-bugs-ledger.md index c58fe001e..fb270616a 100644 --- a/.agent/history/known-bugs-ledger.md +++ b/.agent/history/known-bugs-ledger.md @@ -279,8 +279,10 @@ authoring that have not yet been fixed. **Pinned by**: `RailgunFiringContractTest` — `railgunFiresCargoToLinkedRailgunInSameDimension` (positive same-dim contract) + `railgunSilentlyFailsWhenDestinationDimensionUnloaded` - (characterizes the silent unloaded-dest no-op + cargo-preservation). New - `artest infra railgun-fire` probe verb drives the source-side path. + (characterizes the silent unloaded-dest no-op + cargo-preservation), and at + client tier by `RailgunCargoTransitE2ETest` (same two contracts with a real + client connected). New `artest infra railgun-fire` probe verb drives the + source-side path. Fix candidates (TASK-49): load/resolve the destination dim on fire + surface a failure message per cause. **Found**: 2026-06-02 during issue #61 investigation (TASK-49). diff --git a/.agent/tasks/README.md b/.agent/tasks/README.md index 76ab974d2..e1c6ad7e9 100644 --- a/.agent/tasks/README.md +++ b/.agent/tasks/README.md @@ -14,11 +14,12 @@ Bug-ledger history lives in ## Current state -- **Pyramid**: 848 (testUnit 267 / testIntegration 89 / - testServer 431 / testClient 61). Counter regenerated 2026-06-02 +- **Pyramid**: 850 (testUnit 267 / testIntegration 89 / + testServer 431 / testClient 63). Counter regenerated 2026-06-02 per SOP §2.5 (prior 856/288/81/426/61 headline was stale — trust - the regen, not the "+N" arithmetic). +2 testServer on 2026-06-02 - from TASK-49 (`RailgunFiringContractTest` — issue #61 repro). + the regen, not the "+N" arithmetic). +2 testServer + 2 testClient + on 2026-06-02 from TASK-49 (issue #61 repro: + `RailgunFiringContractTest` + `RailgunCargoTransitE2ETest`). Historical "+N" narrative below is retained for provenance only. +1 on 2026-05-29 from TASK-40b Batch 2 (Gap F.2 GasChargePad — testClient harness fix unlocked @@ -381,7 +382,7 @@ Bug-ledger history lives in | [TASK-41](TASK-41-runclient-mixin-accessorworld-bug.md) | `./gradlew runClient` mixin AccessorWorld apply error — fixed 2026-05-29 by swapping `@Accessor` for an access transformer (`public net.minecraft.world.World field_72986_A`) and direct `world.worldInfo = ...` assignment in PlanetWeatherManager. AccessorWorld mixin + mixin-config entry deleted. Added `stageMixinRefmapForRun` build task copying the AP-generated refmap into `build/resources/main/` so future @Inject mixins don't trip the same dev-classpath gap. Option C (`@Mixin(targets="...")`) tried first, failed identically — confirmed root cause was refmap-driven SRG-name lookup, not class-load ordering. Validated: runClient boots to main menu, FML loads 9 mods, testUnit + testIntegration green; testServer 423/427 PASS, 3 pre-existing recipe-registration failures unrelated to TASK-41 (logged as ledger entry #5). | ✅ | | [TASK-42](TASK-42-pre-existing-test-failures-investigation.md) | Triage of 5 pre-existing testServer + testClient failures surfaced during TASK-41 validation. Phase 0 revealed three shape buckets: 1 broken-since-inception (`InventoryBypassRedirectE2ETest` — verified at 149c361e worktree, same failure shape; @Ignore'd 2026-05-30, contract still pinned by `testUnit.RocketInventoryHelperRedirectTest`); 3 parallel-fork flakes (`Electrolyser` / `PrecisionAssembler` / `PrecisionLaserEtcher` recipe tests — PASS in isolation, FAIL only in full suite); 1 stable-isolation failure (`WorldCommandFetchModeratorTest` — fails in 3m 10s even alone, real test-design or production bug). Remaining 4 promoted to TASK-43. | ✅ | | [TASK-43](TASK-43-flaky-and-stable-test-failures.md) | Mitigate the 4 deferred TASK-42 failures across two shapes: Shape A (3 recipe tests, parallel-fork contention — plan: `wait-for-recipe-registry` probe verb + kit hook); Shape B (FetchModerator, stable-fail-in-isolation — plan: per-step bot instrumentation to bisect bridge-drop tick). **Phase 3 shipped** (2026-05-30 — `mixin.env.disableRefMap=true` fix, ledger #6 closed); Shapes A/B still open. | 🟡 Phase 3 done; A/B open | -| [TASK-49](TASK-49-railgun-silent-fire-failure.md) | Railgun silent fire-failure (#61) — repro shipped: `infra railgun-fire` probe + 2 server tests (`RailgunFiringContractTest`) proving same-dim fires and unloaded-dest fails silently with cargo preserved. Root cause = unloaded destination dim + zero feedback (ledger #8). Production fix pending. | 🟡 Repro shipped; fix pending | +| [TASK-49](TASK-49-railgun-silent-fire-failure.md) | Railgun silent fire-failure (#61) — repro shipped: `infra railgun-fire` probe + 2 server (`RailgunFiringContractTest`) + 2 client e2e (`RailgunCargoTransitE2ETest`) proving same-dim fires and unloaded-dest fails silently with cargo preserved. Root cause = unloaded destination dim + zero feedback (ledger #8). Production fix pending. | 🟡 Repro shipped; fix pending | | [TASK-44](TASK-44-shallow-to-deep-batch.md) | Shallow→deep conversion batch — 4 real contracts + 1 mixin-CI gap shipped: F.4 (TilePump drains Forge IFluidBlock, ledger #7), B (laser-drill MINING dispatch breaks column + drops), C (area-gravity resets fallDistance in-radius only; found controller not machine-enabled by default), N (asteroid worldprovider generates fill blocks), U (un-`@Ignore`'d `InventoryBypassRedirectE2ETest` via server-side `player open-chest` probe, ledger #6 resolved). 5 new probe verbs. Dropped per SOP: G/H/I/K/M/T (impl-only/unwired/wrong-framing). 429/430 full-suite after batch. | ✅ | ## Backlog diff --git a/.agent/tasks/TASK-49-railgun-silent-fire-failure.md b/.agent/tasks/TASK-49-railgun-silent-fire-failure.md index b2f6b5d96..9f079e72d 100644 --- a/.agent/tasks/TASK-49-railgun-silent-fire-failure.md +++ b/.agent/tasks/TASK-49-railgun-silent-fire-failure.md @@ -75,8 +75,15 @@ across dercodeKoenig `1.12` and zmaster587 — no fix to pull. cargo moves input→output (positive contract / regression guard). - `railgunSilentlyFailsWhenDestinationDimensionUnloaded` — unloaded dest → silent no-op, cargo preserved (characterizes the #61 root-cause mode). - -Both green; testServer cache-busted per flake-diagnosis SOP. +- **2 client e2e tests** (`RailgunCargoTransitE2ETest`, the mandatory + player-truth guard per `bug-report-workflow.md`) — the same two contracts + re-pinned with a REAL client connected (catches a teleport client/server + desync the dedicated-server test is blind to): `cargoTransitsBetweenLinked + RailgunsClientSide` + `railgunDoesNotFireToUnloadedDestinationClientSide`. + Run on a dedicated `DISPLAY`/xvfb (`:100` on this box); `skipped=0` confirms + the client actually connected. + +All four green; testServer + testClient cache-busted per flake-diagnosis SOP. ## Fix plan (not yet implemented) diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/RailgunCargoTransitE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/RailgunCargoTransitE2ETest.java new file mode 100644 index 000000000..0b7db5c4e --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/client/RailgunCargoTransitE2ETest.java @@ -0,0 +1,159 @@ +package zmaster587.advancedRocketry.test.client; + +import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertTrue; + +/** + * Issue #61 ("[BUG] Railgun does not work") — client e2e for the railgun + * cargo-transit mechanic, the mandatory player-truth guard required by + * {@code sops/development/bug-report-workflow.md}. + * + *

      The railgun is a paired item TELEPORT: a source railgun pulls a stack + * from its input port and dispatches it to a linked destination railgun, + * whose {@code onReceiveCargo} deposits it in the output port + * ({@link zmaster587.advancedRocketry.tile.multiblock.TileRailgun#attemptCargoTransfer}). + * {@link zmaster587.advancedRocketry.test.server.RailgunFiringContractTest} + * pins the same contracts on a dedicated server; THIS test re-pins them with + * a REAL client connected to the server, so a client/server desync in the + * teleport path (cargo that moves server-side but never syncs to a connected + * player) would surface here where the server-only test is blind.

      + * + *

      Per the AR client-test convention (see + * {@code GasChargePadFillsPressureTankE2ETest}), setup and observation run + * through server-side {@code artest} probes; the client bot is the live + * harness anchor. The new {@code artest infra railgun-fire} probe drives the + * source-side path and reports where the cargo ended up.

      + * + *

      Headless: runs under {@code xvfb-run} / a dedicated {@code DISPLAY}; + * auto-skips when no display is available.

      + */ +public class RailgunCargoTransitE2ETest extends AbstractClientE2ETest { + + private static final int SX = 100; + private static final int SY = 64; + private static final int SZ = 100; + + private static final int DX = 160; + private static final int DY = 64; + private static final int DZ = 100; + + /** Not registered/loaded on the harness server → production + * {@code net.minecraftforge.common.DimensionManager.getWorld(id)} is null. */ + private static final int UNLOADED_DIM = 31337; + + private static final int CARGO = 16; + + private static final Pattern FIRED = + Pattern.compile("\"fired\":(true|false)"); + private static final Pattern DEST_MATCHED = + Pattern.compile("\"destMatched\":(\\d+)"); + private static final Pattern SRC_REMAINING = + Pattern.compile("\"srcInputRemaining\":(\\d+)"); + + /** + * Same-dimension shot fires with a real client connected: cargo leaves + * the source input and arrives at the destination output — the + * player-visible "railgun works" contract for #61. + */ + @Test + public void cargoTransitsBetweenLinkedRailgunsClientSide() throws Exception { + bot().waitForWorld(); + forceloadFootprints(); + + buildAndComplete(SX, SY, SZ); + buildAndComplete(DX, DY, DZ); + + String fire = exec("artest infra railgun-fire 0 " + SX + " " + SY + " " + SZ + + " 0 " + DX + " " + DY + " " + DZ + " minecraft:cobblestone " + CARGO); + assertTrue("railgun-fire probe must succeed: " + fire, + fire.contains("\"ok\":true")); + + assertTrue("railgun MUST fire to a linked railgun in the same dimension " + + "with a client connected (issue #61 baseline); fire=" + fire, + "true".equals(extractStr(fire, FIRED))); + + int destMatched = extractInt(fire, DEST_MATCHED); + assertTrue("destination output port must contain >= " + CARGO + + " cobblestone after firing; fire=" + fire, + destMatched >= CARGO); + + int srcRemaining = extractInt(fire, SRC_REMAINING); + assertTrue("source input port must be drained after firing " + + "(remaining=" + srcRemaining + "); fire=" + fire, + srcRemaining == 0); + } + + /** + * The #61 root-cause mode under a live client: firing at a destination in + * an unloaded dimension is a SILENT no-op and the cargo is preserved. + */ + @Test + public void railgunDoesNotFireToUnloadedDestinationClientSide() throws Exception { + bot().waitForWorld(); + forceloadFootprints(); + + buildAndComplete(SX, SY, SZ); + + String fire = exec("artest infra railgun-fire 0 " + SX + " " + SY + " " + SZ + + " " + UNLOADED_DIM + " 0 64 0 minecraft:cobblestone " + CARGO); + assertTrue("railgun-fire probe must succeed: " + fire, + fire.contains("\"ok\":true")); + + assertTrue("railgun must NOT fire when the destination dimension is " + + "unloaded (issue #61 root cause); fire=" + fire, + "false".equals(extractStr(fire, FIRED))); + assertTrue("destination dimension must be reported unloaded; fire=" + fire, + fire.contains("\"destLoaded\":false")); + + int srcRemaining = extractInt(fire, SRC_REMAINING); + assertTrue("cargo must be preserved in the source input on a failed " + + "shot (remaining=" + srcRemaining + " expected " + CARGO + + "); fire=" + fire, + srcRemaining == CARGO); + } + + // -- helpers ---------------------------------------------------------- + + /** Force-load the chunks covering both railgun footprints so the + * dedicated server can place + tick them (they sit well outside the + * spawn-loaded area). Footprint per railgun: x[cx-4..cx+4], + * z[cz-1..cz+7]; both share z-chunk 6, x-chunks 6..10. */ + private void forceloadFootprints() throws Exception { + for (int cx = 5; cx <= 11; cx++) { + for (int cz = 5; cz <= 7; cz++) { + exec("artest chunk forceload 0 " + cx + " " + cz); + } + } + } + + private void buildAndComplete(int x, int y, int z) throws Exception { + String fixture = exec("artest fixture multiblock railgun 0 " + + x + " " + y + " " + z); + assertTrue("fixture multiblock railgun failed at " + x + "," + y + "," + z + + ": " + fixture, fixture.contains("\"ok\":true")); + + String tryComplete = exec("artest machine try-complete 0 " + + x + " " + y + " " + z); + assertTrue("railgun must validate at " + x + "," + y + "," + z + + ": " + tryComplete, tryComplete.contains("\"isComplete\":true")); + } + + private String exec(String cmd) throws Exception { + return String.join("\n", serverClient().execute(cmd)); + } + + private static String extractStr(String src, Pattern pattern) { + Matcher m = pattern.matcher(src); + assertTrue("pattern not found in: " + src, m.find()); + return m.group(1); + } + + private static int extractInt(String src, Pattern pattern) { + return Integer.parseInt(extractStr(src, pattern)); + } +} From ef2c2f6390a7defe707df7cb734ca916cad0d52d Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 3 Jun 2026 13:56:19 +0200 Subject: [PATCH 27/47] =?UTF-8?q?fix:=20TASK-49=20=E2=80=94=20railgun=20fi?= =?UTF-8?q?res=20across=20planets=20+=20reports=20failures=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #61 "Railgun does not work": the paired-railgun item teleport failed SILENTLY when the destination railgun's dimension wasn't loaded (sender on planet A, receiver on planet B, player on A) — the railgun force-loads only its own chunk, so DimensionManager.getWorld(destDim) returned null and attemptCargoTransfer bailed with no feedback. Fix (Option 1 — load destination on fire + player feedback): - attemptCargoTransfer now initDimension()s a registered-but-unloaded destination (TileSpaceElevator idiom); the dest railgun's own onLoad ticket sustains it after the first cold shot - new FireStatus enum set at every branch (NO_TARGET / TARGET_UNAVAILABLE / TARGET_FULL / DIFFERENT_SYSTEM / FIRED), synced via the tile description packet and shown as a red GUI line; 4 msg.railgun.status.* lang keys Repro tests flipped to the corrected behaviour (SOP Path B): - server RailgunFiringContractTest (3): same-dim fires; registered- unloaded dest loaded on fire (destLoadedBefore:false→destLoaded:true); unloadable dest reports TARGET_UNAVAILABLE, cargo preserved - client RailgunCargoTransitE2ETest (2): same-dim fires; unloadable dest reported, cargo preserved - infra railgun-fire probe extended with destLoadedBefore + fireStatus All green (testUnit 267 / testIntegration 89 / railgun server 3 + client 2, skipped=0). Pyramid → 851. Ledger #8 fixed; TASK-49 closed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .agent/history/known-bugs-ledger.md | 17 +- .agent/tasks/README.md | 29 ++-- .../TASK-49-railgun-silent-fire-failure.md | 56 ++++-- .../command/test/TestProbeCommand.java | 33 +++- .../tile/multiblock/TileRailgun.java | 117 ++++++++++--- .../assets/advancedrocketry/lang/en_US.lang | 4 + .../client/RailgunCargoTransitE2ETest.java | 70 ++++---- .../server/RailgunFiringContractTest.java | 159 +++++++++++++----- 8 files changed, 346 insertions(+), 139 deletions(-) diff --git a/.agent/history/known-bugs-ledger.md b/.agent/history/known-bugs-ledger.md index fb270616a..02d5c4ebd 100644 --- a/.agent/history/known-bugs-ledger.md +++ b/.agent/history/known-bugs-ledger.md @@ -4,9 +4,9 @@ 2026-05-23). Batch #2 below is **live** and is kept in sync with the summary in [`../tasks/README.md`](../tasks/README.md) bug-ledger section. -**Live bug count (as of 2026-06-02)**: 5 live — Batch #2 entries -#1, #3, #5, #7, #8. Entry #2 dropped as impl-trivia, #4 fixed by TASK-41, -#6 fixed by TASK-43 Phase 3 (see per-entry notes below). +**Live bug count (as of 2026-06-03)**: 4 live — Batch #2 entries +#1, #3, #5, #7. Entry #2 dropped as impl-trivia, #4 fixed by TASK-41, +#6 fixed by TASK-43 Phase 3, #8 fixed by TASK-49 (see per-entry notes below). When a future production bug is uncovered, follow the rule in [`CLAUDE.md`](../../CLAUDE.md#bug-tracking--every-discovered-production-bug-must-be-logged) and append it to Batch #2 here AND to the README summary. @@ -256,7 +256,16 @@ authoring that have not yet been fixed. (drains an AR Forge-fluid source) and documents this in its docstring. **Found**: 2026-05-31 during TASK-44 Gap F.4 un-ignore. -8. **`TileRailgun.attemptCargoTransfer` fails silently — no player feedback +8. ✅ **FIXED 2026-06-03 by TASK-49.** `attemptCargoTransfer` now loads a + registered-but-unloaded destination dimension on fire + (`getWorld==null && isDimensionRegistered → initDimension → getWorld`, + the `TileSpaceElevator` idiom; the destination railgun's own `onLoad` + ticket sustains it after), and every non-firing outcome sets a + `FireStatus` (`NO_TARGET` / `TARGET_UNAVAILABLE` / `TARGET_FULL` / + `DIFFERENT_SYSTEM`) synced to the client and shown as a red GUI line — + no more silent no-op. Repro tests flipped to the corrected behaviour + (3 server + 2 client, green). Original description below. + **`TileRailgun.attemptCargoTransfer` fails silently — no player feedback on any failure branch; the dominant field cause is an unloaded destination dimension.** The railgun is a paired item-teleport: a source pulls a stack from its input port and dispatches it to a linked destination railgun. diff --git a/.agent/tasks/README.md b/.agent/tasks/README.md index e1c6ad7e9..1649a2e02 100644 --- a/.agent/tasks/README.md +++ b/.agent/tasks/README.md @@ -14,13 +14,15 @@ Bug-ledger history lives in ## Current state -- **Pyramid**: 850 (testUnit 267 / testIntegration 89 / - testServer 431 / testClient 63). Counter regenerated 2026-06-02 +- **Pyramid**: 851 (testUnit 267 / testIntegration 89 / + testServer 432 / testClient 63). Counter regenerated 2026-06-03 per SOP §2.5 (prior 856/288/81/426/61 headline was stale — trust - the regen, not the "+N" arithmetic). +2 testServer + 2 testClient - on 2026-06-02 from TASK-49 (issue #61 repro: - `RailgunFiringContractTest` + `RailgunCargoTransitE2ETest`). - Historical "+N" narrative below is retained for provenance only. + the regen, not the "+N" arithmetic). TASK-49 (issue #61): + +2 testServer + 2 testClient on 2026-06-02 (repro), then +1 testServer + on 2026-06-03 when the fix flipped `RailgunFiringContractTest` 2→3 + tests. Classes: `RailgunFiringContractTest` (3) + + `RailgunCargoTransitE2ETest` (2). Historical "+N" narrative below is + retained for provenance only. +1 on 2026-05-29 from TASK-40b Batch 2 (Gap F.2 GasChargePad — testClient harness fix unlocked it). +1 on 2026-05-29 from @@ -148,12 +150,13 @@ Bug-ledger history lives in Counter regenerated via `grep -rc '@Test$' src/test/java/.../{unit,integration,server,client}/`. - **testServer wall time**: 8m 27s (50 % faster than pre-B2). -- **Bug ledger**: 5 live bugs. Arithmetic: 8 entries total minus +- **Bug ledger**: 4 live bugs. Arithmetic: 8 entries total minus #4 (fixed by TASK-41 2026-05-29) minus #6 (fixed by TASK-43 Phase 3 2026-05-30) minus #2 (dropped 2026-05-31 as impl-trivia — see entry) - = 5 live (#1, #3, #5, #7, #8). Batch #2 opened 2026-05-25; entry #5 added - 2026-05-29; entry #7 added 2026-05-31; entry #8 (railgun silent - fire-failure / unloaded-dest, #61) added 2026-06-02 by TASK-49. Batch #1 fully drained by + minus #8 (fixed by TASK-49 2026-06-03) = 4 live (#1, #3, #5, #7). + Batch #2 opened 2026-05-25; entry #5 added 2026-05-29; entry #7 added + 2026-05-31; entry #8 (railgun silent fire-failure / unloaded-dest, #61) + added 2026-06-02 and FIXED 2026-06-03, both by TASK-49. Batch #1 fully drained by TASK-12 on 2026-05-23. Entries: (1) `SatelliteRegistry.getNewSatellite` returns `null` for unknown types instead of the documented `SatelliteDefunct` fallback — @@ -314,7 +317,9 @@ Bug-ledger history lives in `TilePumpFillsFromAdjacentWaterSourceTest` instead pins the real contract (drains an AR Forge-fluid source) and documents this in its docstring. Found during TASK-44 Gap F.4 un-ignore (2026-05-31). - (8) `TileRailgun.attemptCargoTransfer` fails **silently** on every + (8) ✅ **FIXED 2026-06-03 by TASK-49** (load destination dim on fire + + `FireStatus` GUI feedback). Original below. + `TileRailgun.attemptCargoTransfer` failed **silently** on every failure branch (no player feedback); the dominant field cause is a destination railgun in an **unloaded dimension** — `net.minecraftforge.common.DimensionManager.getWorld(destDim)` returns @@ -382,7 +387,7 @@ Bug-ledger history lives in | [TASK-41](TASK-41-runclient-mixin-accessorworld-bug.md) | `./gradlew runClient` mixin AccessorWorld apply error — fixed 2026-05-29 by swapping `@Accessor` for an access transformer (`public net.minecraft.world.World field_72986_A`) and direct `world.worldInfo = ...` assignment in PlanetWeatherManager. AccessorWorld mixin + mixin-config entry deleted. Added `stageMixinRefmapForRun` build task copying the AP-generated refmap into `build/resources/main/` so future @Inject mixins don't trip the same dev-classpath gap. Option C (`@Mixin(targets="...")`) tried first, failed identically — confirmed root cause was refmap-driven SRG-name lookup, not class-load ordering. Validated: runClient boots to main menu, FML loads 9 mods, testUnit + testIntegration green; testServer 423/427 PASS, 3 pre-existing recipe-registration failures unrelated to TASK-41 (logged as ledger entry #5). | ✅ | | [TASK-42](TASK-42-pre-existing-test-failures-investigation.md) | Triage of 5 pre-existing testServer + testClient failures surfaced during TASK-41 validation. Phase 0 revealed three shape buckets: 1 broken-since-inception (`InventoryBypassRedirectE2ETest` — verified at 149c361e worktree, same failure shape; @Ignore'd 2026-05-30, contract still pinned by `testUnit.RocketInventoryHelperRedirectTest`); 3 parallel-fork flakes (`Electrolyser` / `PrecisionAssembler` / `PrecisionLaserEtcher` recipe tests — PASS in isolation, FAIL only in full suite); 1 stable-isolation failure (`WorldCommandFetchModeratorTest` — fails in 3m 10s even alone, real test-design or production bug). Remaining 4 promoted to TASK-43. | ✅ | | [TASK-43](TASK-43-flaky-and-stable-test-failures.md) | Mitigate the 4 deferred TASK-42 failures across two shapes: Shape A (3 recipe tests, parallel-fork contention — plan: `wait-for-recipe-registry` probe verb + kit hook); Shape B (FetchModerator, stable-fail-in-isolation — plan: per-step bot instrumentation to bisect bridge-drop tick). **Phase 3 shipped** (2026-05-30 — `mixin.env.disableRefMap=true` fix, ledger #6 closed); Shapes A/B still open. | 🟡 Phase 3 done; A/B open | -| [TASK-49](TASK-49-railgun-silent-fire-failure.md) | Railgun silent fire-failure (#61) — repro shipped: `infra railgun-fire` probe + 2 server (`RailgunFiringContractTest`) + 2 client e2e (`RailgunCargoTransitE2ETest`) proving same-dim fires and unloaded-dest fails silently with cargo preserved. Root cause = unloaded destination dim + zero feedback (ledger #8). Production fix pending. | 🟡 Repro shipped; fix pending | +| [TASK-49](TASK-49-railgun-silent-fire-failure.md) | Railgun silent fire-failure (#61) — root cause = unloaded destination dim + zero feedback (ledger #8). Fix (Option 1): `attemptCargoTransfer` now `initDimension`s a registered-but-unloaded destination, and a `FireStatus` enum surfaces each failure cause in the GUI. Pinned by 3 server (`RailgunFiringContractTest`) + 2 client e2e (`RailgunCargoTransitE2ETest`) flipped to the corrected behaviour; `infra railgun-fire` probe extended with `destLoadedBefore`/`fireStatus`. | ✅ | | [TASK-44](TASK-44-shallow-to-deep-batch.md) | Shallow→deep conversion batch — 4 real contracts + 1 mixin-CI gap shipped: F.4 (TilePump drains Forge IFluidBlock, ledger #7), B (laser-drill MINING dispatch breaks column + drops), C (area-gravity resets fallDistance in-radius only; found controller not machine-enabled by default), N (asteroid worldprovider generates fill blocks), U (un-`@Ignore`'d `InventoryBypassRedirectE2ETest` via server-side `player open-chest` probe, ledger #6 resolved). 5 new probe verbs. Dropped per SOP: G/H/I/K/M/T (impl-only/unwired/wrong-framing). 429/430 full-suite after batch. | ✅ | ## Backlog diff --git a/.agent/tasks/TASK-49-railgun-silent-fire-failure.md b/.agent/tasks/TASK-49-railgun-silent-fire-failure.md index 9f079e72d..1256972df 100644 --- a/.agent/tasks/TASK-49-railgun-silent-fire-failure.md +++ b/.agent/tasks/TASK-49-railgun-silent-fire-failure.md @@ -6,9 +6,12 @@ "Railgun just does not fire with a linker that has the cords of another railgun"). Reported 2025-07-15 against AR 1.12.2-2.1.8 / LibVulpes ARLIB-17-09-2024. No comments, no repro detail, no stacktrace. -- Status: 🟡 **In Progress — repro shipped 2026-06-02, fix pending.** - Root cause isolated and characterized by tests; production fix not yet - written. +- Type: Bug report — confirmed. +- Priority: urgent. +- Status: ✅ **Completed 2026-06-03.** Repro shipped 2026-06-02 (server + + client); production fix (Option 1: load destination dim on fire + player + feedback) shipped 2026-06-03; repro tests flipped to the corrected + behaviour. All green. - Created: 2026-06-02. ## Context @@ -85,15 +88,40 @@ across dercodeKoenig `1.12` and zmaster587 — no fix to pull. All four green; testServer + testClient cache-busted per flake-diagnosis SOP. -## Fix plan (not yet implemented) - -1. **Resolve/load the destination dimension on fire** so Station→Planet and - Planet→Planet work regardless of player presence — either - `server.getWorld(destDim)` (Forge auto-inits) plus a transient chunk-load - of the destination, or a kept ticket. Then flip the cross-dim test's - expectation to "fires". -2. **Player feedback** on each failure cause (other dim / unloaded / no output - hatch / redstone / power) — turn the silent `false` into a clear message. +## Fix shipped (Option 1 — 2026-06-03) + +User chose **Option 1: load destination on fire + feedback** (over a +persistent destination chunk-ticket, or feedback-only). Implemented in +`TileRailgun`: + +1. **Load the destination dimension on fire.** `attemptCargoTransfer` now, + when `DimensionManager.getWorld(dimId) == null && isDimensionRegistered`, + calls `initDimension(dimId)` and re-resolves — mirroring the + `TileSpaceElevator` idiom. `getTileEntity` then loads the destination chunk; + the destination railgun's own `onLoad` ticket keeps it loaded thereafter. So + Planet→Planet / Station→Planet now work regardless of player presence, with + only a one-time load on the first cold shot. +2. **Player feedback.** New `FireStatus` enum (`IDLE`/`FIRED`/`NO_TARGET`/ + `TARGET_UNAVAILABLE`/`TARGET_FULL`/`DIFFERENT_SYSTEM`) set at every branch of + `attemptCargoTransfer`, synced to the client via the tile description packet + (`write/readNetworkData`), and rendered as a red GUI line via `ModuleText` + in `getModules`. Four new `msg.railgun.status.*` keys in `en_US.lang`. + +## Result + +Shipped 2026-06-03. Production: `TileRailgun` (dimension-load + `FireStatus` +feedback). Probe: `infra railgun-fire` extended with `destLoadedBefore` + +`fireStatus`. Tests flipped to the corrected behaviour (Path B): +- **server** `RailgunFiringContractTest` (3): same-dim fires (status FIRED); + registered-but-unloaded dest dim is loaded on fire + (`destLoadedBefore:false → destLoaded:true`); unloadable (unregistered) dest + reports `TARGET_UNAVAILABLE` with cargo preserved. +- **client** `RailgunCargoTransitE2ETest` (2): same-dim fires; unloadable dest + reports `TARGET_UNAVAILABLE`, cargo preserved. +All green (3 server + 2 client, `skipped=0`). Pyramid +1 net server (the repro +class grew 2→3). Bug-ledger #8 flipped to FIXED. Net production touch: only the +firing path + GUI status; `onReceiveCargo` / structure / receiver contract +(TASK-40, RailgunCargoReceiveContractTest) untouched. ## Out of scope / notes @@ -104,8 +132,8 @@ All four green; testServer + testClient cache-busted per flake-diagnosis SOP. ## Dependencies -- Independent. Touches only `TestProbeCommand.java` + a new test file (repro); - the fix (when done) will touch `TileRailgun.attemptCargoTransfer`. +- Independent. Repro touched `TestProbeCommand.java` + new test files; the fix + touched `TileRailgun` (firing path + GUI status) + `en_US.lang`. ## Bug ledger diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index 9eebc2459..8c064d4e2 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -6406,6 +6406,13 @@ private void handleInfra(MinecraftServer server, ICommandSender sender, String[] return; } + // Was the destination dimension loaded BEFORE firing? Issue #61's + // fix makes attemptCargoTransfer initDimension a registered-but- + // unloaded destination, so a false→true transition here proves the + // load branch ran. + boolean destLoadedBefore = + net.minecraftforge.common.DimensionManager.getWorld(dDim) != null; + // Fire: invoke the private attemptCargoTransfer() directly so the // result isolates the cargo/linker/planetary gate from the // enabled/redstone/power gating in useEnergy(). @@ -6431,14 +6438,26 @@ private void handleInfra(MinecraftServer server, ICommandSender sender, String[] + escapeJson(e.getMessage()) + "\"}"); return; } + // Read the fire status the production code just set (issue #61 + // feedback). Reflective — the field is private transient state. + String fireStatus = ""; + try { + java.lang.reflect.Field fsf = zmaster587.advancedRocketry.tile.multiblock + .TileRailgun.class.getDeclaredField("fireStatus"); + fsf.setAccessible(true); + Object fs = fsf.get(src); + fireStatus = fs == null ? "null" : fs.toString(); + } catch (ReflectiveOperationException ignored) { + // older build without the status field — leave "" + } + boolean destLoaded = false; boolean destIsRailgun = false; int destMatched = 0; - // Mirror production exactly: attemptCargoTransfer resolves the - // destination via Forge's DimensionManager.getWorld (returns null - // when the dim is unloaded). Do NOT use server.getWorld here — it - // auto-inits the dimension, which would mask the unloaded-dest - // silent-failure mode this probe is meant to surface. + // Use Forge's DimensionManager.getWorld (no auto-init) so this + // reflects the dim's ACTUAL post-fire state — i.e. whether + // production itself loaded it (issue #61 fix), not a probe side + // effect. server.getWorld would auto-init and mask that. net.minecraft.world.WorldServer dWorld = net.minecraftforge.common.DimensionManager.getWorld(dDim); if (dWorld != null) { @@ -6461,9 +6480,11 @@ private void handleInfra(MinecraftServer server, ICommandSender sender, String[] + ",\"linkerSet\":" + linkerSet + ",\"inPortCount\":" + inPortCount + ",\"srcInputRemaining\":" + srcInputRemaining + + ",\"destLoadedBefore\":" + destLoadedBefore + ",\"destLoaded\":" + destLoaded + ",\"destIsRailgun\":" + destIsRailgun - + ",\"destMatched\":" + destMatched + "}"); + + ",\"destMatched\":" + destMatched + + ",\"fireStatus\":\"" + escapeJson(fireStatus) + "\"}"); return; } if (args.length >= 5 && "astrobody-set-research".equalsIgnoreCase(args[0])) { diff --git a/src/main/java/zmaster587/advancedRocketry/tile/multiblock/TileRailgun.java b/src/main/java/zmaster587/advancedRocketry/tile/multiblock/TileRailgun.java index 8c1ac5fe2..ca8e02023 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/multiblock/TileRailgun.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/multiblock/TileRailgun.java @@ -170,6 +170,18 @@ public class TileRailgun extends TileMultiPowerConsumer implements IInventory, I } }; public long recoil; + + /** + * Why the last cargo-dispatch attempt did (not) fire. Surfaced to the + * player in the GUI (issue #61: failures used to be a silent no-op). + * Transient — recomputed every tick, synced to the client via the tile + * description packet. + */ + public enum FireStatus { + IDLE, FIRED, NO_TARGET, TARGET_UNAVAILABLE, TARGET_FULL, DIFFERENT_SYSTEM + } + + private FireStatus fireStatus = FireStatus.IDLE; private EmbeddedInventory inv; private Ticket ticket; private int minStackTransferSize = 1; @@ -245,6 +257,16 @@ public List getModules(int ID, EntityPlayer player) { modules.add(redstoneControl); + // Issue #61: surface why the railgun isn't firing instead of failing + // silently. Only the actionable failure states get a line; IDLE/FIRED + // show nothing. + if (world.isRemote && fireStatus != FireStatus.IDLE && fireStatus != FireStatus.FIRED) { + modules.add(new ModuleText(60, 55, + LibVulpes.proxy.getLocalizedString( + "msg.railgun.status." + fireStatus.name().toLowerCase()), + 0xb00000)); + } + return modules; } @@ -329,38 +351,81 @@ private boolean attemptCargoTransfer() { } } - if (!tfrStack.isEmpty()) { - BlockPos pos = getDestPosition(); - if (pos != null) { - int dimId; + if (tfrStack.isEmpty()) { + setFireStatus(FireStatus.IDLE); + return false; + } - dimId = getDestDimId(); + BlockPos pos = getDestPosition(); + int dimId = getDestDimId(); + if (pos == null || dimId == Constants.INVALID_PLANET) { + setFireStatus(FireStatus.NO_TARGET); + return false; + } - if (dimId != Constants.INVALID_PLANET) { - World world = DimensionManager.getWorld(dimId); - TileEntity tile; + // Issue #61: resolve the destination world, loading the dimension if it + // is registered but not currently loaded. The railgun only chunk-loads + // its OWN chunk (see onLoad), so a destination on an otherwise-idle + // planet used to resolve to null here and fail SILENTLY. Mirror the + // initDimension idiom used by TileSpaceElevator; once loaded, the + // destination railgun's own onLoad ticket keeps it loaded. + World destWorld = DimensionManager.getWorld(dimId); + if (destWorld == null && DimensionManager.isDimensionRegistered(dimId)) { + DimensionManager.initDimension(dimId); + destWorld = DimensionManager.getWorld(dimId); + } + if (destWorld == null) { + setFireStatus(FireStatus.TARGET_UNAVAILABLE); + return false; + } - if (world != null && (tile = world.getTileEntity(pos)) instanceof TileRailgun && ((TileRailgun) tile).canReceiveCargo(tfrStack) && - (PlanetaryTravelHelper.isTravelAnywhereInPlanetarySystem(this.world.provider.getDimension(), - zmaster587.advancedRocketry.dimension.DimensionManager.getEffectiveDimId(world, pos).getId()) || - zmaster587.advancedRocketry.dimension.DimensionManager.getEffectiveDimId(world, pos).getId() == zmaster587.advancedRocketry.dimension.DimensionManager.getEffectiveDimId(this.world, this.pos).getId())) { + TileEntity tile = destWorld.getTileEntity(pos); + if (!(tile instanceof TileRailgun)) { + setFireStatus(FireStatus.TARGET_UNAVAILABLE); + return false; + } + TileRailgun dest = (TileRailgun) tile; + + if (!dest.canReceiveCargo(tfrStack)) { + setFireStatus(FireStatus.TARGET_FULL); + return false; + } - ((TileRailgun) tile).onReceiveCargo(tfrStack); - inv2.setInventorySlotContents(index, ItemStack.EMPTY); - inv2.markDirty(); - world.notifyBlockUpdate(pos, world.getBlockState(pos), world.getBlockState(pos), 2); + int destEffective = zmaster587.advancedRocketry.dimension.DimensionManager + .getEffectiveDimId(destWorld, pos).getId(); + int srcEffective = zmaster587.advancedRocketry.dimension.DimensionManager + .getEffectiveDimId(this.world, this.pos).getId(); + if (!(PlanetaryTravelHelper.isTravelAnywhereInPlanetarySystem( + this.world.provider.getDimension(), destEffective) + || destEffective == srcEffective)) { + setFireStatus(FireStatus.DIFFERENT_SYSTEM); + return false; + } - EnumFacing dir = RotatableBlock.getFront(world.getBlockState(pos)); + dest.onReceiveCargo(tfrStack); + inv2.setInventorySlotContents(index, ItemStack.EMPTY); + inv2.markDirty(); + destWorld.notifyBlockUpdate(pos, destWorld.getBlockState(pos), destWorld.getBlockState(pos), 2); - EntityItemAbducted ent = new EntityItemAbducted(this.world, this.pos.getX() - 2 * dir.getFrontOffsetX() + 0.5f, this.pos.getY() + 5, this.pos.getZ() - 2 * dir.getFrontOffsetZ() + 0.5f, tfrStack); - this.world.spawnEntity(ent); - PacketHandler.sendToNearby(new PacketMachine(this, (byte) 3), this.world.provider.getDimension(), this.pos.getX() - dir.getFrontOffsetX(), this.pos.getY() + 5, this.pos.getZ() - dir.getFrontOffsetZ(), 64d); - return true; - } - } - } + EnumFacing dir = RotatableBlock.getFront(destWorld.getBlockState(pos)); + + EntityItemAbducted ent = new EntityItemAbducted(this.world, this.pos.getX() - 2 * dir.getFrontOffsetX() + 0.5f, this.pos.getY() + 5, this.pos.getZ() - 2 * dir.getFrontOffsetZ() + 0.5f, tfrStack); + this.world.spawnEntity(ent); + PacketHandler.sendToNearby(new PacketMachine(this, (byte) 3), this.world.provider.getDimension(), this.pos.getX() - dir.getFrontOffsetX(), this.pos.getY() + 5, this.pos.getZ() - dir.getFrontOffsetZ(), 64d); + setFireStatus(FireStatus.FIRED); + return true; + } + + /** + * Update the cached fire status and, on a real change server-side, push a + * tile resync so an open GUI reflects it (issue #61 feedback). + */ + private void setFireStatus(FireStatus status) { + if (this.fireStatus != status) { + this.fireStatus = status; + if (!world.isRemote) + world.notifyBlockUpdate(pos, world.getBlockState(pos), world.getBlockState(pos), 2); } - return false; } public boolean canReceiveCargo(@Nonnull ItemStack stack) { @@ -536,6 +601,7 @@ protected void writeNetworkData(NBTTagCompound nbt) { super.writeNetworkData(nbt); nbt.setByte("state", (byte) state.ordinal()); nbt.setInteger("minTfrSize", minStackTransferSize); + nbt.setByte("fireStatus", (byte) fireStatus.ordinal()); } @Override @@ -544,6 +610,7 @@ protected void readNetworkData(NBTTagCompound nbt) { state = RedstoneState.values()[nbt.getByte("redstoneState")]; redstoneControl.setRedstoneState(state); minStackTransferSize = nbt.getInteger("minTfrSize"); + fireStatus = FireStatus.values()[nbt.getByte("fireStatus")]; } @Override diff --git a/src/main/resources/assets/advancedrocketry/lang/en_US.lang b/src/main/resources/assets/advancedrocketry/lang/en_US.lang index 8ec78fcf6..69d734209 100644 --- a/src/main/resources/assets/advancedrocketry/lang/en_US.lang +++ b/src/main/resources/assets/advancedrocketry/lang/en_US.lang @@ -512,6 +512,10 @@ msg.gravitycontroller.activeset=Add Force (combine directions) + lift msg.gravitycontroller.targetdir.1=Target-> msg.gravitycontroller.targetdir.2=Direction msg.railgun.transfermin=Min Transfer Size +msg.railgun.status.no_target=No destination set - link to another railgun +msg.railgun.status.target_unavailable=Destination railgun not found +msg.railgun.status.target_full=Destination output is full or has no output hatch +msg.railgun.status.different_system=Destination is in a different planetary system msg.spacelaser.reset=Reset msg.spacelaser.notarget1=No target found! msg.spacelaser.notarget2=Go down and survey the area! diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/RailgunCargoTransitE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/RailgunCargoTransitE2ETest.java index 0b7db5c4e..a54914f05 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/RailgunCargoTransitE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/RailgunCargoTransitE2ETest.java @@ -11,26 +11,23 @@ /** * Issue #61 ("[BUG] Railgun does not work") — client e2e for the railgun * cargo-transit mechanic, the mandatory player-truth guard required by - * {@code sops/development/bug-report-workflow.md}. + * {@code sops/development/bug-report-workflow.md}. Pins the FIXED behaviour + * (TASK-49) with a REAL client connected. * *

      The railgun is a paired item TELEPORT: a source railgun pulls a stack - * from its input port and dispatches it to a linked destination railgun, - * whose {@code onReceiveCargo} deposits it in the output port + * from its input port and dispatches it to a linked destination railgun * ({@link zmaster587.advancedRocketry.tile.multiblock.TileRailgun#attemptCargoTransfer}). * {@link zmaster587.advancedRocketry.test.server.RailgunFiringContractTest} - * pins the same contracts on a dedicated server; THIS test re-pins them with - * a REAL client connected to the server, so a client/server desync in the - * teleport path (cargo that moves server-side but never syncs to a connected - * player) would surface here where the server-only test is blind.

      + * pins these contracts on a dedicated server (plus the registered-but-unloaded + * dimension-load branch, a server-internal mechanism); THIS test re-pins the + * player-visible ones with a live client, so a client/server desync in the + * teleport path would surface here where the server-only test is blind.

      * *

      Per the AR client-test convention (see * {@code GasChargePadFillsPressureTankE2ETest}), setup and observation run * through server-side {@code artest} probes; the client bot is the live - * harness anchor. The new {@code artest infra railgun-fire} probe drives the - * source-side path and reports where the cargo ended up.

      - * - *

      Headless: runs under {@code xvfb-run} / a dedicated {@code DISPLAY}; - * auto-skips when no display is available.

      + * harness anchor. Headless: runs under {@code xvfb-run} / a dedicated + * {@code DISPLAY}; auto-skips when no display is available.

      */ public class RailgunCargoTransitE2ETest extends AbstractClientE2ETest { @@ -42,9 +39,8 @@ public class RailgunCargoTransitE2ETest extends AbstractClientE2ETest { private static final int DY = 64; private static final int DZ = 100; - /** Not registered/loaded on the harness server → production - * {@code net.minecraftforge.common.DimensionManager.getWorld(id)} is null. */ - private static final int UNLOADED_DIM = 31337; + /** Not registered on the harness server → production cannot load it. */ + private static final int UNREGISTERED_DIM = 31337; private static final int CARGO = 16; @@ -54,10 +50,14 @@ public class RailgunCargoTransitE2ETest extends AbstractClientE2ETest { Pattern.compile("\"destMatched\":(\\d+)"); private static final Pattern SRC_REMAINING = Pattern.compile("\"srcInputRemaining\":(\\d+)"); + private static final Pattern FIRE_STATUS = + Pattern.compile("\"fireStatus\":\"([A-Z_]+)\""); + private static final Pattern DEST_LOADED = + Pattern.compile("\"destLoaded\":(true|false)"); /** - * Same-dimension shot fires with a real client connected: cargo leaves - * the source input and arrives at the destination output — the + * Same-dimension shot fires with a real client connected: cargo leaves the + * source input and arrives at the destination output (status FIRED) — the * player-visible "railgun works" contract for #61. */ @Test @@ -76,6 +76,8 @@ public void cargoTransitsBetweenLinkedRailgunsClientSide() throws Exception { assertTrue("railgun MUST fire to a linked railgun in the same dimension " + "with a client connected (issue #61 baseline); fire=" + fire, "true".equals(extractStr(fire, FIRED))); + assertTrue("status must read FIRED after a successful shot; fire=" + fire, + "FIRED".equals(extractStr(fire, FIRE_STATUS))); int destMatched = extractInt(fire, DEST_MATCHED); assertTrue("destination output port must contain >= " + CARGO @@ -89,40 +91,42 @@ public void cargoTransitsBetweenLinkedRailgunsClientSide() throws Exception { } /** - * The #61 root-cause mode under a live client: firing at a destination in - * an unloaded dimension is a SILENT no-op and the cargo is preserved. + * Under a live client, a genuinely unavailable destination (an unregistered + * dimension that cannot be loaded) does NOT fire and REPORTS the reason + * (TARGET_UNAVAILABLE) — the #61 fix's "no more silent no-op" — with the + * cargo preserved. */ @Test - public void railgunDoesNotFireToUnloadedDestinationClientSide() throws Exception { + public void railgunReportsUnavailableForUnloadableDestinationClientSide() throws Exception { bot().waitForWorld(); forceloadFootprints(); buildAndComplete(SX, SY, SZ); String fire = exec("artest infra railgun-fire 0 " + SX + " " + SY + " " + SZ - + " " + UNLOADED_DIM + " 0 64 0 minecraft:cobblestone " + CARGO); + + " " + UNREGISTERED_DIM + " 0 64 0 minecraft:cobblestone " + CARGO); assertTrue("railgun-fire probe must succeed: " + fire, fire.contains("\"ok\":true")); - assertTrue("railgun must NOT fire when the destination dimension is " - + "unloaded (issue #61 root cause); fire=" + fire, + assertTrue("railgun must NOT fire at an unloadable (unregistered) " + + "destination; fire=" + fire, "false".equals(extractStr(fire, FIRED))); - assertTrue("destination dimension must be reported unloaded; fire=" + fire, - fire.contains("\"destLoaded\":false")); + assertTrue("unregistered dim cannot be loaded → destLoaded:false; " + + "fire=" + fire, "false".equals(extractStr(fire, DEST_LOADED))); + assertTrue("status must report TARGET_UNAVAILABLE (not a silent no-op); " + + "fire=" + fire, + "TARGET_UNAVAILABLE".equals(extractStr(fire, FIRE_STATUS))); int srcRemaining = extractInt(fire, SRC_REMAINING); - assertTrue("cargo must be preserved in the source input on a failed " - + "shot (remaining=" + srcRemaining + " expected " + CARGO - + "); fire=" + fire, + assertTrue("cargo must be preserved on a failed shot (remaining=" + + srcRemaining + " expected " + CARGO + "); fire=" + fire, srcRemaining == CARGO); } // -- helpers ---------------------------------------------------------- - /** Force-load the chunks covering both railgun footprints so the - * dedicated server can place + tick them (they sit well outside the - * spawn-loaded area). Footprint per railgun: x[cx-4..cx+4], - * z[cz-1..cz+7]; both share z-chunk 6, x-chunks 6..10. */ + /** Force-load the chunks covering both railgun footprints so the dedicated + * server can place + tick them. */ private void forceloadFootprints() throws Exception { for (int cx = 5; cx <= 11; cx++) { for (int cz = 5; cz <= 7; cz++) { @@ -149,7 +153,7 @@ private String exec(String cmd) throws Exception { private static String extractStr(String src, Pattern pattern) { Matcher m = pattern.matcher(src); - assertTrue("pattern not found in: " + src, m.find()); + assertTrue("pattern " + pattern + " not found in: " + src, m.find()); return m.group(1); } diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/RailgunFiringContractTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/RailgunFiringContractTest.java index eb939a119..b62329102 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/RailgunFiringContractTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/RailgunFiringContractTest.java @@ -1,5 +1,6 @@ package zmaster587.advancedRocketry.test.server; +import org.junit.Assume; import org.junit.Test; import java.util.regex.Matcher; @@ -8,23 +9,21 @@ import static org.junit.Assert.assertTrue; /** - * Issue #61 ("[BUG] Railgun does not work") — source-side firing contract. + * Issue #61 ("[BUG] Railgun does not work") — source-side firing contract, + * now pinning the FIXED behaviour (TASK-49). * - *

      The reporter said the railgun "just does not fire with a linker that has - * the cords of another railgun". {@link RailgunCargoReceiveContractTest} only - * pins the receiver endpoint on a SOLO railgun; nothing exercised the full - * source-side path - * ({@link zmaster587.advancedRocketry.tile.multiblock.TileRailgun#attemptCargoTransfer}), - * which needs TWO assembled railguns at linked positions.

      + *

      The railgun is a paired item TELEPORT: a source railgun pulls a stack + * from its input port and dispatches it to a linked destination railgun, + * whose {@code onReceiveCargo} deposits it in the output port + * ({@link zmaster587.advancedRocketry.tile.multiblock.TileRailgun#attemptCargoTransfer}).

      * - *

      This test builds two railguns in the SAME dimension, programs a libVulpes - * Linker on the source pointing at the destination controller, loads a cargo - * stack into the source's input port, and drives {@code attemptCargoTransfer} - * via the {@code artest infra railgun-fire} probe. The basic same-dimension - * case MUST fire: cargo leaves the source input and lands in the destination - * output. If this passes, the field report is an environmental failure - * (destination dimension unloaded, missing output hatch, redstone, or power) — - * not a logic bug in the firing gate; if it fails, the gate itself is broken.

      + *

      The #61 fix does two things: (1) when the destination dimension is + * registered but not currently loaded, {@code attemptCargoTransfer} now + * {@code initDimension}s it (the railgun only chunk-loads its OWN chunk, so a + * receiver on an idle planet used to resolve to null and fail SILENTLY); and + * (2) every non-firing outcome now sets a {@code FireStatus} surfaced to the + * player, instead of a silent no-op. These tests pin all of that. A live + * client variant lives in {@code RailgunCargoTransitE2ETest}.

      * *

      Position-isolated at x=4900 (source) / x=4960 (destination) — clear of * RailgunMultiblockTest (x=4500..4560) and RailgunCargoReceiveContractTest @@ -40,14 +39,16 @@ public class RailgunFiringContractTest extends AbstractSharedServerTest { private static final int DY = 64; private static final int DZ = 4900; - // Separate source for the cross-dimension case (shared server JVM). + // Separate sources for the cross-dimension cases (shared server JVM). private static final int UX = 5020; - private static final int UZ = 4900; + private static final int LX = 5080; + private static final int XZ = 4900; - /** An id that is not registered/loaded on the test server, so production's - * {@code net.minecraftforge.common.DimensionManager.getWorld(id)} returns - * null — the exact unloaded-destination condition behind issue #61. */ - private static final int UNLOADED_DIM = 31337; + /** An id that is not registered on the harness server, so production cannot + * load it — the genuinely-unavailable destination case. */ + private static final int UNREGISTERED_DIM = 31337; + /** Fresh asteroid dim id for the registered-but-unloaded case. */ + private static final int FRESH_DIM = 60931; private static final int CARGO = 16; @@ -57,7 +58,19 @@ public class RailgunFiringContractTest extends AbstractSharedServerTest { Pattern.compile("\"destMatched\":(\\d+)"); private static final Pattern SRC_REMAINING = Pattern.compile("\"srcInputRemaining\":(\\d+)"); + private static final Pattern FIRE_STATUS = + Pattern.compile("\"fireStatus\":\"([A-Z_]+)\""); + private static final Pattern DEST_LOADED_BEFORE = + Pattern.compile("\"destLoadedBefore\":(true|false)"); + private static final Pattern DEST_LOADED = + Pattern.compile("\"destLoaded\":(true|false)"); + private static final Pattern AR_DIMS_ARRAY = + Pattern.compile("\"arDimensions\":\\[([^]]*)]"); + /** + * Same-dimension shot fires: cargo leaves the source input and arrives at + * the destination output, and the status reads FIRED. + */ @Test public void railgunFiresCargoToLinkedRailgunInSameDimension() throws Exception { buildAndComplete(SX, SY, SZ); @@ -68,9 +81,10 @@ public void railgunFiresCargoToLinkedRailgunInSameDimension() throws Exception { assertTrue("railgun-fire probe must succeed: " + fire, fire.contains("\"ok\":true")); - assertTrue("railgun MUST fire to a linked railgun in the same dimension " - + "(issue #61 baseline); fire=" + fire, - "true".equals(extractStr(fire, FIRED))); + assertTrue("railgun MUST fire to a linked railgun in the same dimension; " + + "fire=" + fire, "true".equals(extractStr(fire, FIRED))); + assertTrue("status must read FIRED after a successful shot; fire=" + fire, + "FIRED".equals(extractStr(fire, FIRE_STATUS))); int destMatched = extractInt(fire, DEST_MATCHED); assertTrue("destination output port must contain >= " + CARGO @@ -84,40 +98,95 @@ public void railgunFiresCargoToLinkedRailgunInSameDimension() throws Exception { } /** - * Issue #61 — the most likely field failure: the destination railgun is in - * a dimension that is not currently loaded (e.g. sender on planet A, - * receiver on planet B, player standing on A). Production resolves the - * destination with {@code net.minecraftforge.common.DimensionManager - * .getWorld(destDim)}, which returns null for an unloaded dim; the railgun - * only chunk-loads its OWN chunk, never the destination's. The result is a - * SILENT no-op: nothing fires, no feedback. This test characterizes that - * behavior — and crucially pins that cargo is NOT lost when the shot fails. + * The #61 fix: firing at a destination dimension that is registered but + * not loaded now LOADS it (instead of silently bailing). A fresh asteroid + * dim is registered-and-unloaded; after the shot it is loaded + * (destLoadedBefore=false → destLoaded=true). No railgun exists there, so + * the shot still doesn't deliver and reports TARGET_UNAVAILABLE — but the + * dimension-load branch is proven, which (composed with the same-dimension + * delivery test) is the cross-planet firing the bug was about. + */ + @Test + public void railgunLoadsRegisteredButUnloadedDestinationDimension() throws Exception { + int template = firstNonOverworldArDimOrSkip(); + String create = exec("artest worldgen create-asteroid-dim " + + FRESH_DIM + " " + template); + assertTrue("create-asteroid-dim must succeed: " + create, + create.contains("\"ok\":true")); + + buildAndComplete(LX, SY, XZ); + + String fire = exec("artest infra railgun-fire 0 " + LX + " " + SY + " " + XZ + + " " + FRESH_DIM + " 0 64 0 minecraft:cobblestone " + CARGO); + assertTrue("railgun-fire probe must succeed: " + fire, + fire.contains("\"ok\":true")); + + Assume.assumeTrue("destination dim was already loaded — can't prove the " + + "load branch; fire=" + fire, + "false".equals(extractStr(fire, DEST_LOADED_BEFORE))); + assertTrue("firing at a registered-but-unloaded dim MUST load it " + + "(issue #61 fix); fire=" + fire, + "true".equals(extractStr(fire, DEST_LOADED))); + // No railgun at the target → no delivery, reported (not silent). + assertTrue("no railgun at the freshly-loaded target → must not fire; " + + "fire=" + fire, "false".equals(extractStr(fire, FIRED))); + assertTrue("status must report TARGET_UNAVAILABLE; fire=" + fire, + "TARGET_UNAVAILABLE".equals(extractStr(fire, FIRE_STATUS))); + + int srcRemaining = extractInt(fire, SRC_REMAINING); + assertTrue("cargo must be preserved when nothing is delivered " + + "(remaining=" + srcRemaining + "); fire=" + fire, + srcRemaining == CARGO); + } + + /** + * A genuinely unavailable destination (an unregistered dim that cannot be + * loaded) does NOT fire and now REPORTS it (TARGET_UNAVAILABLE) instead of + * the old silent no-op — and the cargo is preserved. */ @Test - public void railgunSilentlyFailsWhenDestinationDimensionUnloaded() throws Exception { - buildAndComplete(UX, SY, UZ); + public void railgunReportsUnavailableForUnloadableDestination() throws Exception { + buildAndComplete(UX, SY, XZ); - String fire = exec("artest infra railgun-fire 0 " + UX + " " + SY + " " + UZ - + " " + UNLOADED_DIM + " 0 64 0 minecraft:cobblestone " + CARGO); + String fire = exec("artest infra railgun-fire 0 " + UX + " " + SY + " " + XZ + + " " + UNREGISTERED_DIM + " 0 64 0 minecraft:cobblestone " + CARGO); assertTrue("railgun-fire probe must succeed: " + fire, fire.contains("\"ok\":true")); - assertTrue("railgun must NOT fire when the destination dimension is " - + "unloaded (issue #61 root cause); fire=" + fire, - "false".equals(extractStr(fire, FIRED))); - assertTrue("destination dimension must be reported unloaded " - + "(production getWorld returns null); fire=" + fire, - fire.contains("\"destLoaded\":false")); + assertTrue("must NOT fire at an unloadable (unregistered) destination; " + + "fire=" + fire, "false".equals(extractStr(fire, FIRED))); + assertTrue("unregistered dim cannot be loaded → destLoaded:false; " + + "fire=" + fire, "false".equals(extractStr(fire, DEST_LOADED))); + assertTrue("status must report TARGET_UNAVAILABLE (not a silent no-op); " + + "fire=" + fire, + "TARGET_UNAVAILABLE".equals(extractStr(fire, FIRE_STATUS))); int srcRemaining = extractInt(fire, SRC_REMAINING); - assertTrue("cargo must be preserved in the source input on a failed " - + "shot — never silently consumed (remaining=" + assertTrue("cargo must be preserved on a failed shot (remaining=" + srcRemaining + " expected " + CARGO + "); fire=" + fire, srcRemaining == CARGO); } // -- helpers ---------------------------------------------------------- + /** First registered non-overworld AR dimension, to clone as an asteroid + * template; skips the test if none exist on the harness. */ + private int firstNonOverworldArDimOrSkip() throws Exception { + String joined = exec("artest dim list"); + Assume.assumeFalse("No AR dimensions registered — skipping", + joined.contains("\"arDimensions\":[]")); + Matcher m = AR_DIMS_ARRAY.matcher(joined); + assertTrue("could not parse arDimensions: " + joined, m.find()); + for (String part : m.group(1).split(",")) { + String t = part.trim(); + if (t.isEmpty()) continue; + int dim = Integer.parseInt(t); + if (dim != 0 && dim != FRESH_DIM) return dim; + } + Assume.assumeTrue("Only overworld registered — skipping", false); + return -1; + } + private void buildAndComplete(int x, int y, int z) throws Exception { String fixture = exec("artest fixture multiblock railgun 0 " + x + " " + y + " " + z); @@ -136,7 +205,7 @@ private static String exec(String cmd) throws Exception { private static String extractStr(String src, Pattern pattern) { Matcher m = pattern.matcher(src); - assertTrue("pattern not found in: " + src, m.find()); + assertTrue("pattern " + pattern + " not found in: " + src, m.find()); return m.group(1); } From c5ee89d16293b73667ae61333eb02e4b806b5334 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 3 Jun 2026 14:11:13 +0200 Subject: [PATCH 28/47] docs: SOP for issue-reference discipline (never bare #NN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new sops/development/issue-reference-discipline.md: in this GitHub fork network (Advanced-Rocketry root → StannisMod → dercodeKoenig) a bare #NN leaks to the root and notifies unrelated 2015 issues; always fully-qualify as owner/AdvancedRocketry#NN using the owner from the issue link the user gave (StannisMod or dercodeKoenig), never the root - linked from DEVELOPMENT-README required-reading + bug-report-workflow Co-Authored-By: Claude Opus 4.8 (1M context) --- .agent/DEVELOPMENT-README.md | 12 +++ .../sops/development/bug-report-workflow.md | 2 + .../development/issue-reference-discipline.md | 81 +++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 .agent/sops/development/issue-reference-discipline.md diff --git a/.agent/DEVELOPMENT-README.md b/.agent/DEVELOPMENT-README.md index b3a1a6b6d..6c0df49db 100644 --- a/.agent/DEVELOPMENT-README.md +++ b/.agent/DEVELOPMENT-README.md @@ -27,6 +27,18 @@ fix now, flip the repro tests to the corrected contract, close the task. **Session-start duty**: scan `tasks/` for open `Type: Bug report — confirmed` tasks and offer to fix them. +### Before writing any issue reference (commit / PR / TASK / ledger) + +**[SOP: Issue-reference discipline](./sops/development/issue-reference-discipline.md)** — +read before referencing an issue number anywhere. + +**TL;DR**: this repo is in a GitHub fork network +(`Advanced-Rocketry` root → `StannisMod` → `dercodeKoenig`). A **bare +`#NN` leaks to the root** and notifies unrelated 2015-era issues — +never use it. **Always fully-qualify** as `owner/AdvancedRocketry#NN` +using the `owner/repo` from the issue link the user gave you (it may be +`StannisMod` or `dercodeKoenig`). Never reference the root. + ### Before writing or auditing tests **[SOP: Testing Principles](./sops/development/testing-principles.md)** — diff --git a/.agent/sops/development/bug-report-workflow.md b/.agent/sops/development/bug-report-workflow.md index 6053fb2ef..c53903df2 100644 --- a/.agent/sops/development/bug-report-workflow.md +++ b/.agent/sops/development/bug-report-workflow.md @@ -133,6 +133,8 @@ own). Then: ## Related +- `issue-reference-discipline.md` — how to write the issue number + (never bare `#NN`; fully-qualify per the original issue link). - `testing-principles.md` — contract-vs-impl test design. - `flake-diagnosis.md` — cache-bust + distribution diagnosis. - `task-lifecycle.md` — status values + closure checklist. diff --git a/.agent/sops/development/issue-reference-discipline.md b/.agent/sops/development/issue-reference-discipline.md new file mode 100644 index 000000000..a09482614 --- /dev/null +++ b/.agent/sops/development/issue-reference-discipline.md @@ -0,0 +1,81 @@ +# SOP: Issue-reference discipline — never bare `#NN`, never the fork root + +## Why this SOP exists + +This project lives in a GitHub **fork network**, and a bare `#NN` +issue reference does NOT point where you think. Bare references in +commit messages / PR text leaked to the network **root** +(`Advanced-Rocketry/AdvancedRocketry`) and notified participants of +unrelated **2015** issues there — including a stranger who showed up +on PR #22 thinking we'd "fixed" his decade-old closed issue. + +## The fork network (know it cold) + +``` +Advanced-Rocketry/AdvancedRocketry ← network ROOT (source). issues ON. + │ The original. 2015-era issues live here. + │ We NEVER reference this repo. + └─ StannisMod/AdvancedRocketry ← our fork. PRs live here (e.g. #22). + │ base of our work. + └─ dercodeKoenig/AdvancedRocketry ← active fork. issues ON. + Most bug reports we fix + are filed here. +``` + +A given bug report may come from **either** `StannisMod/AdvancedRocketry` +**or** `dercodeKoenig/AdvancedRocketry`. It is never the root. + +## How GitHub resolves a bare `#NN` (the trap) + +A bare `#NN` in a commit message or PR body resolves to an issue/PR in +the repository it's *attached to*, and in a fork network GitHub +attributes commit references up the network — to the **root** when the +attached fork can't host it. So a bare `#NN` will land on +`Advanced-Rocketry/AdvancedRocketry#NN` (a 2015 issue), NOT on the +dercodeKoenig / StannisMod issue you meant. GitHub never resolves a +bare `#NN` to a *sibling/child* fork like dercodeKoenig, so you can +**never** hit the intended tracker with a bare reference. + +## The rule + +**Always fully-qualify issue references, using the `owner/repo` from +the issue link the user gave you.** + +- Form: `dercodeKoenig/AdvancedRocketry#NN` or + `StannisMod/AdvancedRocketry#NN` (or the full + `https://github.com//AdvancedRocketry/issues/NN` URL). +- The `owner/repo` is dictated by the **original issue link** in the + request — if the user pointed you at a dercodeKoenig issue, reference + dercodeKoenig; if a StannisMod issue, reference StannisMod. +- **Never** write a bare `#NN`. Even now that StannisMod has issues + enabled, a bare `#NN` resolves to the *wrong* repo (StannisMod's own + numbering, or the root) — not to the tracker the report came from. +- **Never** reference `Advanced-Rocketry/AdvancedRocketry`. The root is + off-limits; we do not look at it for issue numbers. + +Applies everywhere a number can autolink: **commit messages, PR title +and body, TASK files, the bug ledger, code comments.** + +## Examples + +``` +# WRONG — leaks to Advanced-Rocketry/AdvancedRocketry#76 (2015 "AR liquids texture") +fix: guard the JEI gas-giant-refresh call so it loads without JEI (#76) + +# RIGHT — points at the report we actually fixed +fix: guard the JEI gas-giant-refresh call so it loads without JEI (dercodeKoenig/AdvancedRocketry#76) +``` + +## Damage control for already-pushed bare refs + +Cross-references already created in the root cannot be un-sent, and +rewriting history to "fix" them risks re-notifying on re-push — so +leave pushed commits as-is and just qualify everything going forward. +PR **body/title** text, however, can be edited freely (editing PR prose +creates no commit cross-reference): replace bare `#NN` with the +fully-qualified form in place, touching nothing else. + +## Related + +- `bug-report-workflow.md` — the pipeline that produces these refs. +- `task-lifecycle.md` — TASK files carry the qualified reference. From e6ed0f0284cf047f163382498f694f24cab4725f Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 3 Jun 2026 22:05:30 +0200 Subject: [PATCH 29/47] feat: client key injection + ridden-entity report (v0.4.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two ClientBot capabilities needed to test client-side input/render behaviour end-to-end (not just server-authoritative state read back via probes): - setKey(keyCode, pressed) / holdKey / releaseKey: injects a real KeyBinding press/release on the client (KeyBinding.setKeyBindState + an isPressed edge via onTick), so mod input handlers polling key state on ClientTickEvent fire their real packet path. - reportRidingEntity(): client-side view of the entity the player is riding (class, id, pos, motion) — the authoritative way to assert what the client actually renders, catching position-sync / interpolation regressions. Bridge handlers run on the client thread like the rest. Version 0.4.2 -> 0.4.3. --- build.gradle | 2 +- .../forge/testing/client/ClientBot.java | 39 +++++++++++++++++++ .../bridge/ForgeTestClientBootstrap.java | 34 ++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index d56afd0a8..e00bdc407 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.github.stannismod.forge' -version = '0.4.2' +version = '0.4.3' base { archivesName = 'forge-test-framework' diff --git a/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java b/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java index f54f45b25..e538ca7bf 100644 --- a/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java +++ b/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java @@ -156,6 +156,45 @@ public JsonObject reportState() throws IOException { return assertOk(execute(command("report_state"))); } + /** + * Client-side view of the entity the player is currently riding. Reports + * {@code riding} (bool), and when riding: {@code entityClass}, {@code entityId}, + * {@code posX}/{@code posY}/{@code posZ} and {@code motionX}/{@code motionY}/ + * {@code motionZ}. This is the authoritative way to assert what the player's + * CLIENT actually renders — distinct from a server-side entity query — so it + * catches client-side position-sync / interpolation regressions. + */ + public JsonObject reportRidingEntity() throws IOException { + return assertOk(execute(command("report_riding_entity"))); + } + + /** + * Injects a real key-binding press/release on the client, exactly as the + * keyboard would. Drives {@code KeyBinding.isKeyDown()} (held movement keys) + * and a single {@code isPressed()} edge, so mod input handlers that poll key + * state on {@code ClientTickEvent}/{@code KeyInputEvent} fire their real + * packet path — not a server-side shortcut. + * + * @param keyCode LWJGL key code (e.g. {@link org.lwjgl.input.Keyboard#KEY_Z}) + * @param pressed true to hold the key down, false to release it + */ + public void setKey(int keyCode, boolean pressed) throws IOException { + JsonObject command = command("set_key"); + command.addProperty("keyCode", keyCode); + command.addProperty("pressed", pressed); + assertOk(execute(command)); + } + + /** Convenience: hold a key down ({@link #setKey(int, boolean) setKey(keyCode, true)}). */ + public void holdKey(int keyCode) throws IOException { + setKey(keyCode, true); + } + + /** Convenience: release a key ({@link #setKey(int, boolean) setKey(keyCode, false)}). */ + public void releaseKey(int keyCode) throws IOException { + setKey(keyCode, false); + } + /** * Client-side view of vanilla weather state for whatever dim the player is * currently in. Reports {@code dim}, {@code worldInfoClass}, {@code isRaining}, diff --git a/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java b/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java index 7f1138138..b62370eb6 100644 --- a/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java +++ b/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java @@ -436,6 +436,40 @@ private static JsonObject handleCommand(JsonObject request) { } return response; }); + case "report_riding_entity": + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + JsonObject response = ok(); + net.minecraft.entity.Entity ridden = + mc.player == null ? null : mc.player.getRidingEntity(); + response.addProperty("riding", ridden != null); + if (ridden != null) { + response.addProperty("entityClass", ridden.getClass().getName()); + response.addProperty("entityId", ridden.getEntityId()); + response.addProperty("posX", ridden.posX); + response.addProperty("posY", ridden.posY); + response.addProperty("posZ", ridden.posZ); + response.addProperty("motionX", ridden.motionX); + response.addProperty("motionY", ridden.motionY); + response.addProperty("motionZ", ridden.motionZ); + } + return response; + }); + case "set_key": + return runOnClientThread(() -> { + int keyCode = requireInt(request, "keyCode"); + boolean pressed = request.has("pressed") && request.get("pressed").getAsBoolean(); + // Drive the binding's held-state (isKeyDown) and, on press, a + // single isPressed() edge via onTick — mirroring a real key. + net.minecraft.client.settings.KeyBinding.setKeyBindState(keyCode, pressed); + if (pressed) { + net.minecraft.client.settings.KeyBinding.onTick(keyCode); + } + JsonObject response = ok(); + response.addProperty("keyCode", keyCode); + response.addProperty("pressed", pressed); + return response; + }); case "report_weather": // Client-side view of vanilla weather state for whatever // dimension the client is currently in. Reports what the From c97a7e8593579b74e3aff58b26ca8bfa6ffb6ffe Mon Sep 17 00:00:00 2001 From: StannisMod Date: Thu, 4 Jun 2026 07:30:31 +0200 Subject: [PATCH 30/47] feat: reflective client static-field reader (v0.4.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ClientBot.readStaticField(className, fieldName): reflectively reads a static field on the client and returns String.valueOf(value) (+ isNull, type). Lets a test assert arbitrary client-side mod state — rendered HUD text, render flags, etc. — without the framework depending on the mod. Version 0.4.3 -> 0.4.4. --- build.gradle | 2 +- .../forge/testing/client/ClientBot.java | 16 ++++++++++++++++ .../bridge/ForgeTestClientBootstrap.java | 19 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index e00bdc407..52ae2e4b1 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.github.stannismod.forge' -version = '0.4.3' +version = '0.4.4' base { archivesName = 'forge-test-framework' diff --git a/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java b/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java index e538ca7bf..760da1d6b 100644 --- a/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java +++ b/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java @@ -195,6 +195,22 @@ public void releaseKey(int keyCode) throws IOException { setKey(keyCode, false); } + /** + * Reflectively reads a static field on the client and returns its + * {@code String.valueOf(...)} as {@code value} (plus {@code isNull}, + * {@code type}). Lets a test assert arbitrary client-side mod state (HUD + * text, render flags, …) without the framework depending on the mod. + * + * @param className fully-qualified class name (loaded on the client classpath) + * @param fieldName a static field on that class or a superclass + */ + public JsonObject readStaticField(String className, String fieldName) throws IOException { + JsonObject command = command("read_static_field"); + command.addProperty("className", className); + command.addProperty("fieldName", fieldName); + return assertOk(execute(command)); + } + /** * Client-side view of vanilla weather state for whatever dim the player is * currently in. Reports {@code dim}, {@code worldInfoClass}, {@code isRaining}, diff --git a/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java b/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java index b62370eb6..ee2c37fa0 100644 --- a/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java +++ b/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java @@ -470,6 +470,25 @@ private static JsonObject handleCommand(JsonObject request) { response.addProperty("pressed", pressed); return response; }); + case "read_static_field": + return runOnClientThread(() -> { + String className = requireString(request, "className"); + String fieldName = requireString(request, "fieldName"); + JsonObject response = ok(); + try { + Class clazz = Class.forName(className); + java.lang.reflect.Field field = findField(clazz, fieldName); + field.setAccessible(true); + Object value = field.get(null); + response.addProperty("isNull", value == null); + response.addProperty("value", value == null ? "" : String.valueOf(value)); + response.addProperty("type", value == null ? "null" : value.getClass().getName()); + } catch (Throwable t) { + throw new IllegalStateException("read_static_field(" + className + "#" + + fieldName + ") failed: " + t, t); + } + return response; + }); case "report_weather": // Client-side view of vanilla weather state for whatever // dimension the client is currently in. Reports what the From 25fa6cd87e1789c2755642a142399d1f96260a2a Mon Sep 17 00:00:00 2001 From: StannisMod Date: Thu, 4 Jun 2026 10:52:03 +0200 Subject: [PATCH 31/47] Add client look injection + rotation reporting (0.4.5) ClientBot.setLook(yaw, pitch) drives the real EntityPlayerSP look (current and prev-tick fields) so tests can exercise mouse-aimed controls through the genuine client path rather than a server probe. report_state now reports playerYaw/playerPitch, and report_riding_entity reports the ridden entity's rotationYaw/rotationPitch, making camera-binding and look-tracking behaviour observable client-side. --- build.gradle | 2 +- .../forge/testing/client/ClientBot.java | 18 +++++++++++++ .../bridge/ForgeTestClientBootstrap.java | 25 +++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 52ae2e4b1..0946125ee 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.github.stannismod.forge' -version = '0.4.4' +version = '0.4.5' base { archivesName = 'forge-test-framework' diff --git a/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java b/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java index 760da1d6b..1ccf8a34c 100644 --- a/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java +++ b/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java @@ -195,6 +195,24 @@ public void releaseKey(int keyCode) throws IOException { setKey(keyCode, false); } + /** + * Sets the client player's look direction, exactly as the mouse would after + * accumulating movement. Drives {@code EntityPlayerSP.rotationYaw/rotationPitch} + * (and the prev-tick fields, so there is no render interpolation jump), so mod + * code that reads the player's look on {@code ClientTickEvent} (e.g. a flight + * controller that aims a craft at where the pilot is looking) exercises its + * real path — not a server-side shortcut. + * + * @param yaw absolute yaw in degrees + * @param pitch absolute pitch in degrees (negative = up, MC convention) + */ + public void setLook(float yaw, float pitch) throws IOException { + JsonObject command = command("set_look"); + command.addProperty("yaw", yaw); + command.addProperty("pitch", pitch); + assertOk(execute(command)); + } + /** * Reflectively reads a static field on the client and returns its * {@code String.valueOf(...)} as {@code value} (plus {@code isNull}, diff --git a/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java b/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java index ee2c37fa0..9b99367d4 100644 --- a/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java +++ b/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java @@ -426,6 +426,8 @@ private static JsonObject handleCommand(JsonObject request) { response.addProperty("playerX", mc.player.posX); response.addProperty("playerY", mc.player.posY); response.addProperty("playerZ", mc.player.posZ); + response.addProperty("playerYaw", mc.player.rotationYaw); + response.addProperty("playerPitch", mc.player.rotationPitch); response.addProperty("health", mc.player.getHealth()); response.addProperty("heldItem", mc.player.getHeldItemMainhand().isEmpty() ? "" @@ -452,9 +454,32 @@ private static JsonObject handleCommand(JsonObject request) { response.addProperty("motionX", ridden.motionX); response.addProperty("motionY", ridden.motionY); response.addProperty("motionZ", ridden.motionZ); + response.addProperty("rotationYaw", ridden.rotationYaw); + response.addProperty("rotationPitch", ridden.rotationPitch); } return response; }); + case "set_look": + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + float yaw = request.get("yaw").getAsFloat(); + float pitch = request.get("pitch").getAsFloat(); + JsonObject response = ok(); + if (mc.player != null) { + // Set both current and prev so the look snaps without a + // render-interpolation sweep — mirrors an instantaneous aim. + mc.player.rotationYaw = yaw; + mc.player.prevRotationYaw = yaw; + mc.player.rotationPitch = pitch; + mc.player.prevRotationPitch = pitch; + response.addProperty("applied", true); + } else { + response.addProperty("applied", false); + } + response.addProperty("yaw", yaw); + response.addProperty("pitch", pitch); + return response; + }); case "set_key": return runOnClientThread(() -> { int keyCode = requireInt(request, "keyCode"); From 0d9d7d05bc4297fc6bdb53306b17e619884c2ec9 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 10 Jun 2026 10:36:57 +0200 Subject: [PATCH 32/47] fix: reseed rain/thunder strength after wrapping planet WorldInfo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorldServer's constructor runs calculateInitialWeather() against the vanilla DerivedWorldInfo — whose isRaining() delegates to the overworld — BEFORE MixinWorldServerMulti installs the per-dim wrapper. A planet world (re)created while the overworld rains is therefore born with rainingStrength=1.0 even though its per-dim weather is clear; the per-tick lerp then drags it back to 0, streaming SPacketChangeGameState(7) to every player entering the dim — a ~5 s phantom-rain fade on arrival (mostly hidden behind the loading screen on first-generation visits, which is why upstream looked immune in manual tests). Reseed the strength fields from the wrapped (effective) state right after the swap. Field writes, not setters: setRainStrength/setThunderStrength are @SideOnly(CLIENT) and absent on a dedicated server. Pins: - WeatherBaselineTest now asserts both planets (lazily constructed while the overworld rains) report rainStrength/thunderStrength 0.0 — red before this fix (read back 1.0). - WeatherClientSyncE2ETest gains a phantom-fade leg: rain the overworld, teleport the real client to a never-constructed planet, client-visible rainStrength must hold at exactly 0 across the would-be fade window. Also tightens the A→B arrival check (strength, not just the flag) and fixes the comment mis-attributing the rain ramp to a client-side lerp (WorldClient.updateWeather() is empty in 1.12.2 — the ramp is the server lerp streamed one packet per tick). --- .../world/weather/PlanetWeatherManager.java | 18 +++++ .../test/client/WeatherClientSyncE2ETest.java | 66 +++++++++++++++++-- .../test/server/WeatherBaselineTest.java | 16 +++++ 3 files changed, 94 insertions(+), 6 deletions(-) diff --git a/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java b/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java index e1a332e46..5e696e42e 100644 --- a/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java +++ b/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java @@ -187,6 +187,24 @@ public static void wrapWorldInfoIfNeeded(WorldServer world) { world.worldInfo = wrapped; + // The WorldServer constructor ran calculateInitialWeather() BEFORE this + // wrapper existed, against the vanilla DerivedWorldInfo — whose + // isRaining() delegates to the OVERWORLD. A planet world (re)created + // while the overworld rains is therefore born with rainingStrength=1.0 + // even though its per-dim weather says clear; the per-tick lerp then + // pulls it back down, streaming a ~5 s "phantom rain" fade + // (SPacketChangeGameState 7) to every player entering the dim. + // Re-run the initial-weather seeding against the wrapped (effective) + // state so the strengths match it from tick one. Direct field writes: + // World.setRainStrength/setThunderStrength are @SideOnly(CLIENT) and + // do not exist on a dedicated server. + float rain = wrapped.isRaining() ? 1.0F : 0.0F; + float thunder = wrapped.isRaining() && wrapped.isThundering() ? 1.0F : 0.0F; + world.prevRainingStrength = rain; + world.rainingStrength = rain; + world.prevThunderingStrength = thunder; + world.thunderingStrength = thunder; + if (ARConfiguration.getCurrentConfig().logPlanetWeatherWrapping) { LOGGER.info("Wrapped WorldInfo for AR planet dim={} provider={}", dim, diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/WeatherClientSyncE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/WeatherClientSyncE2ETest.java index 4a79d75b9..6a7fdbff8 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/WeatherClientSyncE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/WeatherClientSyncE2ETest.java @@ -14,6 +14,7 @@ import java.nio.file.Files; import java.nio.file.Path; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -44,6 +45,12 @@ public class WeatherClientSyncE2ETest { private static final int DIM_A = 9301; private static final int DIM_B = 9302; + /** + * Deliberately NEVER touched by console probes before the phantom-fade leg + * of the test: its WorldServer must be constructed mid-teleport (while the + * overworld is raining) to exercise the constructor-seeding path. + */ + private static final int DIM_C = 9303; private Path workDir; private RealDedicatedServerHarness serverHarness; @@ -67,9 +74,10 @@ public void startBoth() throws Exception { + "\n" + " \n" + + " numPlanets=\"3\" numGasGiants=\"0\">\n" + planetXml("ClientPlanetA", DIM_A) + planetXml("ClientPlanetB", DIM_B) + + planetXml("ClientPlanetC", DIM_C) + " \n" + "\n"; Files.write(arConfigDir.resolve("planetDefs.xml"), xml.getBytes(StandardCharsets.UTF_8)); @@ -195,6 +203,11 @@ public void weatherIsolatedAcrossDimsThroughRealClient() throws Exception { assertFalse("client-visible isRaining must be FALSE on dim B (isolation across " + "teleport — A→B must not carry A's rain): " + onB, onB.get("isRaining").getAsBoolean()); + // Not just the flag: an end-raining packet alone leaves the client at + // strength 1.0 (vanilla code-2 semantics). The transfer sync must zero + // the strength too, or the player keeps seeing A's rain on B. + assertEquals("client rainStrength must be 0 on clear dim B: " + onB, + 0f, onB.get("rainStrength").getAsFloat(), 0f); // Server-side wrapper guarantees on dim B persist too. String getBAgain = String.join("\n", @@ -203,6 +216,45 @@ public void weatherIsolatedAcrossDimsThroughRealClient() throws Exception { getBAgain.contains("ARDimensionWorldInfo")); assertFalse("server-side dim B must remain clear: " + getBAgain, getBAgain.contains("\"isRaining\":true")); + + // ── Phantom-fade regression: fresh world constructed under overworld + // rain. Vanilla /weather (and our artest equivalent) flags the + // OVERWORLD; dim C's WorldServer does not exist yet and is only + // constructed mid-teleport — at which point its constructor runs + // calculateInitialWeather() against the pre-wrap DerivedWorldInfo and + // seeds rainingStrength from the raining overworld. Without the + // post-wrap reseed the client renders a ~5 s rain fade on arrival. + String setOver = String.join("\n", serverHarness.client().execute( + "artest weather set 0 rain 12000")); + assertTrue("set rain on overworld failed: " + setOver, setOver.contains("\"ok\":true")); + + serverHarness.client().execute("artest tp " + DIM_C); + waitForClientDim(DIM_C); + + // Sample across the would-be fade window (~5 s = 100 ticks): the + // client-visible strength must hold at exactly 0 the whole time. A + // single non-zero sample means the seeded strength leaked to the + // client (either via the transfer sync or the per-tick + // SPacketChangeGameState(7) stream from the server lerp). + for (int sample = 0; sample < 6; sample++) { + JsonObject onC = clientHarness.bot().reportWeather(); + assertTrue("client should be in dim C (sample " + sample + "): " + onC, + onC.has("dim") && onC.get("dim").getAsInt() == DIM_C); + assertFalse("client must not see rain on fresh clear dim C (sample " + + sample + "): " + onC, + onC.get("isRaining").getAsBoolean()); + assertEquals("client rainStrength must hold at 0 on fresh dim C (sample " + + sample + "): " + onC, + 0f, onC.get("rainStrength").getAsFloat(), 0f); + clientHarness.bot().waitTicks(20); + } + + // The overworld itself must still be raining — dim C staying dry must + // come from per-dim isolation, not from the rain set having failed. + String overAfter = String.join("\n", + serverHarness.client().execute("artest weather get 0")); + assertTrue("overworld should still be raining: " + overAfter, + overAfter.contains("\"isRaining\":true")); } /** @@ -225,11 +277,13 @@ private void waitForClientDim(int expectedDim) throws Exception { } /** - * After SPacketChangeGameState (begin raining + rain strength) is - * received, {@code World.rainingStrength} starts lerping toward 1.0 at - * +0.01/tick. Poll briefly so the test isn't flaky on the exact tick of - * the snapshot — settling above {@code minStrength} confirms the rain - * packet actually reached and is being applied client-side. + * The client does NOT lerp weather itself in 1.12.2 + * ({@code WorldClient.updateWeather()} is an empty override) — the + * client-visible ramp is the SERVER's lerp streamed one + * {@code SPacketChangeGameState(7)} per tick to in-dim players. Poll + * briefly so the test isn't flaky on the exact tick of the snapshot — + * settling above {@code minStrength} confirms the rain packets actually + * reach and apply client-side. */ private JsonObject waitForClientRainStrengthAtLeast(float minStrength) throws Exception { JsonObject latest = clientHarness.bot().reportWeather(); diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/WeatherBaselineTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/WeatherBaselineTest.java index 244abd53d..777ceb5e6 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/WeatherBaselineTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/WeatherBaselineTest.java @@ -111,5 +111,21 @@ public void weatherPropagationMatchesExpectedMode() throws Exception { // tick simply didn't propagate weather yet), and we'd ship a regression. assertTrue("planet A is NOT wrapped: " + wA, wA.contains("ARDimensionWorldInfo")); assertTrue("planet B is NOT wrapped: " + wB, wB.contains("ARDimensionWorldInfo")); + + // Strength must match the wrapped per-dim state from tick one. Both + // planet worlds were lazily constructed by the `weather get` probes + // above — i.e. WHILE the overworld was raining — and the WorldServer + // constructor seeds rainingStrength from the pre-wrap DerivedWorldInfo + // (the overworld's flag). Without the post-wrap reseed in + // wrapWorldInfoIfNeeded these worlds are born at strength 1.0 and + // stream a ~5 s phantom-rain fade to every arriving player. + assertTrue("planet A born with non-zero rainStrength (seeded from raining overworld): " + wA, + wA.contains("\"rainStrength\":0.0,")); + assertTrue("planet B born with non-zero rainStrength (seeded from raining overworld): " + wB, + wB.contains("\"rainStrength\":0.0,")); + assertTrue("planet A born with non-zero thunderStrength: " + wA, + wA.contains("\"thunderStrength\":0.0")); + assertTrue("planet B born with non-zero thunderStrength: " + wB, + wB.contains("\"thunderStrength\":0.0")); } } From 234546fd8bc2dd4b6a5d08fb8e6ae96a64a74f7c Mon Sep 17 00:00:00 2001 From: jchung01 Date: Wed, 10 Jun 2026 10:37:12 +0200 Subject: [PATCH 33/47] fix: redirect vanilla /weather to per-dim AR variant on planets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vanilla CommandWeather hard-codes server.worlds[0]: run while standing on an AR planet it silently mutates the OVERWORLD's weather and leaves the planet untouched — with per-dim weather enabled there was no command-level way to change a planet's weather at all (the enableCustomPlanetWeather config docstring already promised otherwise). Port the CommandEvent redirect from the upstream per-dim weather PR (the /advancedrocketry weather subcommand itself was already ported): when the sender stands on an AR planet, cancel vanilla /weather and re-issue it as /advancedrocketry weather with the same arguments. --- .../weather/PlanetWeatherEventHandler.java | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherEventHandler.java b/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherEventHandler.java index c16cf5696..c1fef4a6e 100644 --- a/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherEventHandler.java +++ b/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherEventHandler.java @@ -1,13 +1,20 @@ package zmaster587.advancedRocketry.world.weather; +import net.minecraft.command.CommandWeather; +import net.minecraft.command.ICommandSender; import net.minecraft.entity.player.EntityPlayerMP; +import net.minecraft.server.MinecraftServer; import net.minecraft.world.WorldServer; +import net.minecraftforge.event.CommandEvent; import net.minecraftforge.event.world.WorldEvent; import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; import net.minecraftforge.fml.common.gameevent.PlayerEvent; +import zmaster587.advancedRocketry.world.provider.WorldProviderPlanet; + +import java.util.StringJoiner; /** - * Two responsibilities: + * Three responsibilities: * *

        *
      1. Wrap fallback. {@link MixinWorldServerMulti} is the primary wrap @@ -22,10 +29,33 @@ * explicit syncs below cover the gaps and make the client-visible * weather match the wrapped {@link net.minecraft.world.storage.WorldInfo} * of whichever dimension the player is actually in.
      2. + *
      3. {@code /weather} redirect. Vanilla {@code CommandWeather} + * hard-codes {@code server.worlds[0]} — run on a planet it silently + * mutates the OVERWORLD and leaves the planet untouched. Redirect it to + * the per-dimension {@code /advancedrocketry weather} when the sender + * stands on an AR planet.
      4. *
      */ public final class PlanetWeatherEventHandler { + @SubscribeEvent + public void redirectWeatherCommand(CommandEvent event) { + if (!(event.getCommand() instanceof CommandWeather)) return; + ICommandSender sender = event.getSender(); + if (!(sender.getEntityWorld().provider instanceof WorldProviderPlanet)) return; + MinecraftServer server = sender.getServer(); + if (server == null) return; + + StringJoiner redirected = new StringJoiner(" "); + redirected.add("advancedrocketry").add("weather"); + for (String param : event.getParameters()) { + redirected.add(param); + } + + event.setCanceled(true); + server.getCommandManager().executeCommand(sender, redirected.toString()); + } + @SubscribeEvent public void onWorldLoad(WorldEvent.Load event) { if (event.getWorld() instanceof WorldServer) { From 82797ca920de1d046dbe7c1f554f805eaf939b9a Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 10 Jun 2026 12:28:41 +0200 Subject: [PATCH 34/47] =?UTF-8?q?docs:=20file=20TASK-50=20=E2=80=94=20dire?= =?UTF-8?q?ctional=20gravity=20+=20camera=20feature=20request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records kaduvill's experimental directional-gravity prototype (EntityLivingBase move/jump/look hooks + EntityRenderer.orientCamera, ~95% commented out upstream) as a backlog feature request. The hook skeleton left our tree with ClassTransformer's deletion in 877d1495; the prototype remains readable at c1c791d3 and the commented-out math still sits in client/ClientHelper.java. Surfaced by the 2026-06-10 kaduvill-port audit — never functional upstream, so a feature request rather than a porting regression. --- .agent/knowledge/graph.json | 58 +++++++++-- .agent/tasks/README.md | 1 + ...ectional-gravity-camera-feature-request.md | 99 +++++++++++++++++++ 3 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 .agent/tasks/TASK-50-directional-gravity-camera-feature-request.md diff --git a/.agent/knowledge/graph.json b/.agent/knowledge/graph.json index 27bb89fce..e1e99ca7e 100644 --- a/.agent/knowledge/graph.json +++ b/.agent/knowledge/graph.json @@ -1,9 +1,9 @@ { "version": "1.0.0", - "last_updated": "2026-06-02T10:26:34.379623Z", + "last_updated": "2026-06-10T12:28:26.822412Z", "stats": { - "total_nodes": 120, - "total_edges": 418, + "total_nodes": 121, + "total_edges": 423, "memory_count": 8 }, "nodes": { @@ -636,6 +636,18 @@ "database", "context" ] + }, + "TASK-50": { + "path": ".agent/tasks/TASK-50-directional-gravity-camera-feature-request.md", + "title": "Directional Gravity + Camera Rotation (feature request)", + "status": "backlog", + "concepts": [ + "api", + "testing", + "context", + "deployment", + "database" + ] } }, "system": {}, @@ -3742,6 +3754,31 @@ "from": "TASK-48", "to": "context", "type": "implements" + }, + { + "from": "TASK-50", + "to": "deployment", + "type": "implements" + }, + { + "from": "TASK-50", + "to": "api", + "type": "implements" + }, + { + "from": "TASK-50", + "to": "testing", + "type": "implements" + }, + { + "from": "TASK-50", + "to": "context", + "type": "implements" + }, + { + "from": "TASK-50", + "to": "database", + "type": "implements" } ], "concept_index": { @@ -3831,7 +3868,8 @@ "TASK-40", "TASK-45", "TASK-46", - "TASK-48" + "TASK-48", + "TASK-50" ], "knowledge": [ "TASK-09", @@ -3994,7 +4032,8 @@ "TASK-44", "TASK-45", "TASK-46", - "TASK-47" + "TASK-47", + "TASK-50" ], "backend": [ "TASK-09", @@ -4078,7 +4117,8 @@ "2026-05-20-2030_task04-terraformer-orbitallaser", "TASK-42", "TASK-43", - "TASK-48" + "TASK-48", + "TASK-50" ], "markers": [ "TASK-09", @@ -4280,7 +4320,8 @@ "TASK-44", "TASK-45", "TASK-46", - "TASK-47" + "TASK-47", + "TASK-50" ], "deployment": [ "TASK-03", @@ -4319,7 +4360,8 @@ "TASK-45", "TASK-46", "TASK-47", - "TASK-48" + "TASK-48", + "TASK-50" ], "frontend": [ "TASK-03", diff --git a/.agent/tasks/README.md b/.agent/tasks/README.md index 1649a2e02..bfbce6388 100644 --- a/.agent/tasks/README.md +++ b/.agent/tasks/README.md @@ -403,6 +403,7 @@ entry is an actionable TASK with a defined plan + acceptance. | [TASK-46](TASK-46-compatibilitymgr-vestigial.md) | `CompatibilityMgr` is vestigial — `compat` instance never read, mod-presence flags written-but-not-read / set-by-uncalled-method, `reloadRecipes()` commented out. Kept on purpose (may regain meaning); decide revive vs remove. | 🟡 Backlog — not started | Found 2026-06-01 during the #76 JEI-ref audit. | | [TASK-47](TASK-47-per-dim-time-and-sleep.md) | Beds skip no time on planets (#66). Root cause: derived `WorldInfo.setWorldTime` is a no-op so the sleep skip is swallowed; `rotationalPeriod ≠ 24000` means vanilla's 24000-rounding misses planetary dawn. Fix: per-dim time owned by the custom WorldInfo (renamed `ARDimensionWorldInfo`) + `MixinWorldServer` sleep-site rounding to `rotationalPeriod`. | ✅ Shipped 2026-06-02 | Live bot-sleep e2e not covered (no sleeping-player harness). | | [TASK-48](TASK-48-per-dim-worldinfo-delegation.md) | Make other `DerivedWorldInfo` state per-dimension that vanilla forces to the overworld: GameRules (sharpest — `doDaylightCycle`/`doWeatherCycle` still shared after TASK-47), spawn point, difficulty, terrain type, game type. Weather (shipped) + time (TASK-47) are the precedent. | 🟦 Feature request — not urgent, needs design | Research spun off TASK-47 on 2026-06-02. | +| [TASK-50](TASK-50-directional-gravity-camera-feature-request.md) | Directional gravity + camera rotation — resurrect kaduvill's experimental ASM prototype (`EntityLivingBase` move/jump/look hooks + `EntityRenderer.orientCamera`, ~95% commented out upstream) on the Mixin platform. Hook skeleton was deleted with `ClassTransformer` in `877d1495`; prototype readable at `c1c791d3` (kaduvill tip); dead math still in tree as commented-out `client/ClientHelper.java`. | 🟦 Feature request — not urgent, needs design | Recorded 2026-06-10 from the kaduvill-port audit; never functional upstream, so no regression pressure. | ## Conscious non-goals diff --git a/.agent/tasks/TASK-50-directional-gravity-camera-feature-request.md b/.agent/tasks/TASK-50-directional-gravity-camera-feature-request.md new file mode 100644 index 000000000..57ad13863 --- /dev/null +++ b/.agent/tasks/TASK-50-directional-gravity-camera-feature-request.md @@ -0,0 +1,99 @@ +# TASK-50: Directional Gravity + Camera Rotation (feature request) + +**Status**: 📋 Backlog (feature request — not started) +**Created**: 2026-06-10 +**Assignee**: Manual + +--- + +## Context + +**Problem**: +AR planets only support scalar gravity (a per-dimension multiplier applied by +`MixinEntityGravity` → `GravityHandler.applyGravity`). The upstream kaduvill +tree carried the skeleton of a much bigger experimental feature: **directional +gravity** — gravity pulling along an arbitrary axis (walls/ceiling as "down"), +with matching player movement physics and a camera that rotates so the chosen +gravity direction looks like "down". + +The feature was never finished upstream (~95% of it was commented out), and we +deliberately did not port the dead code during the ASM→Mixin migration. This +task records where the prototype lives so it can be revisited. + +**Goal**: +Decide whether to resurrect directional gravity; if yes, re-implement it on the +Mixin platform using the upstream prototype as the design reference. + +--- + +## Where the prototype lives (forensics) + +**Removed from our tree in commit `877d1495`** +("refactor: restore Mixin platform over PR ASM coremod") — it deleted +`src/main/java/zmaster587/advancedRocketry/asm/ClassTransformer.java`, which +carried the hook skeleton. View it with: + +```bash +git show 877d1495^:src/main/java/zmaster587/advancedRocketry/asm/ClassTransformer.java +# identical copy in the upstream tip: +git show c1c791d3:src/main/java/zmaster587/advancedRocketry/asm/ClassTransformer.java +``` + +Prototype inventory (all in upstream tip `c1c791d3`, PR base `280dd59b`): + +| Piece | Location | State upstream | +|---|---|---| +| `EntityLivingBase` hooks: `moveEntity`, `moveFlying`, `jump`, `moveEntityWithHeading`, `getLookVec` + injected `gravRotation` field (1=N, 2=E, 3=S, 4=W, 5=up) | `ClassTransformer.java` lines ~308–462 | ~95% commented out; only field injection + ctor init were active | +| `EntityRenderer.orientCamera` hooks → `ClientHelper.transformCamera()/transformCamera2()` | `ClassTransformer.java` lines ~465–532 | fully commented out | +| Actual math (movement transforms, jump vector, modified look vector, camera transform) | `client/ClientHelper.java` | fully commented out; **the dead file still exists in our working tree** (`src/main/java/zmaster587/advancedRocketry/client/ClientHelper.java`, body commented) | + +Conclusion from the June 2026 port audit: this was experimental and +non-functional even upstream — dropping it was NOT a porting regression. It is +a feature request, not a lost feature. + +--- + +## Acceptance Criteria (if/when picked up) + +- [ ] A living entity on a dimension/zone with directional gravity accelerates + along the configured axis (not just -Y). +- [ ] Player movement (walk, jump, flying drift) is consistent with the rotated + gravity frame. +- [ ] Camera orients so the gravity axis reads as "down"; HUD stays usable. +- [ ] Scalar per-dimension gravity (existing `MixinEntityGravity` behaviour) + is unchanged when no direction override is set. + +--- + +## Implementation sketch (Mixin platform) + +- `gravRotation` per-entity state: capability or `EntityDataManager` parameter + instead of ASM field injection. +- `@Mixin(EntityLivingBase)` for `travel`/`jump`/`getLookVec` (1.12.2 names: + `travel(FFF)`, `jump()`, `getLook(F)`) replacing the commented ASM hooks. +- Client: `@Mixin(EntityRenderer)` around `orientCamera(F)` for the camera + transform; resurrect the math from the commented `ClientHelper`. +- Add behavioural pins to `MixinHookBehaviourPinsTest` + a client e2e via the + FTF bot (real key injection + client-side readback per SOP). + +--- + +## Out of Scope + +- PlusTiC Portly rocket yaw compat (separate small item; transformer dropped in + `6a0dd09b`, config stub still at `ARConfiguration.java:62`). +- Any change to scalar gravity behaviour or `GravityHandler`. + +--- + +## Refs + +- Removal commit: `877d1495` (ClassTransformer deleted; "J21 anchor moot") +- Upstream prototype: `c1c791d3` (kaduvill/1.12 tip, merged for attribution in + `6d011231`, PR #70), PR base `280dd59b` +- Dead math file still in tree: `src/main/java/zmaster587/advancedRocketry/client/ClientHelper.java` +- Port audit that surfaced this: June 2026 kaduvill-port analysis (fix/various) + +--- + +**Last Updated**: 2026-06-10 From 87312747af89b3dfe16fa2a36d45532cd64774db Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 10 Jun 2026 13:25:57 +0200 Subject: [PATCH 35/47] build: compile vendored testframework/ in the test source set Wires the subtree into the build: testframework/src/main/java joins the test source set (same RFG-patched dev classpath the tests link against, which is what the :dev classifier provided), and the published com.github.stannismod.forge:forge-test-framework artifact leaves the dependency list together with the sibling-checkout publishToMavenLocal setup step. Verified: testUnit, testIntegration, server harness (WeatherBaselineTest) and real-client e2e (WeatherClientSyncE2ETest) all green with the framework served from classes dirs. --- build.gradle | 18 +++++++++++------- settings.gradle | 8 ++++---- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/build.gradle b/build.gradle index db31f8a81..c2ef762d9 100644 --- a/build.gradle +++ b/build.gradle @@ -142,17 +142,21 @@ dependencies { } } if (propertyBool('enable_junit_testing')) { - // JUnit 4 + the reusable Forge 1.12.2 test framework (see src/test/README.md). - // The :dev classifier is REQUIRED: the Forge dev workspace links against - // MCP-named MC classes; the reobf (no-classifier) jar has SRG names and - // won't compile against the dev classpath. - // Resolution: composite build when -PuseLocalFramework=true and - // ../ForgeTestFramework exists (settings.gradle), else mavenLocal. testImplementation 'junit:junit:4.13.2' - testImplementation 'com.github.stannismod.forge:forge-test-framework:0.4.2:dev' } } +if (propertyBool('enable_junit_testing')) { + // The Forge 1.12.2 test framework is vendored as a git subtree under + // testframework/ (see testframework/TEST_FRAMEWORK.md) and compiled as + // part of the test source set, against the same RFG-patched MCP-named MC + // classes the tests link against. This replaces the published + // com.github.stannismod.forge:forge-test-framework::dev artifact and + // its clone-sibling + publishToMavenLocal setup step. The external repo + // (github.com/StannisMod/ForgeTestFramework) lives on for other consumers. + sourceSets.test.java.srcDir 'testframework/src/main/java' +} + apply from: 'gradle/scripts/dependencies.gradle' // Adds Access Transformer files to tasks diff --git a/settings.gradle b/settings.gradle index ce984b809..dcc3c9bab 100644 --- a/settings.gradle +++ b/settings.gradle @@ -24,7 +24,7 @@ plugins { // rootProject.name = archives_base_name rootProject.name = rootProject.projectDir.getName() -// ForgeTestFramework is resolved from mavenLocal — publish it once with -// `./gradlew publishToMavenLocal` from a sibling ../ForgeTestFramework checkout. -// (Composite build via includeBuild is incompatible with RetroFuturaGradle's -// dependency-variant transforms, so the mavenLocal path is used instead.) \ No newline at end of file +// ForgeTestFramework is vendored as a git subtree under testframework/ and +// compiled inside the test source set (build.gradle) — no sibling checkout, +// no publishToMavenLocal, no composite build (the latter is incompatible +// with RetroFuturaGradle's dependency-variant transforms anyway). \ No newline at end of file From 64a1bca296b08ca92b9d1bd698d3337b6d2670aa Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 10 Jun 2026 13:36:44 +0200 Subject: [PATCH 36/47] test: e2e the /weather redirect via a new send_chat bot probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testframework: ClientBot.sendChat routes one chat line through EntityPlayerSP.sendChatMessage (the real CPacketChatMessage path), so the server handles the command with a PLAYER sender — permissions, the sender's dimension and CommandEvent hooks all run their production path. Console-driven commands can't reproduce sender-position-dependent behaviour; this probe unlocks that whole test class. WeatherCommandRedirectE2ETest: a real client standing on an AR planet types vanilla /weather rain; asserts the planet's per-dim state flips to raining, the client renders the rain (flag past the 0.2 strength threshold the begin-raining broadcast keys on), and the OVERWORLD stays clear — the assertion that fails without the CommandEvent redirect (vanilla CommandWeather writes to server.worlds[0]). Reverse /weather clear leg included. Red-proven: with the redirect reverted the planet never starts raining and the test fails. --- .../client/WeatherCommandRedirectE2ETest.java | 236 ++++++++++++++++++ .../forge/testing/client/ClientBot.java | 15 ++ .../bridge/ForgeTestClientBootstrap.java | 14 ++ 3 files changed, 265 insertions(+) create mode 100644 src/test/java/zmaster587/advancedRocketry/test/client/WeatherCommandRedirectE2ETest.java diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/WeatherCommandRedirectE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/WeatherCommandRedirectE2ETest.java new file mode 100644 index 000000000..9d57655b5 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/client/WeatherCommandRedirectE2ETest.java @@ -0,0 +1,236 @@ +package zmaster587.advancedRocketry.test.client; + +import com.github.stannismod.forge.testing.client.RealClientHarness; +import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; +import com.github.stannismod.forge.testing.junit.AbstractHeadlessServerTest; +import com.github.stannismod.forge.testing.server.RealDedicatedServerHarness; +import com.google.gson.JsonObject; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * E2e regression guard for the vanilla {@code /weather} → per-dim + * {@code /advancedrocketry weather} redirect + * ({@code PlanetWeatherEventHandler.redirectWeatherCommand}). + * + *

      Why a real client + real chat. The bug this guards is + * sender-position-dependent: vanilla {@code CommandWeather} hard-codes + * {@code server.worlds[0]}, so a player standing on an AR planet who runs + * {@code /weather rain} silently rains the OVERWORLD and leaves the planet + * untouched. A console-driven command cannot reproduce that — the console + * sender stands in the overworld. The framework's {@code send_chat} probe + * routes through {@code EntityPlayerSP.sendChatMessage} (the real + * {@code CPacketChatMessage} path), so the server handles the command with the + * planet-standing player as sender and the {@code CommandEvent} redirect runs + * its production path.

      + * + *

      Lifecycle is reproduced inline rather than via {@link AbstractClientE2ETest} + * for the same reason as {@code WeatherClientSyncE2ETest}: the planet fixture + * XML must exist in the workdir BEFORE the server boots.

      + */ +public class WeatherCommandRedirectE2ETest { + + private static final int DIM = 9304; + /** Must match the framework's single-client default username — the op grant keys on it. */ + private static final String PLAYER = "ForgeTestClient"; + + private Path workDir; + private RealDedicatedServerHarness serverHarness; + private RealClientHarness clientHarness; + + @Before + public void startBoth() throws Exception { + Assume.assumeTrue( + "Server harness disabled — set -D" + AbstractHeadlessServerTest.PROP_HARNESS_ENABLED + "=true", + Boolean.parseBoolean(System.getProperty( + AbstractHeadlessServerTest.PROP_HARNESS_ENABLED, "false"))); + Assume.assumeTrue( + "Client harness disabled — set -D" + AbstractClientE2ETest.PROP_CLIENT_ENABLED + "=true", + Boolean.parseBoolean(System.getProperty( + AbstractClientE2ETest.PROP_CLIENT_ENABLED, "false"))); + + workDir = Files.createTempDirectory("forge-client-weather-redirect-"); + Path arConfigDir = workDir.resolve("config").resolve("advRocketry"); + Files.createDirectories(arConfigDir); + String xml = "\n" + + "\n" + + " \n" + + " \n" + + " true\n" + + " 0.5,0.5,0.5\n" + + " 0.4,0.6,0.9\n" + + " 100\n" + + " 100\n" + + " 0\n" + + " 0\n" + + " false\n" + + " 250\n" + + " 24000\n" + + " 100\n" + + " false\n" + + " true\n" + + " false\n" + + " \n" + + " \n" + + "\n"; + Files.write(arConfigDir.resolve("planetDefs.xml"), xml.getBytes(StandardCharsets.UTF_8)); + + serverHarness = RealDedicatedServerHarness.startWith(workDir, /*cleanupOnClose=*/false); + try { + clientHarness = RealClientHarness.start(serverHarness); + } catch (Exception startupException) { + try { + serverHarness.close(); + } catch (Exception cleanup) { + startupException.addSuppressed(cleanup); + } + serverHarness = null; + throw startupException; + } + } + + @After + public void stopBoth() throws Exception { + Exception deferred = null; + if (clientHarness != null) { + try { + clientHarness.close(); + } catch (Exception e) { + deferred = e; + } + clientHarness = null; + } + if (serverHarness != null) { + try { + serverHarness.close(); + } catch (Exception e) { + if (deferred == null) deferred = e; + else deferred.addSuppressed(e); + } + serverHarness = null; + } + if (deferred != null) throw deferred; + } + + @Test + public void slashWeatherOnPlanetRainsThePlanetNotTheOverworld() throws Exception { + clientHarness.bot().waitForWorld(); + + // /weather (and the redirect target /advancedrocketry weather) require + // permission level 2 — grant it the way a server admin would. + serverHarness.client().execute("op " + PLAYER); + + // Known baseline: both dims explicitly clear. The set/get probes also + // load + pin the planet dim before the teleport. + serverHarness.client().execute("artest weather set 0 clear 12000"); + serverHarness.client().execute("artest weather set " + DIM + " clear 12000"); + String before = String.join("\n", + serverHarness.client().execute("artest weather get " + DIM)); + assertTrue("planet must be wrapped before the command test: " + before, + before.contains("ARDimensionWorldInfo")); + assertFalse("planet must start clear: " + before, + before.contains("\"isRaining\":true")); + + serverHarness.client().execute("artest tp " + DIM); + waitForClientDim(DIM); + + // The player — standing on the planet — types vanilla /weather rain. + clientHarness.bot().sendChat("/weather rain 600"); + + // Server truth: the PLANET's per-dim state flips to raining... + JsonObject planetAfter = waitForServerRaining(DIM, true); + assertTrue("planet did not start raining after player /weather rain " + + "(redirect to /advancedrocketry weather missing?): " + planetAfter, + planetAfter.get("raw").getAsString().contains("\"isRaining\":true")); + + // ...and the OVERWORLD stays clear. Without the redirect vanilla + // CommandWeather writes to server.worlds[0] — this is the assertion + // that fails on the unfixed build. + String overworld = String.join("\n", + serverHarness.client().execute("artest weather get 0")); + assertFalse("player /weather rain on a planet leaked to the overworld " + + "(vanilla worlds[0] path, redirect not applied): " + overworld, + overworld.contains("\"isRaining\":true")); + + // Player truth: the client in the planet dim renders the rain the + // command asked for. Strength streams per tick (code 7); the + // begin-raining FLAG (code 1) is only broadcast when the server-side + // strength crosses the isRaining() threshold (> 0.2), so wait past + // that before asserting the flag. + JsonObject onPlanet = waitForClientRainStrengthAtLeast(0.25f); + assertTrue("client should still be in the planet dim: " + onPlanet, + onPlanet.has("dim") && onPlanet.get("dim").getAsInt() == DIM); + assertTrue("client-visible isRaining must flip true on the planet: " + onPlanet, + onPlanet.get("isRaining").getAsBoolean()); + assertTrue("client rainStrength must start climbing on the planet: " + onPlanet, + onPlanet.get("rainStrength").getAsFloat() > 0f); + + // Reverse direction: /weather clear from the same spot clears the + // planet (and the overworld stays untouched — still clear). + clientHarness.bot().sendChat("/weather clear 600"); + waitForServerRaining(DIM, false); + String overworldAfterClear = String.join("\n", + serverHarness.client().execute("artest weather get 0")); + assertFalse("overworld must remain clear after planet /weather clear: " + + overworldAfterClear, overworldAfterClear.contains("\"isRaining\":true")); + } + + /** Polls until the client world reports the expected dimension (~10 s cap). */ + private void waitForClientDim(int expectedDim) throws Exception { + for (int waited = 0; waited < 200; waited += 10) { + clientHarness.bot().waitTicks(10); + JsonObject w = clientHarness.bot().reportWeather(); + if (w != null && w.has("dim") && w.get("dim").getAsInt() == expectedDim) { + return; + } + } + throw new AssertionError("client never reached dim " + expectedDim + + " (last weather report: " + clientHarness.bot().reportWeather() + ")"); + } + + /** + * Polls the SERVER-side wrapped weather flag of {@code dim} until it equals + * {@code raining} (~10 s cap) — the chat command travels client → server and + * lands on the next tick, so a one-shot read would race it. Returns a JSON + * object with the final raw probe output under {@code raw}. + */ + private JsonObject waitForServerRaining(int dim, boolean raining) throws Exception { + String raw = ""; + for (int waited = 0; waited < 200; waited += 10) { + raw = String.join("\n", + serverHarness.client().execute("artest weather get " + dim)); + if (raw.contains("\"isRaining\":" + raining)) { + JsonObject out = new JsonObject(); + out.addProperty("raw", raw); + return out; + } + clientHarness.bot().waitTicks(10); + } + throw new AssertionError("server dim " + dim + " never reached isRaining=" + + raining + "; last probe: " + raw); + } + + /** Polls until client-visible rainStrength reaches {@code minStrength} (~10 s cap, soft). */ + private JsonObject waitForClientRainStrengthAtLeast(float minStrength) throws Exception { + JsonObject latest = clientHarness.bot().reportWeather(); + for (int waited = 0; waited < 200; waited += 10) { + if (latest.has("rainStrength") && latest.get("rainStrength").getAsFloat() >= minStrength) { + return latest; + } + clientHarness.bot().waitTicks(10); + latest = clientHarness.bot().reportWeather(); + } + return latest; // soft wait — caller asserts and prints the report + } +} diff --git a/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java b/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java index 1ccf8a34c..d38944c21 100644 --- a/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java +++ b/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java @@ -229,6 +229,21 @@ public JsonObject readStaticField(String className, String fieldName) throws IOE return assertOk(execute(command)); } + /** + * Sends one chat line exactly as if the player typed it — leading-{@code /} + * commands included. Routes through {@code EntityPlayerSP.sendChatMessage} + * (the real {@code CPacketChatMessage} path), so the server handles it with + * a PLAYER sender: permission checks, the sender's world/dimension, and + * {@code CommandEvent} hooks all run their production path. This is the + * canonical way to e2e a command whose behaviour depends on where the + * player stands — console-driven commands can't reproduce that. + */ + public void sendChat(String message) throws IOException { + JsonObject command = command("send_chat"); + command.addProperty("message", message); + assertOk(execute(command)); + } + /** * Client-side view of vanilla weather state for whatever dim the player is * currently in. Reports {@code dim}, {@code worldInfoClass}, {@code isRaining}, diff --git a/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java b/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java index 9b99367d4..341f737e6 100644 --- a/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java +++ b/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java @@ -514,6 +514,20 @@ private static JsonObject handleCommand(JsonObject request) { } return response; }); + case "send_chat": + // One chat line exactly as typed by the player (commands + // included): EntityPlayerSP.sendChatMessage → CPacketChatMessage, + // so the server sees a real player sender — its world, + // permissions and CommandEvent hooks follow the production + // path, unlike console-driven commands. + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + if (mc.player == null) { + throw new IllegalStateException("send_chat: client player not in world yet"); + } + mc.player.sendChatMessage(requireString(request, "message")); + return ok(); + }); case "report_weather": // Client-side view of vanilla weather state for whatever // dimension the client is currently in. Reports what the From 7610260b04217e9aa51434099303b5f611d8c774 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 10 Jun 2026 13:45:21 +0200 Subject: [PATCH 37/47] test: e2e the dummy-container removal via a new report_mods bot probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testframework: ClientBot.reportMods exposes Loader.getModList / getActiveModList sizes + loaded modids — the exact lists the vanilla main menu renders as 'N mods loaded, M mods active' via FMLCommonHandler.getBrandings. ModCountParityE2ETest pins dercodeKoenig/AdvancedRocketry#71 at the player-visible layer: every loaded mod is active (the title-screen counts agree) and the vestigial advancedrocketrycore container is gone. Red-proven: with ModContainer.java + the plugin's getModContainerClass restored from 7f8ee7f0^ the probe reports advancedrocketrycore in the loaded list and the test fails. --- .../test/client/ModCountParityE2ETest.java | 54 +++++++++++++++++++ .../forge/testing/client/ClientBot.java | 11 ++++ .../bridge/ForgeTestClientBootstrap.java | 20 +++++++ 3 files changed, 85 insertions(+) create mode 100644 src/test/java/zmaster587/advancedRocketry/test/client/ModCountParityE2ETest.java diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/ModCountParityE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/ModCountParityE2ETest.java new file mode 100644 index 000000000..06386c10d --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/client/ModCountParityE2ETest.java @@ -0,0 +1,54 @@ +package zmaster587.advancedRocketry.test.client; + +import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * E2e regression guard for the dummy-mod-container removal + * (dercodeKoenig/AdvancedRocketry#71). + * + *

      The ASM coremod used to register a {@code DummyModContainer} + * ({@code advancedrocketrycore}) with empty lifecycle handlers. Its single + * observable effect was the vanilla main-menu line "N mods loaded, M mods + * active" disagreeing by one: the phantom container counted as loaded but + * never became active. The {@code report_mods} probe reads the exact two + * lists that menu line renders ({@code FMLCommonHandler.getBrandings} → + * {@code Loader.getModList()} / {@code getActiveModList()}), on the real + * client — so this is the player-visible layer of the report.

      + */ +public class ModCountParityE2ETest extends AbstractClientE2ETest { + + @Test + public void everyLoadedModIsActiveAndTheDummyContainerIsGone() throws Exception { + bot().waitForWorld(); + + JsonObject mods = bot().reportMods(); + int loaded = mods.get("loadedCount").getAsInt(); + int active = mods.get("activeCount").getAsInt(); + JsonArray ids = mods.getAsJsonArray("loadedModIds"); + + StringBuilder idList = new StringBuilder(); + boolean hasAr = false; + boolean hasDummy = false; + for (int i = 0; i < ids.size(); i++) { + String id = ids.get(i).getAsString(); + idList.append(id).append(' '); + hasAr |= "advancedrocketry".equals(id); + hasDummy |= "advancedrocketrycore".equals(id); + } + + assertTrue("advancedrocketry must be among loaded mods: " + idList, hasAr); + assertFalse("the vestigial dummy container advancedrocketrycore must be gone " + + "(issue dercodeKoenig/AdvancedRocketry#71): " + idList, hasDummy); + // The actual user-visible symptom: the title-screen counts must agree. + // A loaded-but-never-active container makes loadedCount = activeCount + 1. + assertEquals("every loaded mod must be active (title-screen 'loaded' vs 'active' " + + "mismatch — phantom container?): " + idList, loaded, active); + } +} diff --git a/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java b/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java index d38944c21..242a52b44 100644 --- a/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java +++ b/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java @@ -229,6 +229,17 @@ public JsonObject readStaticField(String className, String fieldName) throws IOE return assertOk(execute(command)); } + /** + * Forge mod registry as the CLIENT sees it: {@code loadedCount} / + * {@code activeCount} (the two numbers the vanilla main menu renders as + * "N mods loaded, M mods active" via {@code FMLCommonHandler.getBrandings}) + * plus {@code loadedModIds}. Lets a test pin loaded/active parity and the + * presence/absence of specific containers at the layer the player reads. + */ + public JsonObject reportMods() throws IOException { + return assertOk(execute(command("report_mods"))); + } + /** * Sends one chat line exactly as if the player typed it — leading-{@code /} * commands included. Routes through {@code EntityPlayerSP.sendChatMessage} diff --git a/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java b/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java index 341f737e6..c969be609 100644 --- a/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java +++ b/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java @@ -514,6 +514,26 @@ private static JsonObject handleCommand(JsonObject request) { } return response; }); + case "report_mods": + // The two counts the vanilla main menu shows ("N mods loaded, + // M mods active" — FMLCommonHandler.getBrandings reads exactly + // these lists), plus the loaded modids. A loaded-but-never- + // active container shows up here as a count mismatch. + return runOnClientThread(() -> { + JsonObject response = ok(); + List loaded = + net.minecraftforge.fml.common.Loader.instance().getModList(); + List active = + net.minecraftforge.fml.common.Loader.instance().getActiveModList(); + response.addProperty("loadedCount", loaded.size()); + response.addProperty("activeCount", active.size()); + JsonArray ids = new JsonArray(); + for (net.minecraftforge.fml.common.ModContainer mod : loaded) { + ids.add(mod.getModId()); + } + response.add("loadedModIds", ids); + return response; + }); case "send_chat": // One chat line exactly as typed by the player (commands // included): EntityPlayerSP.sendChatMessage → CPacketChatMessage, From 85d94e0d6ad3f7e204da0fa59f0030018f713815 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 10 Jun 2026 13:45:21 +0200 Subject: [PATCH 38/47] test: pin dirty-planetDefs server boot for the #77 fault tolerance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XMLPlanetLoaderTest already pins the parser guards (reserved-but-empty ore name, per-planet isolation); what only a real dedicated server can prove is the headline of dercodeKoenig/AdvancedRocketry#77 — the server BOOTS with a dirty planetDefs.xml instead of dying in a silent exitJava. Fixture: one well-formed planet + one with a non-numeric rainMarker (throws deep inside readPlanetFromNode). Asserts the server comes up, the malformed planet is skipped, and the good planet is registered and round-trips its config. --- .../server/PlanetDefsFaultToleranceTest.java | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/PlanetDefsFaultToleranceTest.java diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/PlanetDefsFaultToleranceTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/PlanetDefsFaultToleranceTest.java new file mode 100644 index 000000000..82822da15 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/PlanetDefsFaultToleranceTest.java @@ -0,0 +1,112 @@ +package zmaster587.advancedRocketry.test.server; + +import com.github.stannismod.forge.testing.junit.AbstractHeadlessServerTest; +import com.github.stannismod.forge.testing.server.RealDedicatedServerHarness; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Server-level regression guard for the tolerant planetDefs.xml loading + * (dercodeKoenig/AdvancedRocketry#77). + * + *

      The original report: a planetDefs.xml referencing content from a mod + * that isn't installed crashed world creation, and the crash killed the JVM + * via a silent {@code FMLCommonHandler.exitJava} — no crash report, the + * window just closed. The parser-level guards are pinned in + * {@code XMLPlanetLoaderTest} (reserved-but-empty ore name, per-planet + * isolation); what only a real dedicated server can prove is the headline + * behaviour: the server still boots with a dirty file, the malformed + * planet is skipped, and the well-formed planets around it survive.

      + * + *

      The malformed trigger mirrors the integration fixture: a non-numeric + * {@code } throws deep inside {@code readPlanetFromNode}, which + * the per-planet isolation must catch-and-skip.

      + */ +public class PlanetDefsFaultToleranceTest { + + private static final int GOOD_DIM = 9401; + private static final int BAD_DIM = 9402; + + private Path workDir; + private RealDedicatedServerHarness harness; + + @Before + public void writeDirtyFixture() throws Exception { + Assume.assumeTrue( + "Server harness disabled — set -Dforge.test.harness.enabled=true", + Boolean.parseBoolean(System.getProperty( + AbstractHeadlessServerTest.PROP_HARNESS_ENABLED, "false"))); + + workDir = Files.createTempDirectory("forge-server-planetdefs-fault-"); + Path arConfigDir = workDir.resolve("config").resolve("advRocketry"); + Files.createDirectories(arConfigDir); + + String xml = "\n" + + "\n" + + " \n" + + planetXml("GoodPlanet", GOOD_DIM, + "") + + planetXml("BadWeatherPlanet", BAD_DIM, + " NOT_A_NUMBER\n") + + " \n" + + "\n"; + Files.write(arConfigDir.resolve("planetDefs.xml"), xml.getBytes(StandardCharsets.UTF_8)); + } + + private static String planetXml(String name, int dim, String extraElements) { + return " \n" + + " true\n" + + " 0.5,0.5,0.5\n" + + " 0.4,0.6,0.9\n" + + " 100\n" + + " 100\n" + + " 0\n" + + " 0\n" + + " false\n" + + " 250\n" + + " 24000\n" + + " 100\n" + + extraElements + + " false\n" + + " true\n" + + " false\n" + + " \n"; + } + + @After + public void stopHarness() throws Exception { + if (harness != null) harness.close(); + } + + @Test + public void serverBootsWithMalformedPlanetSkipped() throws Exception { + // The assertion that matters most is implicit in this line: before the + // #77 fix a malformed planet killed the JVM during startup (silent + // exitJava), so startWith() would fail with "server process exited + // before becoming ready". + harness = RealDedicatedServerHarness.startWith(workDir, /*cleanupOnClose=*/true); + + String dimList = String.join("\n", harness.client().execute("artest dim list")); + assertTrue("well-formed planet must survive a dirty planetDefs.xml: " + dimList, + dimList.contains(String.valueOf(GOOD_DIM))); + assertFalse("malformed planet must be skipped, not registered: " + dimList, + dimList.contains(String.valueOf(BAD_DIM))); + + // The good planet is fully functional, not just listed. + String info = String.join("\n", + harness.client().execute("artest planet info " + GOOD_DIM)); + assertTrue("good planet must round-trip its config: " + info, + info.contains("\"name\":\"GoodPlanet\"")); + } +} From 56d2045124c3cef438f96e53619c233cec0f23dc Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 10 Jun 2026 14:27:57 +0200 Subject: [PATCH 39/47] test: live bot-sleep e2e for per-dim time/planetary dawn (#66 gap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the 'no sleeping-player harness' gap recorded in TASK-47. testframework: ClientBot.interactBlock right-clicks a block through PlayerControllerMP.processRightClickBlock — the real CPacketPlayerTryUseItemOnBlock path — so server-side interaction code (reach checks, Block.onBlockActivated, bed trySleep) runs against the real player. artest: new 'dim time ' probe — per-dimension clock readout (worldTime/totalTime/rotationalPeriod/isDaytime) with the same lazy load-and-pin semantics as the weather probes. PlanetBedSleepE2ETest: a real client sleeps in a real bed on a planet with rotationalPeriod=30000 at planet-night; the sleep skip must land on a multiple of 30000 (planetary dawn) and the overworld clock must keep ticking from its own time, proving per-dim isolation. Red-proven: with MixinWorldServer removed from the mixin config the skip lands at vanilla's 24000 — mid-night on this planet, the original #66 symptom — and the test fails (observed worldTime=24481). --- .../command/test/TestProbeCommand.java | 34 +++ .../test/client/PlanetBedSleepE2ETest.java | 220 ++++++++++++++++++ .../forge/testing/client/ClientBot.java | 17 ++ .../bridge/ForgeTestClientBootstrap.java | 20 ++ 4 files changed, 291 insertions(+) create mode 100644 src/test/java/zmaster587/advancedRocketry/test/client/PlanetBedSleepE2ETest.java diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index 8c064d4e2..5913b3b47 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -266,6 +266,40 @@ private void handleDim(ICommandSender sender, String[] args) { send(sender, builder.toString()); return; } + if ("time".equalsIgnoreCase(args[0]) && args.length >= 2) { + // Per-dimension clock readout — worldTime is per-dim on AR planets + // (ARDimensionWorldInfo, TASK-47), so this is the probe that can + // tell a planet's clock apart from the overworld's. Lazily loads + + // pins the dim like the weather probes, so a fresh dim can be read. + int dim = parseIntOr(args[1], Integer.MIN_VALUE); + if (dim == Integer.MIN_VALUE) { + send(sender, "{\"error\":\"invalid dim id\",\"value\":\"" + args[1] + "\"}"); + return; + } + net.minecraftforge.common.DimensionManager.keepDimensionLoaded(dim, true); + if (net.minecraftforge.common.DimensionManager.getWorld(dim) == null) { + net.minecraftforge.common.DimensionManager.initDimension(dim); + } + net.minecraft.world.WorldServer world = sender.getServer() != null + ? sender.getServer().getWorld(dim) : null; + if (world == null) { + send(sender, "{\"error\":\"world not loaded\",\"dim\":" + dim + "}"); + return; + } + Map map = new LinkedHashMap<>(); + map.put("dim", dim); + map.put("worldInfoClass", world.getWorldInfo().getClass().getName()); + map.put("worldTime", world.getWorldInfo().getWorldTime()); + map.put("totalTime", world.getWorldInfo().getWorldTotalTime()); + map.put("rotationalPeriod", + world.provider instanceof zmaster587.advancedRocketry.api.IPlanetaryProvider + ? ((zmaster587.advancedRocketry.api.IPlanetaryProvider) world.provider) + .getRotationalPeriod(null) + : 24000); + map.put("isDaytime", world.provider.isDaytime()); + send(sender, jsonMap(map)); + return; + } if ("info".equalsIgnoreCase(args[0]) && args.length >= 2) { int dim = parseIntOr(args[1], Integer.MIN_VALUE); if (dim == Integer.MIN_VALUE) { diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/PlanetBedSleepE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/PlanetBedSleepE2ETest.java new file mode 100644 index 000000000..781281a53 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/client/PlanetBedSleepE2ETest.java @@ -0,0 +1,220 @@ +package zmaster587.advancedRocketry.test.client; + +import com.github.stannismod.forge.testing.client.RealClientHarness; +import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; +import com.github.stannismod.forge.testing.junit.AbstractHeadlessServerTest; +import com.github.stannismod.forge.testing.server.RealDedicatedServerHarness; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertTrue; + +/** + * The live bot-sleep e2e for per-dimension time + planetary dawn rounding + * (dercodeKoenig/AdvancedRocketry#66 / TASK-47) — the player-truth layer the + * unit ({@code SleepWakeTimeTest}) and integration + * ({@code ARDimensionWorldInfoTest}) pins could not reach before the + * framework grew {@code interact_block}. + * + *

      A real client player stands on an AR planet whose day is + * {@code rotationalPeriod = 30000} ticks (deliberately ≠ 24000), right-clicks + * a real bed at planet-night, and falls asleep through the production + * {@code trySleep} path. The sleep skip must then land on the PLANET's next + * dawn — a multiple of 30000, where vanilla's hard-coded rounding would put + * 24000 (still night on this planet, the original #66 symptom) — and the + * overworld's clock must not move beyond normal ticking, proving the per-dim + * clock isolation.

      + */ +public class PlanetBedSleepE2ETest { + + private static final int DIM = 9501; + private static final int ROTATIONAL_PERIOD = 30000; + private static final String PLAYER = "ForgeTestClient"; + + /** Mid-air stone platform well above worldgen terrain — no mobs, flat, deterministic. */ + private static final int PLAT_Y = 150; + private static final int BED_X = 8, BED_Y = PLAT_Y + 1, BED_FOOT_Z = 9, BED_HEAD_Z = 10; + + private Path workDir; + private RealDedicatedServerHarness serverHarness; + private RealClientHarness clientHarness; + + @Before + public void startBoth() throws Exception { + Assume.assumeTrue( + "Server harness disabled — set -D" + AbstractHeadlessServerTest.PROP_HARNESS_ENABLED + "=true", + Boolean.parseBoolean(System.getProperty( + AbstractHeadlessServerTest.PROP_HARNESS_ENABLED, "false"))); + Assume.assumeTrue( + "Client harness disabled — set -D" + AbstractClientE2ETest.PROP_CLIENT_ENABLED + "=true", + Boolean.parseBoolean(System.getProperty( + AbstractClientE2ETest.PROP_CLIENT_ENABLED, "false"))); + + workDir = Files.createTempDirectory("forge-client-bed-sleep-"); + Path arConfigDir = workDir.resolve("config").resolve("advRocketry"); + Files.createDirectories(arConfigDir); + String xml = "\n" + + "\n" + + " \n" + + " \n" + + " true\n" + + " 0.5,0.5,0.5\n" + + " 0.4,0.6,0.9\n" + + " 100\n" + + " 100\n" + + " 0\n" + + " 0\n" + + " false\n" + + " 250\n" + + " " + ROTATIONAL_PERIOD + "\n" + + " 100\n" + + " false\n" + + " true\n" + + " false\n" + + " \n" + + " \n" + + "\n"; + Files.write(arConfigDir.resolve("planetDefs.xml"), xml.getBytes(StandardCharsets.UTF_8)); + + serverHarness = RealDedicatedServerHarness.startWith(workDir, /*cleanupOnClose=*/false); + try { + clientHarness = RealClientHarness.start(serverHarness); + } catch (Exception startupException) { + try { + serverHarness.close(); + } catch (Exception cleanup) { + startupException.addSuppressed(cleanup); + } + serverHarness = null; + throw startupException; + } + } + + @After + public void stopBoth() throws Exception { + Exception deferred = null; + if (clientHarness != null) { + try { + clientHarness.close(); + } catch (Exception e) { + deferred = e; + } + clientHarness = null; + } + if (serverHarness != null) { + try { + serverHarness.close(); + } catch (Exception e) { + if (deferred == null) deferred = e; + else deferred.addSuppressed(e); + } + serverHarness = null; + } + if (deferred != null) throw deferred; + } + + @Test + public void sleepingOnPlanetSkipsToPlanetaryDawnOnly() throws Exception { + clientHarness.bot().waitForWorld(); + + // trySleep's mob scan (±8 around the bed) must stay empty; the mid-air + // platform handles existing worldgen mobs, this handles new spawns. + serverHarness.client().execute("gamerule doMobSpawning false"); + + // Load + pin the planet, then stage the sleeping site: stone platform + // and a bed (foot at z=9, head at z=10, both facing south — meta 0/8). + serverHarness.client().execute("artest weather set " + DIM + " clear 12000"); + serverHarness.client().execute("artest fill " + DIM + " 4 " + PLAT_Y + " 4 12 " + PLAT_Y + " 12 minecraft:stone"); + serverHarness.client().execute("artest place " + DIM + " " + BED_X + " " + BED_Y + " " + BED_FOOT_Z + " minecraft:bed 0"); + serverHarness.client().execute("artest place " + DIM + " " + BED_X + " " + BED_Y + " " + BED_HEAD_Z + " minecraft:bed 8"); + + serverHarness.client().execute("artest tp " + DIM); + waitForClientDim(DIM); + // Vanilla console /tp (same-dim) puts the player on the platform, a + // bed-reach-range step north of the bed head (|Δz| = 2.5 ≤ 3). + serverHarness.client().execute("tp " + PLAYER + " 8.5 " + BED_Y + " 7.5"); + clientHarness.bot().waitTicks(20); + + // Night on every clock: vanilla /time set writes ALL loaded worlds, and + // on the wrapped planet that lands in the per-dim state. Phase + // 20000/30000 ≈ 0.67 is night on the planet; 20000/24000 is night in + // the overworld. + serverHarness.client().execute("time set 20000"); + clientHarness.bot().waitTicks(30); // let skylightSubtracted catch up (isDaytime gate) + + JsonObject before = dimTime(DIM); + long staged = before.get("worldTime").getAsLong(); + assertTrue("planet clock must be at the staged night time (~20000, tick drift " + + "tolerated): " + before, staged >= 20000 && staged < 22000); + + // The real player right-clicks the bed foot (server normalizes to the + // head) → production trySleep → fully asleep after 100 ticks → the + // sleep skip runs WorldServer's setWorldTime through MixinWorldServer's + // rotationalPeriod rounding. + JsonObject click = clientHarness.bot().interactBlock(BED_X, BED_Y, BED_FOOT_Z); + assertTrue("bed right-click must not error: " + click, click.has("result")); + + // Poll for the planetary dawn: next multiple of 30000 after 20000 is + // exactly 30000. Vanilla's hard-coded rounding would give 24000 — + // mid-night on this planet — which the modulo assertion rejects. + long planetTime = waitForPlanetDawn(); + assertTrue("sleep skip must land at/after the next planetary dawn (30000), got " + + planetTime, planetTime >= ROTATIONAL_PERIOD); + assertTrue("sleep skip must land ON planetary dawn (multiple of " + ROTATIONAL_PERIOD + + ", vanilla 24000-rounding would miss it): " + planetTime, + planetTime % ROTATIONAL_PERIOD < 2400); + + // Per-dim isolation: the overworld's clock keeps ticking from 20000 — + // the planet's sleep skip must NOT touch it. + long overworldTime = dimTime(0).get("worldTime").getAsLong(); + assertTrue("overworld clock must be unaffected by the planet's sleep skip " + + "(expected ~20000 + elapsed, got " + overworldTime + ")", + overworldTime >= 20000 && overworldTime < 24000); + } + + private JsonObject dimTime(int dim) throws Exception { + String raw = String.join("\n", + serverHarness.client().execute("artest dim time " + dim)); + int start = raw.indexOf('{'); + assertTrue("dim time probe must return JSON: " + raw, start >= 0); + return new JsonParser().parse(raw.substring(start)).getAsJsonObject(); + } + + /** Polls ~30 s for the planet clock to jump past the staged night (sleep takes 100+ ticks). */ + private long waitForPlanetDawn() throws Exception { + long last = -1; + for (int waited = 0; waited < 600; waited += 20) { + last = dimTime(DIM).get("worldTime").getAsLong(); + if (last >= ROTATIONAL_PERIOD) { + return last; + } + clientHarness.bot().waitTicks(20); + } + throw new AssertionError("planet never reached its dawn — either the player " + + "never fell asleep (trySleep rejected?) or the sleep skip landed off " + + "planetary dawn (vanilla 24000-rounding instead of rotationalPeriod); " + + "last planet worldTime=" + last); + } + + private void waitForClientDim(int expectedDim) throws Exception { + for (int waited = 0; waited < 200; waited += 10) { + clientHarness.bot().waitTicks(10); + JsonObject w = clientHarness.bot().reportWeather(); + if (w != null && w.has("dim") && w.get("dim").getAsInt() == expectedDim) { + return; + } + } + throw new AssertionError("client never reached dim " + expectedDim + + " (last weather report: " + clientHarness.bot().reportWeather() + ")"); + } +} diff --git a/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java b/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java index 242a52b44..6a7ef992f 100644 --- a/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java +++ b/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java @@ -229,6 +229,23 @@ public JsonObject readStaticField(String className, String fieldName) throws IOE return assertOk(execute(command)); } + /** + * Right-clicks a block exactly as the player would: routes through + * {@code PlayerControllerMP.processRightClickBlock} on the client thread, + * which sends the real {@code CPacketPlayerTryUseItemOnBlock} — so the + * server runs its production interaction path (reach checks, + * {@code Block.onBlockActivated}, bed {@code trySleep}, …) with the real + * player. Returns the client-side {@code EnumActionResult} name under + * {@code result}. + */ + public JsonObject interactBlock(int x, int y, int z) throws IOException { + JsonObject command = command("interact_block"); + command.addProperty("x", x); + command.addProperty("y", y); + command.addProperty("z", z); + return assertOk(execute(command)); + } + /** * Forge mod registry as the CLIENT sees it: {@code loadedCount} / * {@code activeCount} (the two numbers the vanilla main menu renders as diff --git a/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java b/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java index c969be609..059a42aba 100644 --- a/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java +++ b/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java @@ -514,6 +514,26 @@ private static JsonObject handleCommand(JsonObject request) { } return response; }); + case "interact_block": + // Real right-click: PlayerControllerMP.processRightClickBlock + // sends CPacketPlayerTryUseItemOnBlock, so the server's + // interaction path (reach checks, Block.onBlockActivated, bed + // trySleep, ...) runs against the real player. + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + if (mc.player == null || mc.world == null) { + throw new IllegalStateException("interact_block: client world/player not ready"); + } + BlockPos pos = new BlockPos(requireInt(request, "x"), + requireInt(request, "y"), requireInt(request, "z")); + Vec3d hit = new Vec3d(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5); + net.minecraft.util.EnumActionResult result = mc.playerController + .processRightClickBlock(mc.player, mc.world, pos, + EnumFacing.UP, hit, EnumHand.MAIN_HAND); + JsonObject response = ok(); + response.addProperty("result", result.name()); + return response; + }); case "report_mods": // The two counts the vanilla main menu shows ("N mods loaded, // M mods active" — FMLCommonHandler.getBrandings reads exactly From 2a53ebe2bd0c37ae1ff59c092dabebae83acae3f Mon Sep 17 00:00:00 2001 From: StannisMod Date: Wed, 10 Jun 2026 14:29:10 +0200 Subject: [PATCH 40/47] docs: backfill ledger entries 9-12 for PR #22 fixes + record e2e exceptions - Entries #9-#12 (issues #71, #77, #76, #66) were fixed before their ledger rows existed; backfilled with their current pins. - Records the user-approved (2026-06-10) client-e2e exceptions per the bug-report-workflow SOP: #76 (no no-JEI harness profile) and the client half of #77 (server-tier boot pin covers the substance). - TASK-47 + README: the 'no sleeping-player harness' gap is closed by PlanetBedSleepE2ETest. --- .agent/history/known-bugs-ledger.md | 44 ++++++++++++++++++- .agent/tasks/README.md | 2 +- .../tasks/TASK-47-per-dim-time-and-sleep.md | 6 +-- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/.agent/history/known-bugs-ledger.md b/.agent/history/known-bugs-ledger.md index 02d5c4ebd..cab7c4cbb 100644 --- a/.agent/history/known-bugs-ledger.md +++ b/.agent/history/known-bugs-ledger.md @@ -4,8 +4,10 @@ 2026-05-23). Batch #2 below is **live** and is kept in sync with the summary in [`../tasks/README.md`](../tasks/README.md) bug-ledger section. -**Live bug count (as of 2026-06-03)**: 4 live — Batch #2 entries -#1, #3, #5, #7. Entry #2 dropped as impl-trivia, #4 fixed by TASK-41, +**Live bug count (as of 2026-06-10)**: 4 live — Batch #2 entries +#1, #3, #5, #7. Entries #9–#12 backfilled 2026-06-10 for the PR #22 +issue fixes (all fixed on arrival; see per-entry pins + approved e2e +exceptions). Entry #2 dropped as impl-trivia, #4 fixed by TASK-41, #6 fixed by TASK-43 Phase 3, #8 fixed by TASK-49 (see per-entry notes below). When a future production bug is uncovered, follow the rule in [`CLAUDE.md`](../../CLAUDE.md#bug-tracking--every-discovered-production-bug-must-be-logged) @@ -295,3 +297,41 @@ authoring that have not yet been fixed. Fix candidates (TASK-49): load/resolve the destination dim on fire + surface a failure message per cause. **Found**: 2026-06-02 during issue #61 investigation (TASK-49). + +9. ✅ **FIXED 2026-06-01 (PR #22, `7f8ee7f0`).** Vestigial `DummyModContainer` + (`advancedrocketrycore`) made the title screen count one more "loaded" mod + than "active" (dercodeKoenig/AdvancedRocketry#71). Backfilled entry — fixed + before this ledger row existed. + **Pinned by**: `ModCountParityE2ETest` (client tier, via the framework's + `report_mods` probe — the same `Loader` lists the menu line renders; + red-proven against the restored container). + +10. ✅ **FIXED 2026-06-01 (PR #22, `ae379cac`).** planetDefs.xml referencing + content from an uninstalled mod crashed world creation through a silent + `FMLCommonHandler.exitJava` — window closed, no crash report + (dercodeKoenig/AdvancedRocketry#77). Backfilled entry. + **Pinned by**: `XMLPlanetLoaderTest` (reserved-but-empty ore, per-planet + isolation) + `PlanetDefsFaultToleranceTest` (server tier: boots with a + dirty file, malformed planet skipped, good planet survives). + **Client e2e: approved exception (user, 2026-06-10)** — the symptom is the + client window closing on a server-side startup crash; the server-tier boot + pin covers the substance, a client shutter assert adds nothing. + +11. ✅ **FIXED 2026-06-01 (PR #22, `cac31155`).** `PacketDimInfo.executeClient` + touched the JEI `ARPlugin` unconditionally → `NoClassDefFoundError` without + JEI installed, re-introducing dercodeKoenig/AdvancedRocketry#76 via the + dimension-sync path. Backfilled entry. + **Pinned by**: nothing executable — **approved exception (user, + 2026-06-10)**: reproducing needs a client WITHOUT JEI on the classpath, and + both harnesses always carry JEI; no no-JEI harness profile is planned. + Source-level guard (`Loader.isModLoaded("jei")`) audited at fix time. + +12. ✅ **FIXED 2026-06-02 (PR #22, `d1eb4794`) — e2e closed 2026-06-10.** Beds + skipped no time on AR planets and vanilla's 24000-rounded wake missed + planetary dawn (dercodeKoenig/AdvancedRocketry#66, TASK-47). Backfilled + entry. + **Pinned by**: `SleepWakeTimeTest` (dawn math), `ARDimensionWorldInfoTest` + (per-dim clock ownership), and since 2026-06-10 the live + `PlanetBedSleepE2ETest` (real client sleeps in a real bed via the + framework's `interact_block`; red-proven: without `MixinWorldServer` the + skip lands at vanilla 24000 — mid-night on a 30000-tick planet). diff --git a/.agent/tasks/README.md b/.agent/tasks/README.md index bfbce6388..12f06ce3e 100644 --- a/.agent/tasks/README.md +++ b/.agent/tasks/README.md @@ -401,7 +401,7 @@ entry is an actionable TASK with a defined plan + acceptance. | [TASK-16](TASK-16-test-stability-flake-watch.md) | Test-stability flake watch — investigation deliverable. Three flake shapes root-caused; shape #3 mitigated in TASK-26 via kit retry; #1+#2 split into TASK-27; #4 (worldgen sampling) confirmed across 3 sightings, promoted to TASK-28 F7. | 🟡 Investigation complete | Investigation done 2026-05-23. | | [TASK-45](TASK-45-oregen-clumpsize-clamp-disables-impossible.md) | `` `clumpSize`/`chancePerChunk` clamp to a floor of 1 (and empty `` falls through to the global pressure/temp default), so "disable this ore" is inexpressible per planet — likely the real cause behind #73's "zeroing veins still spawns ore". Analysis only; fix-vs-document undecided. | 🟡 Backlog — not started | Found 2026-06-01 alongside the #73 round-trip test. | | [TASK-46](TASK-46-compatibilitymgr-vestigial.md) | `CompatibilityMgr` is vestigial — `compat` instance never read, mod-presence flags written-but-not-read / set-by-uncalled-method, `reloadRecipes()` commented out. Kept on purpose (may regain meaning); decide revive vs remove. | 🟡 Backlog — not started | Found 2026-06-01 during the #76 JEI-ref audit. | -| [TASK-47](TASK-47-per-dim-time-and-sleep.md) | Beds skip no time on planets (#66). Root cause: derived `WorldInfo.setWorldTime` is a no-op so the sleep skip is swallowed; `rotationalPeriod ≠ 24000` means vanilla's 24000-rounding misses planetary dawn. Fix: per-dim time owned by the custom WorldInfo (renamed `ARDimensionWorldInfo`) + `MixinWorldServer` sleep-site rounding to `rotationalPeriod`. | ✅ Shipped 2026-06-02 | Live bot-sleep e2e not covered (no sleeping-player harness). | +| [TASK-47](TASK-47-per-dim-time-and-sleep.md) | Beds skip no time on planets (#66). Root cause: derived `WorldInfo.setWorldTime` is a no-op so the sleep skip is swallowed; `rotationalPeriod ≠ 24000` means vanilla's 24000-rounding misses planetary dawn. Fix: per-dim time owned by the custom WorldInfo (renamed `ARDimensionWorldInfo`) + `MixinWorldServer` sleep-site rounding to `rotationalPeriod`. | ✅ Shipped 2026-06-02 | Bot-sleep e2e covered 2026-06-10 (`PlanetBedSleepE2ETest`, framework `interact_block`). | | [TASK-48](TASK-48-per-dim-worldinfo-delegation.md) | Make other `DerivedWorldInfo` state per-dimension that vanilla forces to the overworld: GameRules (sharpest — `doDaylightCycle`/`doWeatherCycle` still shared after TASK-47), spawn point, difficulty, terrain type, game type. Weather (shipped) + time (TASK-47) are the precedent. | 🟦 Feature request — not urgent, needs design | Research spun off TASK-47 on 2026-06-02. | | [TASK-50](TASK-50-directional-gravity-camera-feature-request.md) | Directional gravity + camera rotation — resurrect kaduvill's experimental ASM prototype (`EntityLivingBase` move/jump/look hooks + `EntityRenderer.orientCamera`, ~95% commented out upstream) on the Mixin platform. Hook skeleton was deleted with `ClassTransformer` in `877d1495`; prototype readable at `c1c791d3` (kaduvill tip); dead math still in tree as commented-out `client/ClientHelper.java`. | 🟦 Feature request — not urgent, needs design | Recorded 2026-06-10 from the kaduvill-port audit; never functional upstream, so no regression pressure. | diff --git a/.agent/tasks/TASK-47-per-dim-time-and-sleep.md b/.agent/tasks/TASK-47-per-dim-time-and-sleep.md index 4783386fc..b333ac102 100644 --- a/.agent/tasks/TASK-47-per-dim-time-and-sleep.md +++ b/.agent/tasks/TASK-47-per-dim-time-and-sleep.md @@ -8,9 +8,9 @@ implemented; `ARWeatherWorldInfo` renamed to `ARDimensionWorldInfo`. unit + integration green; server weather/wiring suites green (mixin applies, decoupling no regression). Live "bot sleeps in a bed → time advances to - dawn" e2e is NOT covered (needs a sleeping-player testClient harness) — - dawn math is unit-tested and the mixin application is confirmed by server - boot under `required:true`. + dawn" e2e: ✅ covered 2026-06-10 by `PlanetBedSleepE2ETest` (framework + `interact_block` capability landed with the vendored testframework/), with + a red-proof against vanilla 24000-rounding. - Created: 2026-06-02. ## Root cause (confirmed against decompiled MC 1.12.2) From 9fa1c69922261970d39bf63b1310449195ae5269 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Thu, 11 Jun 2026 08:18:39 +0200 Subject: [PATCH 41/47] test: honest-e2e the /ar command tests via real client chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testframework grows four generic probes: use_item (real CPacketPlayerTryUseItem), report_chat (client chat overlay, i18n resolved — what the player reads), report_player_items (client-rendered held/armor/main stacks incl. NBT), report_entities (entities the client world actually sees). The three WorldCommand tests stop driving /ar through the exec-as-player server probe (the exact surrogate honest-client-e2e.md forbids) and TYPE the command in the real client chat via sendChat. Outcomes are now observed at the player layer: client position (reportState), client dimension (reportWeather), client-rendered inventory (reportPlayerItems — /ar station give chip), the chat overlay (reportChat — torch/sealant replies and vanilla player-not-found), with server probes kept only as arrange + cross-side oracles. All 8 test methods green. --- .../WorldCommandFetchModeratorTest.java | 40 ++-- .../test/client/WorldCommandFetchTest.java | 113 ++++----- .../WorldCommandPlayerEquippedE2ETest.java | 214 ++++++++++-------- .../forge/testing/client/ClientBot.java | 44 ++++ .../bridge/ForgeTestClientBootstrap.java | 116 ++++++++++ 5 files changed, 361 insertions(+), 166 deletions(-) diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandFetchModeratorTest.java b/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandFetchModeratorTest.java index 5faf7c385..5ab96459e 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandFetchModeratorTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandFetchModeratorTest.java @@ -169,28 +169,40 @@ public void moderatorFetchTeleportsTargetToSenderPosition() throws Exception { assertTrue("op-named must succeed for bot1: " + op, op.contains("\"opped\":true")); - // The moderator (bot1) runs /ar fetch bot2. - String fetch = exec("artest player exec-as-named " + BOT1_NAME - + " /ar fetch " + BOT2_NAME); - assertTrue("exec-as-named /ar fetch must succeed: " + fetch, - fetch.contains("\"ok\":true")); - - // Verify bot2's position is now bot1's pre-fetch position. - String bot2Post = exec("artest player position-of " + BOT2_NAME); - double bot2PostX = extractDouble(bot2Post, PLAYER_POS_X); - double bot2PostZ = extractDouble(bot2Post, PLAYER_POS_Z); - // setPosition copies sender coords exactly — sub-block tolerance - // covers any same-dim transferPlayerToDimension nudging. - assertTrue("post-fetch: bot2 must be at bot1's pre-fetch X (" + // The moderator (bot1) TYPES /ar fetch bot2 in the real client chat — + // CPacketChatMessage, real player sender, production command path. + bot1Harness.bot().sendChat("/ar fetch " + BOT2_NAME); + + // The TARGET's client must end up rendering itself at the moderator's + // pre-fetch position — that's what bot2's player sees on screen. + // setPosition copies sender coords exactly; sub-block tolerance covers + // same-dim transferPlayerToDimension nudging. Poll: the chat packet + + // transfer land a few ticks after send. + double bot2PostX = Double.NaN, bot2PostZ = Double.NaN; + for (int waited = 0; waited < 200; waited += 10) { + bot2Harness.bot().waitTicks(10); + com.google.gson.JsonObject state = bot2Harness.bot().reportState(); + bot2PostX = state.get("playerX").getAsDouble(); + bot2PostZ = state.get("playerZ").getAsDouble(); + if (Math.abs(bot2PostX - bot1PreX) < 1.5 && Math.abs(bot2PostZ - bot1PreZ) < 1.5) { + break; + } + } + assertTrue("post-fetch: bot2's CLIENT must render itself at bot1's pre-fetch X (" + bot1PreX + "), got " + bot2PostX, Math.abs(bot2PostX - bot1PreX) < 1.5); - assertTrue("post-fetch: bot2 must be at bot1's pre-fetch Z (" + assertTrue("post-fetch: bot2's CLIENT must render itself at bot1's pre-fetch Z (" + bot1PreZ + "), got " + bot2PostZ, Math.abs(bot2PostZ - bot1PreZ) < 1.5); // And NOT at its prior position any more. assertTrue("post-fetch: bot2 must have moved away from its prior X (" + bot2PreX + "), got " + bot2PostX, Math.abs(bot2PostX - bot2PreX) > 10.0); + + // Cross-side oracle: the server agrees about bot2's new position. + String bot2Post = exec("artest player position-of " + BOT2_NAME); + assertTrue("server must agree bot2 sits at bot1's pre-fetch X: " + bot2Post, + Math.abs(extractDouble(bot2Post, PLAYER_POS_X) - bot1PreX) < 1.5); } private static double extractDouble(String src, Pattern pattern) { diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandFetchTest.java b/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandFetchTest.java index 3deee0ddc..652664a94 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandFetchTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandFetchTest.java @@ -8,7 +8,6 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; @@ -56,7 +55,6 @@ public class WorldCommandFetchTest extends AbstractClientE2ETest { private static final Pattern POS_X = Pattern.compile("\"posX\":(-?\\d+(?:\\.\\d+)?)"); private static final Pattern POS_Y = Pattern.compile("\"posY\":(-?\\d+(?:\\.\\d+)?)"); private static final Pattern POS_Z = Pattern.compile("\"posZ\":(-?\\d+(?:\\.\\d+)?)"); - private static final Pattern RESULT = Pattern.compile("\"result\":(-?\\d+)"); private String exec(String cmd) throws Exception { return String.join("\n", serverClient().execute(cmd)); @@ -82,73 +80,83 @@ public void deopTheBot() throws Exception { } } - /** {@code /ar fetch } must complete without - * crashing and leave the bot at the same coords (sender pos == - * target pos in a self-fetch). Pins the - * resolve → transfer → setPosition path. */ + /** {@code /ar fetch } typed in the real client chat + * must complete without crashing and leave the bot at the same coords + * (sender pos == target pos in a self-fetch). Pins the chat → server + * command → resolve → transfer → setPosition path, observed from the + * CLIENT side. */ @Test public void selfFetchCompletesAndPreservesPosition() throws Exception { - // Discover the bot's username via /artest player health, which - // echoes player.getName() in its JSON. The bot's username is - // set by the harness and not exposed as a constant we can - // import — health probe is the canonical readback. + // Discover the bot's username via /artest player health (arrange-only + // server read), which echoes player.getName() in its JSON. String health = exec("artest player health"); Matcher nameM = PLAYER_NAME.matcher(health); assertTrue("player health must echo player name: " + health, nameM.find()); String botName = nameM.group(1); assertNotEquals("bot name must be non-empty", "", botName); - // Snapshot pre-call position so we can verify setPosition's - // effect (the bot is teleporting itself to its OWN current - // sender position — should net to a no-op). - double preX = extractDouble(health, POS_X); - double preZ = extractDouble(health, POS_Z); - - String fetch = exec("artest player exec-as-player /ar fetch " + botName); - assertTrue("exec-as-player /ar fetch must succeed: " + fetch, - fetch.contains("\"ok\":true")); - assertTrue("/ar fetch result must be >= 1 (command ran): " + fetch, - extractInt(fetch, RESULT) >= 1); - - // Post-call: bot must still exist + still be at (approximately) - // the pre-call coords (a self-fetch sets position to sender's - // own position). - String post = exec("artest player health"); - double postX = extractDouble(post, POS_X); - double postZ = extractDouble(post, POS_Z); - // Sub-block tolerance — transferPlayerToDimension may nudge by - // sub-block fractions even in the same-dim path. We pin - // "didn't teleport to a wrong location", not "exact float - // equality". + // Snapshot the CLIENT-observed position — the layer the player sees. + com.google.gson.JsonObject pre = bot().reportState(); + double preX = pre.get("playerX").getAsDouble(); + double preZ = pre.get("playerZ").getAsDouble(); + + // The real stimulus: the player types the command in chat. + bot().sendChat("/ar fetch " + botName); + bot().waitTicks(20); + + // Post-call: the CLIENT must still render itself at (approximately) + // the pre-call coords. Sub-block tolerance — the same-dim transfer + // path may nudge by fractions; we pin "didn't teleport to a wrong + // location", not float equality. + com.google.gson.JsonObject post = bot().reportState(); + double postX = post.get("playerX").getAsDouble(); + double postZ = post.get("playerZ").getAsDouble(); assertTrue("self-fetch must leave bot within 1 block of its prior position: " + "preX=" + preX + " postX=" + postX, Math.abs(postX - preX) < 1.0); assertTrue("self-fetch must leave bot within 1 block of its prior position: " + "preZ=" + preZ + " postZ=" + postZ, Math.abs(postZ - preZ) < 1.0); + + // Cross-side oracle: the server agrees about where the player is. + String postServer = exec("artest player health"); + assertTrue("server-side X must agree with the client view: " + postServer, + Math.abs(extractDouble(postServer, POS_X) - postX) < 1.0); } - /** {@code /ar fetch } returns the "Invalid player - * name: ..." error chat without crashing. Pins the - * {@code getPlayerByName == null} branch. */ + /** {@code /ar fetch } typed in the real client chat must + * surface vanilla's "player cannot be found" error ON THE PLAYER'S CHAT + * OVERLAY (i18n resolved) without crashing. Pins the + * {@code getPlayer → PlayerNotFoundException} branch at the layer the + * player reads it. */ @Test public void fetchUnknownNameReportsInvalidPlayerName() throws Exception { - // Use a name that's extremely unlikely to collide with any - // real player. The contract: production hits the - // "Invalid player name: " reply branch. String bogus = "_no_such_player_xyz_TASK35_"; - String fetch = exec("artest player exec-as-player /ar fetch " + bogus); - assertTrue("exec-as-player /ar fetch must dispatch without crash: " + fetch, - fetch.contains("\"ok\":true")); - // FetchCommand resolves the target via vanilla getPlayer(), which - // throws PlayerNotFoundException on an unknown name. The server's - // CommandHandler catches it, sends the "player not found" error to - // the sender's chat, and the command yields 0 (not executed). So - // the contract is: unknown name fails cleanly — the probe dispatch - // does not crash (ok:true) and the command's result is 0, with the - // error surfaced to chat (not in the probe JSON). - assertEquals("/ar fetch unknown-name must fail cleanly (result 0): " - + fetch, 0, extractInt(fetch, RESULT)); + + // The real stimulus: the player types the command in chat. + bot().sendChat("/ar fetch " + bogus); + + // FetchCommand resolves via vanilla getPlayer(), which throws + // PlayerNotFoundException; CommandHandler turns that into the red + // commands.generic.player.notFound chat reply. Poll the CLIENT chat + // overlay (newest line first) for the resolved text. + String newest = ""; + boolean found = false; + for (int waited = 0; waited < 100 && !found; waited += 10) { + bot().waitTicks(10); + com.google.gson.JsonObject chat = bot().reportChat(10); + com.google.gson.JsonArray lines = chat.getAsJsonArray("lines"); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i).getAsString(); + newest = newest.isEmpty() ? line : newest; + if (line.toLowerCase(java.util.Locale.ROOT).contains("cannot be found")) { + found = true; + break; + } + } + } + assertTrue("client chat must show the vanilla player-not-found error " + + "for an unknown fetch target (newest line: '" + newest + "')", found); } private static double extractDouble(String src, Pattern pattern) { @@ -157,9 +165,4 @@ private static double extractDouble(String src, Pattern pattern) { return Double.parseDouble(m.group(1)); } - private static int extractInt(String src, Pattern pattern) { - Matcher m = pattern.matcher(src); - assertTrue("pattern not found in: " + src, m.find()); - return Integer.parseInt(m.group(1)); - } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandPlayerEquippedE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandPlayerEquippedE2ETest.java index 227f3db9a..4b3647a52 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandPlayerEquippedE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/WorldCommandPlayerEquippedE2ETest.java @@ -1,6 +1,8 @@ package zmaster587.advancedRocketry.test.client; import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -9,46 +11,35 @@ import java.util.regex.Pattern; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; /** - * TASK-21 — {@code /ar} player-equipped verbs positive paths. + * TASK-21 — {@code /ar} player-equipped verbs positive paths, driven the way + * a player drives them: typed into the REAL client chat + * ({@code ClientBot.sendChat} → {@code CPacketChatMessage}), with the outcome + * observed from the CLIENT side (dim via {@code reportWeather}, inventory via + * {@code reportPlayerItems}, command replies via {@code reportChat}) and the + * server consulted only as a cross-side oracle. * *

      {@code WorldCommandGuardContractTest} closed the guard side - * (non-player sender rejection). This test closes the symmetric - * positive side — verbs that DO mutate state when a real player - * with op privileges runs them:

      + * (non-player sender rejection). This test closes the symmetric positive + * side — verbs that DO mutate state when a real opped player runs them:

      * *
        - *
      • {@code /ar goto } — transfers player to dim.
      • - *
      • {@code /ar giveStation } — adds station chip to player - * inventory.
      • + *
      • {@code /ar goto dimension } — transfers player to dim.
      • + *
      • {@code /ar station give } — adds station chip to inventory.
      • *
      • {@code /ar addTorch} — adds held block to torch list.
      • - *
      • {@code /ar addSealant} — adds held block to - * sealed-block list.
      • + *
      • {@code /ar addSealant} — adds held block to sealed-block list.
      • + *
      • {@code /ar goto station } — teleports to station spawn.
      • *
      * - *

      Out of scope here:

      - *
        - *
      • {@code /ar fetch} — needs a second connected player. The - * testClient harness supports one bot only.
      • - *
      • {@code /ar fillData} — needs a fixture with an - * {@code itemMultiData} stack; the verb itself is exercised by - * the production assembly flow elsewhere.
      • - *
      - * - *

      The bot is opped in {@code @Before} and de-opped in {@code @After}. - * AR config-list mutations (torch / sealed-block) are restored where - * mutated — they're harness-globals shared with sibling tests.

      + *

      Out of scope here: {@code /ar fillData} (needs an {@code itemMultiData} + * fixture; the verb is exercised by the production assembly flow elsewhere). + * The bot is opped in {@code @Before} and de-opped in {@code @After}.

      */ public class WorldCommandPlayerEquippedE2ETest extends AbstractClientE2ETest { - private static final Pattern PLAYER_DIM = Pattern.compile("\"playerDim\":(-?\\d+)"); - private static final Pattern INV_COUNT = Pattern.compile("\"count\":(-?\\d+)"); - private static final Pattern RESULT = Pattern.compile("\"result\":(-?\\d+)"); - private String exec(String cmd) throws Exception { return String.join("\n", serverClient().execute(cmd)); } @@ -68,8 +59,9 @@ public void opTheBot() throws Exception { @After public void deopTheBot() throws Exception { try { - // Return to overworld in case a goto test moved us. - exec("artest player exec-as-player /ar goto dimension 0"); + // Return to overworld in case a goto test moved us (console-side + // cleanup — not part of any assertion). + exec("artest tp 0"); } catch (Exception ignored) { } try { @@ -80,102 +72,97 @@ public void deopTheBot() throws Exception { @Test public void arGotoTransfersPlayerToTargetDim() throws Exception { - // Generate an AR planet to provide a known destination dim - // distinct from overworld. The harness keeps 0 (overworld) - // available always; an AR-generated planet gives a non-zero - // dim id we can verify against. - // (uses the same probe pattern as TASK-19 Phase 1a.) + // Generate an AR planet to provide a known destination dim distinct + // from overworld (same probe pattern as TASK-19 Phase 1a) — arrange. String before = exec("ar planet list"); exec("ar planet generate 0 GotoTarget 10 10 10"); String after = exec("ar planet list"); - // Naive id extraction — find a DIM in `after` that's not in `before`. int targetDim = newDimFromDiff(before, after); assertNotEquals("planet generate must yield a new dim id", -1, targetDim); try { exec("artest dim load " + targetDim); - String resp = exec("artest player exec-as-player /ar goto dimension " + targetDim); - assertTrue("exec-as-player /ar goto must succeed: " + resp, - resp.contains("\"ok\":true")); - // result>=1 means the command parsed + ran. /ar's outcome - // is observed via the post-call playerDim. - assertTrue("/ar goto result must be > 0: " + resp, - extract(resp, RESULT) > 0); - assertEquals("/ar goto must transfer the player to the target dim " - + "(was overworld=0, now " + targetDim + "): " + resp, - targetDim, extract(resp, PLAYER_DIM)); + // The player types the command in the real chat. + bot().sendChat("/ar goto dimension " + targetDim); + + // The CLIENT must end up rendering the target dim. + waitForClientDim(targetDim); + + // Cross-side oracle: the server agrees about the player's dim. + String health = exec("artest player health"); + assertTrue("server must agree the player is in dim " + targetDim + + ": " + health, health.contains("\"dim\":" + targetDim)); } finally { - // Force-transfer back to overworld + clean up the generated dim. - exec("artest player exec-as-player /ar goto dimension 0"); + exec("artest tp 0"); exec("ar planet delete " + targetDim); } } @Test public void arGiveStationAddsChipToPlayerInventory() throws Exception { - // Pre-create a station so /ar giveStation has a real ID to bind. + // Pre-create a station so /ar station give has a real ID to bind. String create = exec("artest station create 0"); Matcher idM = Pattern.compile("\"id\":(-?\\d+)").matcher(create); assertTrue("station create response must include id: " + create, idM.find()); int stationId = Integer.parseInt(idM.group(1)); - // Baseline: no chip yet. - String pre = exec("artest player inventory-contains advancedrocketry:spacestationchip"); - assertEquals("baseline: bot inventory has no station chip", - 0, extract(pre, INV_COUNT)); + // Baseline: no chip in the CLIENT-rendered inventory yet. + assertEquals("baseline: bot inventory has no station chip (client view)", + 0, countClientItems("advancedrocketry:spacestationchip")); + + // The player types the command in the real chat. + bot().sendChat("/ar station give " + stationId); - String resp = exec("artest player exec-as-player /ar station give " + stationId); - assertTrue("exec-as-player /ar giveStation must succeed: " + resp, - resp.contains("\"ok\":true")); + // The chip must show up in the CLIENT-rendered inventory — that's + // what the player sees when they open their inventory screen. + int count = -1; + for (int waited = 0; waited < 100; waited += 10) { + bot().waitTicks(10); + count = countClientItems("advancedrocketry:spacestationchip"); + if (count >= 1) break; + } + assertTrue("/ar station give must add a station chip to the bot's " + + "client-rendered inventory; client count=" + count, count >= 1); + // Cross-side oracle: server inventory agrees. String post = exec("artest player inventory-contains advancedrocketry:spacestationchip"); - assertTrue("/ar giveStation must add at least one station chip to " - + "the bot's inventory: " + post, - extract(post, INV_COUNT) >= 1); + assertTrue("server inventory must also contain the chip: " + post, + !post.contains("\"count\":0")); } @Test public void arAddTorchAddsHeldBlockToTorchList() throws Exception { - // Equip the bot with a torch-eligible block — the AR - // `commandAddTorch` reads getHeldItemMainhand and adds its - // block to torchBlocks. - // minecraft:cobblestone is a safe choice — likely not in the - // default torchBlocks list, and easy to confirm. + // Equip the bot with a torch-eligible block (arrange) — the verb + // reads getHeldItemMainhand. String give = exec("artest player give-held minecraft:cobblestone"); assertTrue("give-held must succeed: " + give, give.contains("\"ok\":true")); - // Sanity baseline: cobblestone NOT in torch list yet. We rely - // on the command's chat message — the production verb sends - // either "added to the torch list" or "is already in the torch - // list" depending on prior state. Idempotent re-runs would - // catch the second branch; we accept either since the - // observable post-state is the same. - String resp = exec("artest player exec-as-player /ar addTorch"); - assertTrue("exec-as-player /ar addTorch must succeed: " + resp, - resp.contains("\"ok\":true")); - assertTrue("/ar addTorch result must be >= 1 (command ran): " + resp, - extract(resp, RESULT) >= 1); + // The player types the command in the real chat. + bot().sendChat("/ar addTorch"); + + // The command replies on the sender's chat: either "%s added to the + // torch list" or "%s is already in the torch list" (idempotent + // re-runs hit the second branch; the post-state is the same). Both + // resolve through the client's lang — assert at the layer the player + // reads. + assertTrue("client chat must show the torch-list reply", + waitForChatContaining("torch list", 100)); } @Test public void arAddSolidBlockOverrideAddsHeldBlockToSealedList() throws Exception { - // Same shape as addTorch. Use a different block (dirt) so the - // two tests don't accidentally share state via the torchBlocks - // list (which addTorch + addSolidBlockOverride both check by - // membership for the duplicate-warning branch — see - // WorldCommand.java:126). + // Different block than addTorch so the two tests don't share state + // via the torchBlocks list (see WorldCommand duplicate-warning branch). String give = exec("artest player give-held minecraft:dirt"); assertTrue("give-held must succeed: " + give, give.contains("\"ok\":true")); - String resp = exec("artest player exec-as-player /ar addSealant"); - assertTrue("exec-as-player /ar addSealant must succeed: " - + resp, - resp.contains("\"ok\":true")); - assertTrue("/ar addSealant result must be >= 1: " + resp, - extract(resp, RESULT) >= 1); + bot().sendChat("/ar addSealant"); + + assertTrue("client chat must show the sealed-block-list reply", + waitForChatContaining("sealed block list", 100)); } @Test @@ -186,16 +173,14 @@ public void arGotoStationTeleportsToStationSpawnInSpaceDim() throws Exception { assertTrue("station create must succeed: " + create, idM.find()); int stationId = Integer.parseInt(idM.group(1)); - // Make sure space dim is loaded. + // Make sure space dim is loaded (arrange). exec("artest dim load -2"); - String resp = exec("artest player exec-as-player /ar goto station " + stationId); - assertTrue("exec-as-player /ar goto station must succeed: " + resp, - resp.contains("\"ok\":true")); - // Player must end up in spaceDim (-2 default). - assertEquals("/ar goto station must transfer player to spaceDim (-2): " - + resp, - -2, extract(resp, PLAYER_DIM)); + // The player types the command in the real chat. + bot().sendChat("/ar goto station " + stationId); + + // The CLIENT must end up rendering the space dim (-2 default). + waitForClientDim(-2); } // ─── helpers ─────────────────────────────────────────────────────── @@ -214,10 +199,45 @@ private static int newDimFromDiff(String before, String after) { return -1; } - private static int extract(String src, Pattern pattern) { - Matcher m = pattern.matcher(src); - assertFalse("pattern " + pattern.pattern() + " not found in: " + src, - !m.find()); - return Integer.parseInt(m.group(1)); + /** Polls until the CLIENT world reports the expected dimension (~10 s cap). */ + private void waitForClientDim(int expectedDim) throws Exception { + JsonObject last = null; + for (int waited = 0; waited < 200; waited += 10) { + bot().waitTicks(10); + last = bot().reportWeather(); + if (last != null && last.has("dim") && last.get("dim").getAsInt() == expectedDim) { + return; + } + } + throw new AssertionError("client never reached dim " + expectedDim + + " (last client report: " + last + ")"); + } + + /** Counts stacks of {@code itemId} in the CLIENT-rendered main inventory + offhand. */ + private int countClientItems(String itemId) throws Exception { + JsonObject items = bot().reportPlayerItems(); + int count = 0; + JsonArray main = items.getAsJsonArray("main"); + for (int i = 0; i < main.size(); i++) { + if (itemId.equals(main.get(i).getAsJsonObject().get("id").getAsString())) { + count += main.get(i).getAsJsonObject().get("count").getAsInt(); + } + } + return count; + } + + /** Polls the CLIENT chat overlay until a line contains {@code needle}. */ + private boolean waitForChatContaining(String needle, int maxTicks) throws Exception { + for (int waited = 0; waited < maxTicks; waited += 10) { + bot().waitTicks(10); + JsonArray lines = bot().reportChat(10).getAsJsonArray("lines"); + for (int i = 0; i < lines.size(); i++) { + if (lines.get(i).getAsString().toLowerCase(java.util.Locale.ROOT) + .contains(needle.toLowerCase(java.util.Locale.ROOT))) { + return true; + } + } + } + return false; } } diff --git a/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java b/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java index 6a7ef992f..ddba45810 100644 --- a/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java +++ b/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java @@ -229,6 +229,50 @@ public JsonObject readStaticField(String className, String fieldName) throws IOE return assertOk(execute(command)); } + /** + * Right-clicks the HELD item with no block target: routes through + * {@code PlayerControllerMP.processRightClick} (the real + * {@code CPacketPlayerTryUseItem} path), so {@code Item.onItemRightClick} + * runs on both sides against the real player. Returns the client-side + * {@code EnumActionResult} name under {@code result}. + */ + public JsonObject useItem() throws IOException { + return assertOk(execute(command("use_item"))); + } + + /** + * Recent lines of the client chat overlay, newest first, i18n already + * resolved — exactly the text the player reads. The honest observation + * for "the player received a chat message". + */ + public JsonObject reportChat(int limit) throws IOException { + JsonObject command = command("report_chat"); + command.addProperty("limit", limit); + return assertOk(execute(command)); + } + + /** + * Client-side view of the player's held / offhand / armor / main-inventory + * stacks ({@code id}, {@code count}, {@code nbt} string). This is the + * synced state the HUD and inventory screen render from. + */ + public JsonObject reportPlayerItems() throws IOException { + return assertOk(execute(command("report_player_items"))); + } + + /** + * Entities in the CLIENT world within {@code radius} of the player whose + * class name contains {@code classContains} (empty = all). Pins "the + * client actually sees the entity" — spawn sync, tracking range, render + * presence — which no server-side query can. + */ + public JsonObject reportEntities(String classContains, double radius) throws IOException { + JsonObject command = command("report_entities"); + command.addProperty("classContains", classContains); + command.addProperty("radius", radius); + return assertOk(execute(command)); + } + /** * Right-clicks a block exactly as the player would: routes through * {@code PlayerControllerMP.processRightClickBlock} on the client thread, diff --git a/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java b/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java index 059a42aba..4d66b497d 100644 --- a/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java +++ b/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java @@ -514,6 +514,107 @@ private static JsonObject handleCommand(JsonObject request) { } return response; }); + case "use_item": + // Right-click the held item "in the air" (no block target): + // PlayerControllerMP.processRightClick sends the real + // CPacketPlayerTryUseItem, so Item.onItemRightClick runs on + // both sides against the real player. + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + if (mc.player == null || mc.world == null) { + throw new IllegalStateException("use_item: client world/player not ready"); + } + net.minecraft.util.EnumActionResult result = mc.playerController + .processRightClick(mc.player, mc.world, EnumHand.MAIN_HAND); + JsonObject response = ok(); + response.addProperty("result", result.name()); + return response; + }); + case "report_chat": + // Recent lines of the client chat overlay (GuiNewChat), newest + // first — i18n ALREADY RESOLVED, exactly what the player reads. + // The honest observation for "the player got a chat message". + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + int limit = request.has("limit") ? request.get("limit").getAsInt() : 20; + JsonObject response = ok(); + JsonArray lines = new JsonArray(); + if (mc.ingameGUI != null) { + try { + net.minecraft.client.gui.GuiNewChat chat = mc.ingameGUI.getChatGUI(); + java.lang.reflect.Field f = findField(chat.getClass(), "chatLines"); + f.setAccessible(true); + @SuppressWarnings("unchecked") + List raw = + (List) f.get(chat); + for (int i = 0; i < raw.size() && i < limit; i++) { + lines.add(raw.get(i).getChatComponent().getUnformattedText()); + } + } catch (Throwable t) { + throw new IllegalStateException("report_chat failed: " + t, t); + } + } + response.add("lines", lines); + response.addProperty("count", lines.size()); + return response; + }); + case "report_player_items": + // Client-side view of the player's held/offhand/armor/main + // inventory stacks (id, count, NBT string). This is the synced + // state the HUD and inventory screen render from — the honest + // layer for "the suit's air tank drained" style assertions. + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + JsonObject response = ok(); + if (mc.player == null) { + response.addProperty("worldReady", false); + return response; + } + response.addProperty("worldReady", true); + response.add("held", stackJson(mc.player.getHeldItemMainhand())); + response.add("offhand", stackJson(mc.player.getHeldItemOffhand())); + JsonArray armor = new JsonArray(); + for (ItemStack stack : mc.player.inventory.armorInventory) { + armor.add(stackJson(stack)); // index 0=feet … 3=head + } + response.add("armor", armor); + JsonArray main = new JsonArray(); + for (ItemStack stack : mc.player.inventory.mainInventory) { + main.add(stackJson(stack)); + } + response.add("main", main); + return response; + }); + case "report_entities": + // Entities in the CLIENT world near the player, optionally + // filtered by a class-name substring. Pins "the client actually + // sees the spawned/tracked entity", which no server query can. + return runOnClientThread(() -> { + Minecraft mc = Minecraft.getMinecraft(); + if (mc.player == null || mc.world == null) { + throw new IllegalStateException("report_entities: client world/player not ready"); + } + double radius = request.has("radius") ? request.get("radius").getAsDouble() : 64.0D; + String needle = request.has("classContains") + ? requireString(request, "classContains") : ""; + JsonObject response = ok(); + JsonArray entities = new JsonArray(); + for (net.minecraft.entity.Entity entity : mc.world.loadedEntityList) { + if (entity == mc.player) continue; + if (!needle.isEmpty() && !entity.getClass().getName().contains(needle)) continue; + if (mc.player.getDistance(entity) > radius) continue; + JsonObject je = new JsonObject(); + je.addProperty("class", entity.getClass().getName()); + je.addProperty("id", entity.getEntityId()); + je.addProperty("x", entity.posX); + je.addProperty("y", entity.posY); + je.addProperty("z", entity.posZ); + entities.add(je); + } + response.add("entities", entities); + response.addProperty("count", entities.size()); + return response; + }); case "interact_block": // Real right-click: PlayerControllerMP.processRightClickBlock // sends CPacketPlayerTryUseItemOnBlock, so the server's @@ -687,6 +788,21 @@ private static EntityPlayerSP requirePlayer(Minecraft mc) { return mc.player; } + /** {id, count, nbt} of a client-side ItemStack; empty stacks → id="" count=0. */ + private static JsonObject stackJson(ItemStack stack) { + JsonObject json = new JsonObject(); + if (stack == null || stack.isEmpty()) { + json.addProperty("id", ""); + json.addProperty("count", 0); + json.addProperty("nbt", ""); + return json; + } + json.addProperty("id", String.valueOf(stack.getItem().getRegistryName())); + json.addProperty("count", stack.getCount()); + json.addProperty("nbt", stack.getTagCompound() == null ? "" : stack.getTagCompound().toString()); + return json; + } + private static JsonObject ok() { JsonObject response = new JsonObject(); response.addProperty("ok", true); From fce8071f674c0f25e56630704809a8c494ae2d38 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Thu, 11 Jun 2026 08:26:31 +0200 Subject: [PATCH 42/47] =?UTF-8?q?test:=20honest-e2e=20the=20ride=20tests?= =?UTF-8?q?=20=E2=80=94=20real=20W/sneak=20keys,=20client-side=20riding=20?= =?UTF-8?q?reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HovercraftRideE2ETest + ElevatorCapsuleRideE2ETest keep mounting as a server probe (an SOP-allowed arrange step) and drive the actual ride contracts through the real client input surface: the forward key (W) feeds MovementInput → CPacketInput → server player.moveForward → getPassengerMovingForward(), and sneak (LSHIFT) exercises the vanilla wants-to-stop-riding dismount. All riding/motion assertions now read the CLIENT view via reportRidingEntity (mount sync, ridden-entity class and id, lateral movement of the rendered craft), with the old server probes demoted to cross-side oracles. drive-ridden-entity / set-move-forward probe calls are gone from both tests. 6 methods green. --- .../client/ElevatorCapsuleRideE2ETest.java | 52 ++++++-- .../test/client/HovercraftRideE2ETest.java | 113 ++++++++++++------ 2 files changed, 122 insertions(+), 43 deletions(-) diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/ElevatorCapsuleRideE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/ElevatorCapsuleRideE2ETest.java index a8d83f9c3..d3ff136cf 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/ElevatorCapsuleRideE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/ElevatorCapsuleRideE2ETest.java @@ -44,6 +44,9 @@ */ public class ElevatorCapsuleRideE2ETest extends AbstractClientE2ETest { + /** LWJGL key code for the vanilla default sneak bind (LSHIFT). */ + private static final int KEY_LSHIFT = 42; + private static final Pattern ENTITY_ID = Pattern.compile("\"entityId\":(-?\\d+)"); private static final Pattern RIDING_ID = Pattern.compile("\"ridingEntityId(?:Now)?\":(-?\\d+)"); @@ -82,11 +85,18 @@ public void playerMountsElevatorCapsuleViaStartRiding() throws Exception { assertTrue("mount-entity must report mounted:true: " + mount, mount.contains("\"mounted\":true")); + // CLIENT truth: the bot's own client renders itself riding the capsule. + com.google.gson.JsonObject clientRiding = waitForClientRiding(true); + assertEquals("client-side ridden entity id must be the capsule's id", + capsuleId, clientRiding.get("entityId").getAsInt()); + assertTrue("client-side ridden entity class must be EntityElevatorCapsule: " + + clientRiding, + clientRiding.get("entityClass").getAsString().contains("EntityElevatorCapsule")); + + // Cross-side oracle: the server agrees. String riding = exec("artest player riding-entity"); assertEquals("after mount, riding-entity probe must report the capsule's id", capsuleId, extract(riding, RIDING_ID)); - assertTrue("riding entity class must be EntityElevatorCapsule: " + riding, - riding.contains("EntityElevatorCapsule")); // Cleanup — dismount so subsequent tests in the same JVM start fresh. exec("artest player dismount"); @@ -100,18 +110,42 @@ public void playerDismountClearsRidingEntity() throws Exception { int capsuleId = spawnCapsuleAt(128.5, 79, 10.5); exec("artest player mount-entity " + capsuleId); - - String dismount = exec("artest player dismount"); - assertTrue("dismount probe must succeed: " + dismount, - dismount.contains("\"ok\":true")); - assertEquals("dismount must report ridingEntityIdNow:-1", - -1, extract(dismount, RIDING_ID)); - + com.google.gson.JsonObject mounted = waitForClientRiding(true); + assertEquals("arrange: client must be riding the capsule first", + capsuleId, mounted.get("entityId").getAsInt()); + + // The REAL dismount input: hold sneak — the vanilla + // wants-to-stop-riding path sends the dismount to the server. + bot().setKey(KEY_LSHIFT, true); + try { + com.google.gson.JsonObject clientRiding = waitForClientRiding(false); + assertTrue("client must report riding=false after sneak-dismount: " + + clientRiding, + !clientRiding.get("riding").getAsBoolean()); + } finally { + bot().setKey(KEY_LSHIFT, false); + } + + // Cross-side oracle: the server agrees. String riding = exec("artest player riding-entity"); assertEquals("after dismount, player must report no riding entity (-1)", -1, extract(riding, RIDING_ID)); } + /** Polls until the CLIENT reports riding == expected (~10 s cap). */ + private com.google.gson.JsonObject waitForClientRiding(boolean expected) throws Exception { + com.google.gson.JsonObject last = null; + for (int waited = 0; waited < 200; waited += 5) { + bot().waitTicks(5); + last = bot().reportRidingEntity(); + if (last.get("riding").getAsBoolean() == expected) { + return last; + } + } + throw new AssertionError("client never reached riding=" + expected + + "; last report: " + last); + } + private static int extract(String src, Pattern pattern) { Matcher m = pattern.matcher(src); assertTrue("pattern not found in: " + src, m.find()); diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/HovercraftRideE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/HovercraftRideE2ETest.java index f3f641730..dcf29e0dc 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/HovercraftRideE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/HovercraftRideE2ETest.java @@ -31,15 +31,14 @@ *
    • No input → hovercraft hovers (lateral position stable).
    • * * - *

      Bot-driven vs probe-driven inputs: AR's testClient - * {@code ClientBot} surface doesn't include "right-click on entity", - * "sneak", or "forward movement input" — only block right-clicks - * and GUI clicks are exposed. To pin hovercraft ride behaviour we - * drive mount / dismount / moveForward via new server-side probe - * verbs ({@code /artest player mount-entity}, {@code dismount}, - * {@code set-move-forward}). The observable result is identical: - * the EntityHoverCraft sees the same {@code player.moveForward} - * field that {@code getPassengerMovingForward()} reads from.

      + *

      Honest-e2e shape (per honest-client-e2e.md): mounting stays a + * server probe — the SOP explicitly allows "mount" as arrange. The RIDE + * contracts are then driven through the real client input surface: the + * forward key (W) feeds {@code MovementInput} → {@code CPacketInput} → + * server {@code player.moveForward} → {@code getPassengerMovingForward()}, + * and sneak (LSHIFT) drives the vanilla wants-to-stop-riding dismount. + * Observations read the CLIENT view via {@code reportRidingEntity}, with + * server probes kept as cross-side oracles.

      * *

      No fuel test: the EntityHoverCraft class has zero fuel * or energy logic — onUpdate only reads player input and applies @@ -50,6 +49,10 @@ */ public class HovercraftRideE2ETest extends AbstractClientE2ETest { + /** LWJGL key codes for the vanilla default binds. */ + private static final int KEY_W = 17; + private static final int KEY_LSHIFT = 42; + private static final Pattern ENTITY_ID = Pattern.compile("\"entityId\":(-?\\d+)"); private static final Pattern RIDING_ID = Pattern.compile("\"ridingEntityId(?:Now)?\":(-?\\d+)"); private static final Pattern POS_X = Pattern.compile("\"posX\":(-?\\d+(?:\\.\\d+)?)"); @@ -87,11 +90,21 @@ public void playerMountsHovercraftViaStartRiding() throws Exception { assertTrue("mount-entity must report mounted:true: " + mount, mount.contains("\"mounted\":true")); + // CLIENT truth: the bot's own client must render itself riding the + // craft — datawatcher/mount sync reaching the rendered frame. + com.google.gson.JsonObject clientRiding = waitForClientRiding(true); + assertTrue("client must report riding=true after mount: " + clientRiding, + clientRiding.get("riding").getAsBoolean()); + assertEquals("client-side ridden entity id must be the craft's id", + craftId, clientRiding.get("entityId").getAsInt()); + assertTrue("client-side ridden entity class must be EntityHoverCraft: " + + clientRiding, + clientRiding.get("entityClass").getAsString().contains("EntityHoverCraft")); + + // Cross-side oracle: the server agrees. String riding = exec("artest player riding-entity"); assertEquals("after mount, riding-entity probe must report the craft's id", craftId, extract(riding, RIDING_ID)); - assertTrue("riding entity class must be EntityHoverCraft: " + riding, - riding.contains("EntityHoverCraft")); } @Test @@ -102,13 +115,23 @@ public void playerDismountClearsRidingEntity() throws Exception { int craftId = spawnHovercraftAt(28.5, 79, 10.5); exec("artest player mount-entity " + craftId); + com.google.gson.JsonObject mounted = waitForClientRiding(true); + assertEquals("arrange: client must be riding the craft first", + craftId, mounted.get("entityId").getAsInt()); - String dismount = exec("artest player dismount"); - assertTrue("dismount probe must succeed: " + dismount, - dismount.contains("\"ok\":true")); - assertEquals("dismount must report ridingEntityIdNow:-1", - -1, extract(dismount, RIDING_ID)); + // The REAL dismount input: hold sneak — EntityPlayerSP's + // wants-to-stop-riding path sends the dismount to the server. + bot().setKey(KEY_LSHIFT, true); + try { + com.google.gson.JsonObject clientRiding = waitForClientRiding(false); + assertTrue("client must report riding=false after sneak-dismount: " + + clientRiding, + !clientRiding.get("riding").getAsBoolean()); + } finally { + bot().setKey(KEY_LSHIFT, false); + } + // Cross-side oracle: the server agrees. String riding = exec("artest player riding-entity"); assertEquals("after dismount, player must report no riding entity (-1)", -1, extract(riding, RIDING_ID)); @@ -126,25 +149,28 @@ public void forwardThrottleMovesHovercraftLaterally() throws Exception { int craftId = spawnHovercraftAt(48.5, 79, 10.5); exec("artest player mount-entity " + craftId); - // Reset any latent moveForward from prior input. - exec("artest player set-move-forward 0"); - bot().waitTicks(2); + waitForClientRiding(true); - // Snapshot baseline lateral position. - String preInfo = exec("artest entity info 0 " + craftId); - double xBefore = extractDouble(preInfo, POS_X); - double zBefore = extractDouble(preInfo, POS_Z); + // Baseline lateral position as the CLIENT renders the ridden craft. + com.google.gson.JsonObject pre = bot().reportRidingEntity(); + double xBefore = pre.get("posX").getAsDouble(); + double zBefore = pre.get("posZ").getAsDouble(); - // Drive forward — the combined probe re-applies moveForward - // inline before each onUpdate so the bot client's CPacketInput - // doesn't reset the field between iterations. - String drive = exec("artest player drive-ridden-entity 1 40"); - assertTrue("drive-ridden-entity must succeed: " + drive, - drive.contains("\"ok\":true")); + // Drive forward with the REAL forward key: W feeds MovementInput → + // CPacketInput → server player.moveForward, which EntityHoverCraft's + // getPassengerMovingForward() reads each tick. + bot().setKey(KEY_W, true); + try { + bot().waitTicks(40); + } finally { + bot().setKey(KEY_W, false); + } - String postInfo = exec("artest entity info 0 " + craftId); - double xAfter = extractDouble(postInfo, POS_X); - double zAfter = extractDouble(postInfo, POS_Z); + com.google.gson.JsonObject post = bot().reportRidingEntity(); + assertTrue("client must still be riding after the throttle window: " + post, + post.get("riding").getAsBoolean()); + double xAfter = post.get("posX").getAsDouble(); + double zAfter = post.get("posZ").getAsDouble(); double dx = xAfter - xBefore; double dz = zAfter - zBefore; @@ -155,8 +181,13 @@ public void forwardThrottleMovesHovercraftLaterally() throws Exception { + " after=(" + xAfter + "," + zAfter + ")", lateralDist > 0.1); - // Cleanup — release throttle so subsequent tests start fresh. - exec("artest player set-move-forward 0"); + // Cross-side oracle: the server's craft position agrees with the + // client-rendered one (within interpolation tolerance). + String postInfo = exec("artest entity info 0 " + craftId); + assertTrue("server craft X must agree with the client view: " + postInfo, + Math.abs(extractDouble(postInfo, POS_X) - xAfter) < 4.0); + + // Cleanup — dismount so subsequent tests start fresh. exec("artest player dismount"); } @@ -198,6 +229,20 @@ public void unmountedHovercraftDoesNotMoveLaterally() throws Exception { lateralDrift < 0.5); } + /** Polls until the CLIENT reports riding == expected (~10 s cap). */ + private com.google.gson.JsonObject waitForClientRiding(boolean expected) throws Exception { + com.google.gson.JsonObject last = null; + for (int waited = 0; waited < 200; waited += 5) { + bot().waitTicks(5); + last = bot().reportRidingEntity(); + if (last.get("riding").getAsBoolean() == expected) { + return last; + } + } + throw new AssertionError("client never reached riding=" + expected + + "; last report: " + last); + } + private static int extract(String src, Pattern pattern) { Matcher m = pattern.matcher(src); assertTrue("pattern not found in: " + src, m.find()); From eb5b68cfca4b331878dcb30fa1790a3a6645172b Mon Sep 17 00:00:00 2001 From: StannisMod Date: Thu, 11 Jun 2026 08:51:00 +0200 Subject: [PATCH 43/47] =?UTF-8?q?test:=20honest-e2e=20the=20item-use=20tes?= =?UTF-8?q?ts=20=E2=80=94=20real=20client=20clicks,=20player-layer=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five item tests stop simulating right-clicks through try-* server probes and click through the real client: useItem (CPacketPlayerTryUseItem) for in-air uses, interactBlock for the seal detector's block target, with held-item sync polled before each click (TheOneProbe's join-gift note otherwise races the equip). - ItemHovercraftSpawnE2ETest: aim via setLook + useItem; client world entity count (report_entities) + client-rendered held stack consumption. - OreScannerRightClickClientE2ETest: the resolved-satellite branch now asserts the OreMapping GUI actually OPENS on the client screen — the old probe pinned only 'no crash' and could not see that the GUI never opened for long satellite ids (ItemOreScanner casts the stored id to int before the registry lookup; the new arrange probe uses an int-safe id and documents the truncation hazard). - ItemAtmosphereAnalzerReadoutE2ETest: both readout lines asserted on the client chat overlay, i18n resolved ('Atmosphere Type: …air', 'Breathable: yes'). - ItemSealDetectorPlayerMessagesE2ETest: fixtures lifted to open air, the player perches next to the block and clicks it; branch replies asserted as the resolved chat text the player reads, server mirror kept as the cross-side oracle. - ItemBiomeChangerSatelliteActionE2ETest: real click + posList growth via the new poslist-size oracle. artest grows arrange-only splits of the old combined probes (player equip-orescanner / equip-biomechanger, satellite poslist-size); probe-infra shape tests tied to the retired try-* stimuli are removed. 12 methods green. --- .../command/test/TestProbeCommand.java | 95 +++++++++++ .../ItemAtmosphereAnalzerReadoutE2ETest.java | 125 ++++++-------- ...temBiomeChangerSatelliteActionE2ETest.java | 116 ++++++------- .../client/ItemHovercraftSpawnE2ETest.java | 152 ++++++++++-------- ...ItemSealDetectorPlayerMessagesE2ETest.java | 116 +++++++------ .../OreScannerRightClickClientE2ETest.java | 110 +++++++------ 6 files changed, 408 insertions(+), 306 deletions(-) diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index 5913b3b47..eab775002 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -2617,6 +2617,23 @@ private void handleSatellite(MinecraftServer server, ICommandSender sender, Stri } return; } + if ("poslist-size".equalsIgnoreCase(args[0]) && args.length >= 3) { + // /artest satellite poslist-size — save-format view + // of a SatelliteBiomeChanger's queued positions (posList ints). + int dim = parseIntOr(args[1], Integer.MIN_VALUE); + long satId = parseLongOr(args[2], Long.MIN_VALUE); + DimensionProperties props = DimensionManager.getInstance().getDimensionProperties(dim); + SatelliteBase sat = props == null ? null : props.getSatellite(satId); + if (sat == null) { + send(sender, "{\"error\":\"satellite not found\",\"dim\":" + dim + ",\"satId\":" + satId + "}"); + return; + } + net.minecraft.nbt.NBTTagCompound snap = new net.minecraft.nbt.NBTTagCompound(); + sat.writeToNBT(snap); + int size = snap.getIntArray("posList").length; + send(sender, "{\"ok\":true,\"satId\":" + satId + ",\"posListSize\":" + size + "}"); + return; + } if ("weather-list-size".equalsIgnoreCase(args[0]) && args.length >= 3) { int dim = parseIntOr(args[1], Integer.MIN_VALUE); long satId = parseLongOr(args[2], Long.MIN_VALUE); @@ -10779,6 +10796,84 @@ private void handlePlayer(MinecraftServer server, ICommandSender sender, String[ + "}"); return; } + if ("equip-orescanner".equals(sub)) { + // /artest player equip-orescanner [register-satellite-on-dim|none] + // + // Arrange-only split of try-orescanner-rclick for honest client + // e2e: registers the SatelliteOreMapping (when a dim is given), + // seeds the held item's NBT, equips — and does NOT click. The + // click comes from the real client (ClientBot.useItem). + int satRegisterDim = (args.length >= 2 && !"none".equalsIgnoreCase(args[1])) + ? parseIntOr(args[1], Integer.MIN_VALUE) : Integer.MIN_VALUE; + long satId = -1; + if (satRegisterDim != Integer.MIN_VALUE) { + net.minecraft.world.WorldServer satWorld = server.getWorld(satRegisterDim); + zmaster587.advancedRocketry.dimension.DimensionProperties props = satWorld == null ? null + : zmaster587.advancedRocketry.dimension.DimensionManager.getInstance() + .getDimensionProperties(satRegisterDim); + if (satWorld != null && props != null) { + zmaster587.advancedRocketry.satellite.SatelliteOreMapping sat = + new zmaster587.advancedRocketry.satellite.SatelliteOreMapping(); + // INT-SAFE id: ItemOreScanner.onItemRightClick casts the + // stored id to (int) before the registry lookup — a full + // nanoTime() long would never resolve and the GUI would + // silently not open (the bug the old try- probe couldn't + // see because it only pinned "no crash"). + satId = System.nanoTime() & 0x7FFFFFFFL; + sat.getProperties().setId(satId); + props.addSatellite(sat, satWorld); + } + } + net.minecraft.item.Item scanner = + zmaster587.advancedRocketry.api.AdvancedRocketryItems.itemOreScanner; + net.minecraft.item.ItemStack held = new net.minecraft.item.ItemStack(scanner); + if (satId != -1) { + ((zmaster587.advancedRocketry.item.ItemOreScanner) scanner) + .setSatelliteID(held, satId); + } + player.setHeldItem(net.minecraft.util.EnumHand.MAIN_HAND, held); + send(sender, "{\"ok\":true,\"hadSatelliteId\":" + (satId != -1) + + ",\"satelliteId\":" + satId + + ",\"registeredOnDim\":" + satRegisterDim + "}"); + return; + } + if ("equip-biomechanger".equals(sub) && args.length >= 2) { + // /artest player equip-biomechanger + // + // Arrange-only split of try-biomechanger-rclick: registers the + // SatelliteBiomeChanger, equips the NBT-bound chip — no click. + // Pair with `artest satellite poslist-size` as the post-click oracle. + int dim = parseIntOr(args[1], Integer.MIN_VALUE); + net.minecraft.world.WorldServer world = server.getWorld(dim); + if (world == null) { + send(sender, "{\"error\":\"world not loaded\",\"dim\":" + dim + "}"); + return; + } + zmaster587.advancedRocketry.dimension.DimensionProperties props = + zmaster587.advancedRocketry.dimension.DimensionManager.getInstance() + .getDimensionProperties(dim); + if (props == null) { + send(sender, "{\"error\":\"no DimensionProperties for dim\",\"dim\":" + dim + "}"); + return; + } + zmaster587.advancedRocketry.satellite.SatelliteBiomeChanger sat = + new zmaster587.advancedRocketry.satellite.SatelliteBiomeChanger(); + long satId = System.nanoTime(); + sat.getProperties().setId(satId); + props.addSatellite(sat, world); + + net.minecraft.item.Item chip = + zmaster587.advancedRocketry.api.AdvancedRocketryItems.itemBiomeChanger; + net.minecraft.item.ItemStack held = new net.minecraft.item.ItemStack(chip); + net.minecraft.nbt.NBTTagCompound chipNbt = new net.minecraft.nbt.NBTTagCompound(); + chipNbt.setString("satelliteName", sat.getName()); + chipNbt.setInteger("dimId", dim); + chipNbt.setLong("satelliteId", satId); + held.setTagCompound(chipNbt); + player.setHeldItem(net.minecraft.util.EnumHand.MAIN_HAND, held); + send(sender, "{\"ok\":true,\"dim\":" + dim + ",\"satId\":" + satId + "}"); + return; + } if ("try-orescanner-rclick".equals(sub)) { // /artest player try-orescanner-rclick [register-satellite-on-dim] // diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/ItemAtmosphereAnalzerReadoutE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/ItemAtmosphereAnalzerReadoutE2ETest.java index 5bd5455b9..7da6384cb 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/ItemAtmosphereAnalzerReadoutE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/ItemAtmosphereAnalzerReadoutE2ETest.java @@ -1,92 +1,71 @@ package zmaster587.advancedRocketry.test.client; import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; +import com.google.gson.JsonArray; import org.junit.Test; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; /** - * TASK-10b Phase 7 — player-visible side of - * {@link zmaster587.advancedRocketry.item.ItemAtmosphereAnalzer#onItemRightClick}. + * Player-visible side of + * {@code ItemAtmosphereAnalzer#onItemRightClick}, driven through the REAL + * client item-use path ({@code ClientBot.useItem}) and observed on the REAL + * client chat overlay ({@code reportChat}) — i18n already resolved, exactly + * the two lines the player reads. * - *

      Production dispatches TWO chat lines on right-click:

      - *
        - *
      • line 1: {@code "%s %s %s"} wrapping - * ({@code msg.atmanal.atmtype}, atmType name, pressure string)
      • - *
      • line 2: {@code "%s %s"} wrapping - * ({@code msg.atmanal.canbreathe}, {@code msg.yes} or {@code msg.no})
      • - *
      - * - *

      On dim 0 there's typically no {@code AtmosphereHandler} registered, - * so {@code getOxygenHandler} returns null and - * {@code getAtmosphereReadout} substitutes {@code AtmosphereType.AIR} - * — the i18n suffix is the literal {@code "air"} (from - * {@code AtmosphereType.AIR.getUnlocalizedName()}) and breathable=yes. - * That is the contract pinned here: a vanilla-dim right-click reports - * AIR + breathable, regardless of whether an oxygen handler exists.

      - * - *

      The chat-tap captures translation keys by joining the outer key - * with every nested translation key (DFS) separated by {@code |}; tests - * assert on substring presence so they don't depend on i18n output.

      - * - *

      Gated by {@code forge.test.client.enabled=true}; auto-skips on - * headless CI.

      + *

      Dim 0 has no AtmosphereHandler → production falls back to + * {@code AtmosphereType.AIR}. Both lines must reach the player's screen: + * "Atmosphere Type: …air…" and "Breathable: yes".

      */ public class ItemAtmosphereAnalzerReadoutE2ETest extends AbstractClientE2ETest { - /** Dim 0 has no AtmosphereHandler → production falls back to - * AtmosphereType.AIR. Both lines must reach the player: line 1 - * carries msg.atmanal.atmtype + the AIR i18n suffix ("air"), line 2 - * carries msg.atmanal.canbreathe + msg.yes (AIR is breathable). */ - @Test - public void rightClickInVanillaDimDispatchesAirReadoutToPlayer() throws Exception { - serverClient().execute("artest player chat-clear"); - String resp = String.join("\n", serverClient().execute( - "artest player try-atm-analyze 0")); - assertFalse("try-atm-analyze must not error; resp=" + resp, - resp.contains("\"error\"")); - // Exactly two messages must have been dispatched. - assertTrue("expected messageCount=2; resp=" + resp, - resp.contains("\"messageCount\":2")); - - // Line 1 (atmType): outer format + msg.atmanal.atmtype + AIR - // i18n key "air". All three must be present in the captured key. - assertTrue("line 1 must include msg.atmanal.atmtype; resp=" + resp, - resp.contains("msg.atmanal.atmtype")); - assertTrue("line 1 must include the AIR atm-name key (\"air\"); resp=" + resp, - resp.contains("|air")); - - // Line 2 (canbreathe): outer format + msg.atmanal.canbreathe + msg.yes - assertTrue("line 2 must include msg.atmanal.canbreathe; resp=" + resp, - resp.contains("msg.atmanal.canbreathe")); - assertTrue("line 2 must include msg.yes (AIR is breathable); resp=" + resp, - resp.contains("msg.yes")); - // And must NOT report msg.no (no false negatives on a breathable atm). - assertFalse("line 2 must NOT report msg.no for breathable AIR; resp=" + resp, - resp.contains("msg.no")); + private String exec(String cmd) throws Exception { + return String.join("\n", serverClient().execute(cmd)); } - /** Probe must surface an error JSON when the dim arg is missing, - * matching the rest of the /artest player error envelope. Catches - * accidental signature changes that would silently no-op. */ - @Test - public void tryAtmAnalyzeErrorsWithoutDim() throws Exception { - String resp = String.join("\n", serverClient().execute( - "artest player try-atm-analyze")); - assertTrue("missing args must surface an error; resp=" + resp, - resp.contains("\"error\"")); + /** Polls until the CLIENT renders {@code itemId} in the main hand (~10 s cap) + * — server-side equips need a sync round-trip before the click. */ + private void waitForHeld(String itemId) throws Exception { + String held = ""; + for (int waited = 0; waited < 200; waited += 5) { + bot().waitTicks(5); + held = bot().reportPlayerItems().getAsJsonObject("held").get("id").getAsString(); + if (itemId.equals(held)) return; + } + throw new AssertionError("client never rendered " + itemId + + " in hand; held=" + held); } - /** Probe must surface a clear error for an unloaded dim rather than - * silently emitting no messages — catches typo'd dim ids. */ @Test - public void tryAtmAnalyzeErrorsForUnloadedDim() throws Exception { - String resp = String.join("\n", serverClient().execute( - "artest player try-atm-analyze 999999")); - assertTrue("unloaded dim must surface an error; resp=" + resp, - resp.contains("\"error\"")); - assertTrue("error must identify the dim id; resp=" + resp, - resp.contains("\"dim\":999999")); + public void rightClickInVanillaDimDispatchesAirReadoutToPlayerChat() throws Exception { + bot().waitForWorld(); + String give = exec("artest player give-held advancedrocketry:atmanalyser"); + assertTrue("give-held atmanalyser must succeed: " + give, + give.contains("\"ok\":true")); + waitForHeld("advancedrocketry:atmanalyser"); + + // The REAL right-click from the client. + bot().useItem(); + + // Both readout lines must land on the CLIENT chat overlay, with the + // lang keys resolved (msg.atmanal.atmtype → "Atmosphere Type: ", + // msg.atmanal.canbreathe → "Breathable: " + msg.yes → "yes"). + boolean sawType = false; + boolean sawBreathableYes = false; + for (int waited = 0; waited < 100 && !(sawType && sawBreathableYes); waited += 10) { + bot().waitTicks(10); + JsonArray lines = bot().reportChat(10).getAsJsonArray("lines"); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i).getAsString().toLowerCase(java.util.Locale.ROOT); + if (line.contains("atmosphere type") && line.contains("air")) sawType = true; + if (line.contains("breathable")) { + assertTrue("breathable line must read 'yes' for AIR, got: " + line, + line.contains("yes")); + sawBreathableYes = true; + } + } + } + assertTrue("client chat must show the resolved 'Atmosphere Type: …air' line", sawType); + assertTrue("client chat must show the resolved 'Breathable: yes' line", sawBreathableYes); } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/ItemBiomeChangerSatelliteActionE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/ItemBiomeChangerSatelliteActionE2ETest.java index de5b1a326..09dd38548 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/ItemBiomeChangerSatelliteActionE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/ItemBiomeChangerSatelliteActionE2ETest.java @@ -6,72 +6,80 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; /** - * TASK-10b Phase 7 — player-visible side of - * {@link zmaster587.advancedRocketry.item.ItemBiomeChanger#onItemRightClick}. + * Player-visible side of {@code ItemBiomeChanger#onItemRightClick}, driven + * through the REAL client item-use path ({@code ClientBot.useItem}). * - *

      Contract: right-clicking a programmed BiomeChanger chip in the same - * dimension as its registered SatelliteBiomeChanger calls - * {@code SatelliteBiomeChanger.performAction(player, world, pos)}, which - * queues a radius-12 + noise field of positions into the satellite's - * save-format {@code posList} (int-array NBT key). The queue is then - * drained over server ticks to actually mutate biomes; this test pins - * only the queue-population step because the i/o-bound drain is a - * separate behavioural slice (rate-of-drain is impl-detail per SOP).

      - * - *

      Pin shape: {@code posList} NBT key (declared in - * {@link zmaster587.advancedRocketry.satellite.SatelliteBiomeChanger#writeToNBT}). - * Tests a save-format contract — if production stops writing posList, - * existing-world saves with queued biome changes silently drop them - * on the next boot (player-visible regression). If production stops - * populating the queue on right-click, the chip becomes a no-op.

      - * - *

      Gated by {@code forge.test.client.enabled=true}; auto-skips on - * headless CI.

      + *

      Arrange uses the arrange-only {@code artest player equip-biomechanger} + * probe (register SatelliteBiomeChanger + equip the NBT-bound chip — no + * click). The client performs the actual right-click; the satellite's + * queued-position list is then read back through the + * {@code artest satellite poslist-size} oracle — server state is the + * contract here (save-format posList), the CLIENT contributes the stimulus + * and the held-chip view.

      */ public class ItemBiomeChangerSatelliteActionE2ETest extends AbstractClientE2ETest { - private static final Pattern DELTA = Pattern.compile("\"posListDelta\":(-?\\d+)"); + private static final Pattern SAT_ID = Pattern.compile("\"satId\":(-?\\d+)"); + private static final Pattern POSLIST_SIZE = Pattern.compile("\"posListSize\":(-?\\d+)"); + + private String exec(String cmd) throws Exception { + return String.join("\n", serverClient().execute(cmd)); + } + + /** Polls until the CLIENT renders {@code itemId} in the main hand (~10 s cap) + * — server-side equips need a sync round-trip before the click. */ + private void waitForHeld(String itemId) throws Exception { + String held = ""; + for (int waited = 0; waited < 200; waited += 5) { + bot().waitTicks(5); + held = bot().reportPlayerItems().getAsJsonObject("held").get("id").getAsString(); + if (itemId.equals(held)) return; + } + throw new AssertionError("client never rendered " + itemId + + " in hand; held=" + held); + } - /** Same-dim right-click on a programmed chip must enqueue at least - * one position into posList (production radius=12 + noise field - * guarantees many entries; the loose lower bound of >= 1 stays - * contract-faithful instead of pinning the magic radius/noise - * constants). - * - *

      Each posList entry is a 3-int triple (x, y, z) so the int-array - * length must be divisible by 3 — pin that too, as the - * {@link zmaster587.advancedRocketry.satellite.SatelliteBiomeChanger#readFromNBT} - * reader splits by stride-3 and would crash on a non-multiple. */ @Test - public void rightClickInSameDimEnqueuesPositionsIntoSatellitePosList() throws Exception { - String resp = String.join("\n", serverClient().execute( - "artest player try-biomechanger-rclick 0")); - assertFalse("try-biomechanger-rclick must not error; resp=" + resp, - resp.contains("\"error\"")); + public void rightClickQueuesPositionsIntoSatellitePosList() throws Exception { + bot().waitForWorld(); + + String equip = exec("artest player equip-biomechanger 0"); + assertTrue("equip-biomechanger must succeed: " + equip, equip.contains("\"ok\":true")); + Matcher m = SAT_ID.matcher(equip); + assertTrue("equip response must carry satId: " + equip, m.find()); + long satId = Long.parseLong(m.group(1)); + + // CLIENT view of the arrange: the chip is in hand (poll — the + // server-side equip needs a sync round-trip). + waitForHeld("advancedrocketry:biomechanger"); - Matcher m = DELTA.matcher(resp); - assertTrue("expected posListDelta field in: " + resp, m.find()); - int delta = Integer.parseInt(m.group(1)); + String before = exec("artest satellite poslist-size 0 " + satId); + int posBefore = extractInt(before, POSLIST_SIZE); - assertTrue("right-click on a programmed BiomeChanger in same dim " - + "must enqueue >= 1 position triple (delta in ints " - + ">= 3); got delta=" + delta + "; resp=" + resp, - delta >= 3); - assertTrue("posList entries are (x,y,z) triples — int-array length " - + "delta must be a multiple of 3; got delta=" + delta, - delta % 3 == 0); + // The REAL right-click from the client. + bot().useItem(); + + // Oracle: the satellite queued at least one (x,y,z) triple. + int posAfter = -1; + for (int waited = 0; waited < 100; waited += 10) { + bot().waitTicks(10); + posAfter = extractInt(exec("artest satellite poslist-size 0 " + satId), POSLIST_SIZE); + if (posAfter > posBefore) break; + } + assertTrue("right-click must queue positions into the satellite's posList " + + "(before=" + posBefore + ", after=" + posAfter + ")", + posAfter > posBefore); + assertEquals("posList stores (x,y,z) triples — length must be divisible by 3, got " + + posAfter, 0, posAfter % 3); } - /** Probe must surface an error JSON when the dim arg is missing. */ - @Test - public void tryBiomeChangerRclickErrorsWithoutDim() throws Exception { - String resp = String.join("\n", serverClient().execute( - "artest player try-biomechanger-rclick")); - assertTrue("missing args must surface an error; resp=" + resp, - resp.contains("\"error\"")); + private static int extractInt(String src, Pattern pattern) { + Matcher m = pattern.matcher(src); + assertTrue("pattern " + pattern.pattern() + " not found in: " + src, m.find()); + return Integer.parseInt(m.group(1)); } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/ItemHovercraftSpawnE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/ItemHovercraftSpawnE2ETest.java index 1eec3f5da..8104a93fc 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/ItemHovercraftSpawnE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/ItemHovercraftSpawnE2ETest.java @@ -1,116 +1,126 @@ package zmaster587.advancedRocketry.test.client; import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; +import com.google.gson.JsonObject; import org.junit.Test; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; /** * TASK-10b Phase 7 — player-visible side of - * {@link zmaster587.advancedRocketry.item.ItemHovercraft#onItemRightClick}. + * {@link zmaster587.advancedRocketry.item.ItemHovercraft#onItemRightClick}, + * driven the way the player drives it: a REAL item right-click + * ({@code ClientBot.useItem} → {@code CPacketPlayerTryUseItem}) with the look + * aimed via {@code setLook}, observed at the layer the player sees — + * the CLIENT world's entity list ({@code reportEntities}) and the + * CLIENT-rendered held stack ({@code reportPlayerItems}). * - *

      Contract: right-click while looking at a block within ~5 blocks - * spawns an EntityHoverCraft at the hit position and (in survival) - * consumes one item from the stack. PASS if nothing is in front of the - * player; FAIL if there is no room to spawn at the hit pos.

      + *

      Contract: right-click while looking at a block within ~5 blocks spawns + * an EntityHoverCraft at the hit position and (in survival) consumes one + * item; right-click into open air passes without spawning or consuming.

      * - *

      Fixture: place a stone block at (X, Y, Z), teleport player two - * blocks above looking straight down. The 5-block ray-trace from the - * eye hits the stone top face — entity spawns at the hit pos.

      - * - *

      Gated by {@code forge.test.client.enabled=true}; auto-skips on - * headless CI.

      + *

      Fixture: stone block at (X, Y, Z), player two blocks above looking + * straight down — the item's 5-block eye ray hits the stone top face.

      */ public class ItemHovercraftSpawnE2ETest extends AbstractClientE2ETest { private static final int DIM = 0; // Distinct fixture column from SealDetector (300..350) and the - // existing inventory-bypass test (-200..-200) so multiple tests - // can share one testClient JVM without colliding. + // inventory-bypass test (-200..-200) so multiple tests can share one + // testClient JVM without colliding. private static final int X = 400; // High above natural overworld terrain so the EntityHoverCraft's - // 2.5×1×2.5 bounding box (shrunk to -0.1) has guaranteed empty - // neighbours when checked at hitVec.y + small offset — terrain at - // y≈72 caused intermittent FAIL from incidental grass/leaves - // intersecting the spawn box. + // bounding box has guaranteed empty neighbours at the hit pos. private static final int Y_BLOCK = 150; private static final int Z = 300; + private String exec(String cmd) throws Exception { + return String.join("\n", serverClient().execute(cmd)); + } + private void forceLoadAround(int x, int z) throws Exception { int cx = x >> 4; int cz = z >> 4; for (int dx = -1; dx <= 1; dx++) { for (int dz = -1; dz <= 1; dz++) { - serverClient().execute("artest chunk forceload " + DIM - + " " + (cx + dx) + " " + (cz + dz)); + exec("artest chunk forceload " + DIM + " " + (cx + dx) + " " + (cz + dz)); } } } /** Right-click looking down at a stone block must spawn exactly one - * EntityHoverCraft and (in survival) consume the held stack. */ + * EntityHoverCraft the CLIENT can see, and consume the held stack + * (survival). */ @Test public void rightClickAtTargetBlockSpawnsHovercraftAndConsumesStack() throws Exception { + bot().waitForWorld(); forceLoadAround(X, Z); - // Place fixture block under the player's eye line. - String placeResp = String.join("\n", serverClient().execute( - "artest place " + DIM + " " + X + " " + Y_BLOCK + " " + Z + " minecraft:stone")); - assertFalse("place must not error; resp=" + placeResp, - placeResp.contains("\"error\"")); - - // Player 2 blocks above, looking straight down. The 5-block ray - // from the eye (~(Y_BLOCK+2)+1.62) hits the stone top face. - double px = X + 0.5; - double py = Y_BLOCK + 2; - double pz = Z + 0.5; - String resp = String.join("\n", serverClient().execute( - "artest player try-hovercraft " + DIM + " " - + px + " " + py + " " + pz + " 0 90")); - - assertFalse("try-hovercraft must not error; resp=" + resp, - resp.contains("\"error\"")); - assertTrue("right-click on a target block must SUCCEED; resp=" + resp, - resp.contains("\"result\":\"SUCCESS\"")); - assertTrue("exactly one EntityHoverCraft must have spawned; resp=" + resp, - resp.contains("\"entityDelta\":1")); - assertTrue("survival player must have stack consumed (0 left); resp=" + resp, - resp.contains("\"heldAfter\":0")); - assertTrue("probe must confirm survival gamemode for the consume pin; resp=" + resp, - resp.contains("\"creative\":false")); + String placeResp = exec("artest place " + DIM + " " + X + " " + Y_BLOCK + " " + Z + " minecraft:stone"); + assertFalse("place must not error; resp=" + placeResp, placeResp.contains("\"error\"")); + + // Arrange: survival player two blocks above the stone, holding the + // hovercraft item. + exec("gamemode survival @a"); + String give = exec("artest player give-held advancedrocketry:hovercraft"); + assertTrue("give-held must succeed: " + give, give.contains("\"ok\":true")); + exec("tp @a " + (X + 0.5) + " " + (Y_BLOCK + 2) + " " + (Z + 0.5)); + bot().waitTicks(10); + assertEquals("arrange: client must render the hovercraft item in hand", + "advancedrocketry:hovercraft", + bot().reportPlayerItems().getAsJsonObject("held").get("id").getAsString()); + + // The REAL stimulus: aim straight down, right-click the held item. + bot().setLook(0f, 90f); + bot().useItem(); + + // CLIENT truth #1: the client world now contains exactly one + // hovercraft near the player. + int seen = waitForClientEntityCount("EntityHoverCraft", 1); + assertEquals("client must see exactly one spawned EntityHoverCraft", 1, seen); + + // CLIENT truth #2: the held stack was consumed (survival). + JsonObject held = bot().reportPlayerItems().getAsJsonObject("held"); + assertEquals("survival right-click must consume the held hovercraft item; held=" + + held, 0, held.get("count").getAsInt()); } - /** Right-click into open air (no block within 5 blocks of the eye) - * must PASS rather than SUCCESS — no entity spawned, stack - * preserved. Pins the empty-ray-trace branch. */ + /** Right-click into open air (no block within 5 blocks of the eye) must + * pass: no entity spawned, stack preserved. Pins the empty-ray-trace + * branch. */ @Test public void rightClickIntoEmptyAirReturnsPassWithoutSpawn() throws Exception { + bot().waitForWorld(); forceLoadAround(X + 20, Z); -// Player at y=200 looking up — nothing within 5 blocks. - double px = X + 20 + 0.5; - double py = 200; - double pz = Z + 0.5; - String resp = String.join("\n", serverClient().execute( - "artest player try-hovercraft " + DIM + " " - + px + " " + py + " " + pz + " 0 -90")); - - assertFalse("try-hovercraft must not error; resp=" + resp, - resp.contains("\"error\"")); - assertTrue("empty ray-trace must report PASS; resp=" + resp, - resp.contains("\"result\":\"PASS\"")); - assertTrue("no entity must have spawned; resp=" + resp, - resp.contains("\"entityDelta\":0")); - assertTrue("stack must NOT be consumed on PASS; resp=" + resp, - resp.contains("\"heldAfter\":1")); + + exec("gamemode survival @a"); + String give = exec("artest player give-held advancedrocketry:hovercraft"); + assertTrue("give-held must succeed: " + give, give.contains("\"ok\":true")); + exec("tp @a " + (X + 20 + 0.5) + " 200 " + (Z + 0.5)); + bot().waitTicks(10); + + // Aim straight UP into empty sky and right-click. + bot().setLook(0f, -90f); + bot().useItem(); + bot().waitTicks(20); + + assertEquals("no hovercraft must spawn on an empty ray-trace", + 0, bot().reportEntities("EntityHoverCraft", 32).get("count").getAsInt()); + JsonObject held = bot().reportPlayerItems().getAsJsonObject("held"); + assertEquals("stack must NOT be consumed on PASS; held=" + held, + 1, held.get("count").getAsInt()); } - /** Probe must surface an error JSON for missing args. */ - @Test - public void tryHovercraftErrorsWithoutFullArgs() throws Exception { - String resp = String.join("\n", serverClient().execute( - "artest player try-hovercraft 0 100")); - assertTrue("missing args must surface an error; resp=" + resp, - resp.contains("\"error\"")); + /** Polls until the CLIENT sees {@code expected} entities of the class (~10 s cap). */ + private int waitForClientEntityCount(String classContains, int expected) throws Exception { + int seen = -1; + for (int waited = 0; waited < 200; waited += 10) { + bot().waitTicks(10); + seen = bot().reportEntities(classContains, 32).get("count").getAsInt(); + if (seen == expected) return seen; + } + return seen; } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/ItemSealDetectorPlayerMessagesE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/ItemSealDetectorPlayerMessagesE2ETest.java index 95907a2bf..286b845e5 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/ItemSealDetectorPlayerMessagesE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/ItemSealDetectorPlayerMessagesE2ETest.java @@ -53,7 +53,7 @@ public class ItemSealDetectorPlayerMessagesE2ETest extends AbstractClientE2ETest { private static final int DIM = 0; - private static final int Y = 72; + private static final int Y = 150; private static final int Z = 300; // Distinct from SealDetectorDispatchTest (200..260 / y=80 / z=200) @@ -66,7 +66,6 @@ public class ItemSealDetectorPlayerMessagesE2ETest extends AbstractClientE2ETest private static final int X_SAND = 340; private static final int X_SLAB = 350; - private static final Pattern KEY = Pattern.compile("\"key\":\"([^\"]+)\""); private static final Pattern BRANCH = Pattern.compile("\"branch\":\"([^\"]+)\""); private void forceLoadAround(int x, int z) throws Exception { @@ -98,37 +97,59 @@ private String fieldOf(Pattern p, String src, String label) { return m.group(1); } - /** Calls the chat-tap-aware seal-detector probe at (x, Y, Z) and - * asserts the captured chat key is {@code msg.sealdetector.}. - * Also cross-pins the result against the server-tier seal-detector - * probe so any drift between production dispatch and the mirroring - * probe surfaces immediately. */ - private void assertSealDetectorBranch(int x, String fixtureBlock, String expected) throws Exception { - place(x, fixtureBlock); + /** Polls until the CLIENT renders {@code itemId} in the main hand (~10 s cap). */ + private void waitForHeld(String itemId) throws Exception { + String held = ""; + for (int waited = 0; waited < 200; waited += 5) { + bot().waitTicks(5); + held = bot().reportPlayerItems().getAsJsonObject("held").get("id").getAsString(); + if (itemId.equals(held)) return; + } + throw new AssertionError("client never rendered " + itemId + " in hand; held=" + held); + } - serverClient().execute("artest player chat-clear"); - String tryResp = String.join("\n", serverClient().execute( - "artest player try-seal-detect " + DIM + " " + x + " " + Y + " " + Z)); - assertFalse("try-seal-detect must not error at " + x + " (" + fixtureBlock - + "); resp=" + tryResp, tryResp.contains("\"error\"")); - String capturedKey = fieldOf(KEY, tryResp, "key"); - assertEquals("ItemSealDetector.onItemUse on " + fixtureBlock - + " at " + x + "," + Y + "," + Z + " must dispatch " - + "msg.sealdetector." + expected + "; resp=" + tryResp, - "msg.sealdetector." + expected, capturedKey); - String capturedBranch = fieldOf(BRANCH, tryResp, "branch"); - assertEquals("try-seal-detect branch field must equal i18n suffix", - expected, capturedBranch); + /** Stages the fixture at (x, Y, Z), stands the player on a stone perch one + * block away holding the seal detector, RIGHT-CLICKS the fixture through + * the real client ({@code interactBlock} → CPacketPlayerTryUseItemOnBlock), + * and asserts the i18n-RESOLVED reply lands on the player's chat overlay. + * Cross-pins the branch against the server-tier seal-detector mirror. */ + private void assertSealDetectorBranch(int x, String fixtureBlock, String expected, + String expectedChatText) throws Exception { + place(x, fixtureBlock); + // Perch for the player one block south of the fixture. + String perch = String.join("\n", serverClient().execute( + "artest place " + DIM + " " + x + " " + Y + " " + (Z - 2) + " minecraft:stone")); + assertFalse("perch place must not error: " + perch, perch.contains("\"error\"")); + + String give = String.join("\n", serverClient().execute( + "artest player give-held advancedrocketry:sealdetector")); + assertTrue("give-held sealdetector must succeed: " + give, give.contains("\"ok\":true")); + serverClient().execute("tp @a " + (x + 0.5) + " " + (Y + 1) + " " + (Z - 1.5)); + waitForHeld("advancedrocketry:sealdetector"); + + // The REAL right-click on the fixture block from the client. + bot().interactBlock(x, Y, Z); + + // The player must READ the branch's resolved message on their chat. + boolean found = false; + String newest = ""; + for (int waited = 0; waited < 100 && !found; waited += 10) { + bot().waitTicks(10); + com.google.gson.JsonArray lines = bot().reportChat(5).getAsJsonArray("lines"); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i).getAsString(); + if (newest.isEmpty()) newest = line; + if (line.contains(expectedChatText)) { found = true; break; } + } + } + assertTrue("client chat must show '" + expectedChatText + "' for " + fixtureBlock + + " at " + x + "," + Y + "," + Z + " (newest line: '" + newest + "')", found); // Cross-pin against the server-tier dispatch mirror. String checkResp = String.join("\n", serverClient().execute( "artest seal-detector check " + DIM + " " + x + " " + Y + " " + Z)); - String mirrorBranch = fieldOf(BRANCH, checkResp, "branch"); - assertEquals("production dispatch and server-tier mirror must agree on " - + "branch for " + fixtureBlock + " at " + x + "," + Y + "," + Z - + "; player-msg branch=" + capturedBranch - + " mirror branch=" + mirrorBranch, - capturedBranch, mirrorBranch); + assertEquals("production dispatch and server-tier mirror must agree on branch for " + + fixtureBlock, expected, fieldOf(BRANCH, checkResp, "branch")); } // ───────────────────── sealed branch ────────────────────────────────── @@ -136,7 +157,7 @@ private void assertSealDetectorBranch(int x, String fixtureBlock, String expecte /** Solid ROCK material full-block → "sealed". */ @Test public void stoneFixtureDispatchesSealedMessageToPlayer() throws Exception { - assertSealDetectorBranch(X_STONE, "minecraft:stone", "sealed"); + assertSealDetectorBranch(X_STONE, "minecraft:stone", "sealed", "Should hold a nice seal"); } /** Pins that "sealed" isn't pinned to the singular stone block — @@ -144,7 +165,7 @@ public void stoneFixtureDispatchesSealedMessageToPlayer() throws Exception { * "sealed", per SealableBlockHandler.isBlockSealed's material gate. */ @Test public void cobblestoneFixtureDispatchesSealedMessageToPlayer() throws Exception { - assertSealDetectorBranch(X_COBBLESTONE, "minecraft:cobblestone", "sealed"); + assertSealDetectorBranch(X_COBBLESTONE, "minecraft:cobblestone", "sealed", "Should hold a nice seal"); } // ───────────────────── notsealmat branch ────────────────────────────── @@ -152,14 +173,14 @@ public void cobblestoneFixtureDispatchesSealedMessageToPlayer() throws Exception /** Material.AIR is on the default materialBanList → "notsealmat". */ @Test public void airFixtureDispatchesNotSealMatMessageToPlayer() throws Exception { - assertSealDetectorBranch(X_AIR, "minecraft:air", "notsealmat"); + assertSealDetectorBranch(X_AIR, "minecraft:air", "notsealmat", "Material will not hold a seal"); } /** Material.LEAVES is on the default materialBanList — multi-material * ban-list pin (not just AIR). */ @Test public void leavesFixtureDispatchesNotSealMatMessageToPlayer() throws Exception { - assertSealDetectorBranch(X_LEAVES, "minecraft:leaves", "notsealmat"); + assertSealDetectorBranch(X_LEAVES, "minecraft:leaves", "notsealmat", "Material will not hold a seal"); } /** Material.SAND is on the default materialBanList — silent removal @@ -167,7 +188,7 @@ public void leavesFixtureDispatchesNotSealMatMessageToPlayer() throws Exception * regression). */ @Test public void sandFixtureDispatchesNotSealMatMessageToPlayer() throws Exception { - assertSealDetectorBranch(X_SAND, "minecraft:sand", "notsealmat"); + assertSealDetectorBranch(X_SAND, "minecraft:sand", "notsealmat", "Material will not hold a seal"); } // ───────────────────── other branch ─────────────────────────────────── @@ -177,34 +198,7 @@ public void sandFixtureDispatchesNotSealMatMessageToPlayer() throws Exception { * short-circuiting on the non-IFluidBlock check). */ @Test public void stoneSlabFixtureDispatchesOtherMessageToPlayer() throws Exception { - assertSealDetectorBranch(X_SLAB, "minecraft:stone_slab", "other"); + assertSealDetectorBranch(X_SLAB, "minecraft:stone_slab", "other", "Air will leak through this block"); } - // ───────────────────── chat-tap shape ───────────────────────────────── - - /** chat-clear must drain the deque so a follow-up last-chat reports - * no captured key — guards tests against cross-contamination from - * prior chat traffic (login messages, /tp output, etc.). */ - @Test - public void chatClearEmptiesTheCaptureDeque() throws Exception { - serverClient().execute("artest player chat-clear"); - String resp = String.join("\n", serverClient().execute( - "artest player last-chat")); - assertTrue("after chat-clear, last-chat must report size=0; resp=" + resp, - resp.contains("\"size\":0")); - assertTrue("after chat-clear, last-chat must report key=null; resp=" + resp, - resp.contains("\"key\":null")); - } - - /** Probe must surface an error JSON for missing args, matching the - * rest of the /artest player surface. Catches accidental signature - * changes that would silently no-op. */ - @Test - public void trySealDetectErrorsWithoutCoordinates() throws Exception { - String resp = String.join("\n", serverClient().execute( - "artest player try-seal-detect")); - assertNotNull(resp); - assertTrue("missing args must surface an error; resp=" + resp, - resp.contains("\"error\"")); - } } diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/OreScannerRightClickClientE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/OreScannerRightClickClientE2ETest.java index 4b7762dd7..4e7566221 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/OreScannerRightClickClientE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/OreScannerRightClickClientE2ETest.java @@ -3,38 +3,27 @@ import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; import org.junit.Test; -import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; /** * Coverage-audit gap (Tier 3 #12, client slice) — {@code ItemOreScanner} - * right-click smoke. + * right-click, driven through the REAL client item-use path + * ({@code ClientBot.useItem} → {@code CPacketPlayerTryUseItem}) with the + * outcome read from the CLIENT screen state. * - *

      Pin: {@code onItemRightClick} doesn't crash regardless of the - * stored satellite-ID resolving to a registered satellite. The - * production code path opens the OreMapping GUI when the stored - * satellite-ID resolves to a {@code SatelliteOreMapping} on the - * current dim. In headless harness, GUI-open is a no-op; what we - * actually verify is "right-click runs without throwing".

      - * - *

      Two test methods:

      + *

      Arrange uses the arrange-only {@code artest player equip-orescanner} + * probe (register satellite + seed NBT + equip — no click); the click itself + * is the client's.

      * *
        - *
      • Empty satellite-ID branch — held OreScanner has no NBT; - * {@code getSatelliteID} returns -1; {@code getSatellite(-1)} - * returns null; {@code instanceof SatelliteOreMapping} is false → - * early-out, no GUI, no crash.
      • + *
      • Empty satellite-ID branch — held OreScanner has no NBT → + * early-out: no GUI opens on the client, no crash.
      • *
      • Resolved satellite-ID branch — a registered - * SatelliteOreMapping on dim 0; held OreScanner NBT points at - * it; matches both class + dim guards → would open GUI in real - * client. Pin: no crash, no error reported.
      • + * SatelliteOreMapping on dim 0 → the OreMapping GUI must actually + * OPEN on the client. (The old probe-driven test only pinned + * "no crash" — it could not see whether the GUI opened.) *
      - * - *

      Why testClient: server-side probe-driven test would be enough - * for "no crash", but the GUI-open code path interacts with player - * state in ways that only manifest in the full client harness. Even - * if the harness skips actual rendering, the openGui packet path - * runs server-side.

      */ public class OreScannerRightClickClientE2ETest extends AbstractClientE2ETest { @@ -42,33 +31,60 @@ private String exec(String cmd) throws Exception { return String.join("\n", serverClient().execute(cmd)); } + /** Polls until the CLIENT renders {@code itemId} in the main hand (~10 s cap) + * — server-side equips need a sync round-trip before the click. */ + private void waitForHeld(String itemId) throws Exception { + String held = ""; + for (int waited = 0; waited < 200; waited += 5) { + bot().waitTicks(5); + held = bot().reportPlayerItems().getAsJsonObject("held").get("id").getAsString(); + if (itemId.equals(held)) return; + } + throw new AssertionError("client never rendered " + itemId + + " in hand; held=" + held); + } + @Test - public void rightClickWithEmptySatelliteIdDoesNotCrash() throws Exception { - String resp = exec("artest player try-orescanner-rclick none"); - assertTrue("ore-scanner right-click probe must succeed: " + resp, - resp.contains("\"ok\":true")); - assertTrue("empty-satellite branch must not error: " + resp, - resp.contains("\"error\":null")); - assertTrue("empty branch must report hadSatelliteId:false: " + resp, - resp.contains("\"hadSatelliteId\":false")); - // Player is still alive (didn't crash the server thread). - String state = exec("artest player held-air"); - assertFalse("held-air probe must succeed post-right-click (proves " - + "player state still intact): " + state, - state.contains("\"error\"")); + public void rightClickWithEmptySatelliteIdOpensNoGuiAndDoesNotCrash() throws Exception { + bot().waitForWorld(); + String equip = exec("artest player equip-orescanner none"); + assertTrue("equip-orescanner must succeed: " + equip, equip.contains("\"ok\":true")); + assertTrue("empty branch must report hadSatelliteId:false: " + equip, + equip.contains("\"hadSatelliteId\":false")); + waitForHeld("advancedrocketry:orescanner"); + + // The REAL right-click from the client. + bot().useItem(); + bot().waitTicks(20); + + // CLIENT truth: no GUI opened, client still alive and responsive. + assertEquals("empty-satellite right-click must not open any screen", + "", bot().reportState().get("screen").getAsString()); } @Test - public void rightClickWithRegisteredSatelliteIdResolvesWithoutError() throws Exception { - // Register a fresh SatelliteOreMapping on dim 0 (overworld — - // headless harness has a working DimensionProperties for it). - String resp = exec("artest player try-orescanner-rclick 0"); - assertTrue("ore-scanner right-click probe must succeed: " + resp, - resp.contains("\"ok\":true")); - assertTrue("registered-satellite branch must report hadSatelliteId:true: " - + resp, - resp.contains("\"hadSatelliteId\":true")); - assertTrue("registered-satellite branch must not error: " + resp, - resp.contains("\"error\":null")); + public void rightClickWithRegisteredSatelliteIdOpensOreMappingGui() throws Exception { + bot().waitForWorld(); + String equip = exec("artest player equip-orescanner 0"); + assertTrue("equip-orescanner must succeed: " + equip, equip.contains("\"ok\":true")); + assertTrue("resolved branch must report hadSatelliteId:true: " + equip, + equip.contains("\"hadSatelliteId\":true")); + waitForHeld("advancedrocketry:orescanner"); + + // The REAL right-click from the client. + bot().useItem(); + + // CLIENT truth: the OreMapping GUI actually opens on screen. + String screen = ""; + for (int waited = 0; waited < 100; waited += 10) { + bot().waitTicks(10); + screen = bot().reportState().get("screen").getAsString(); + if (!screen.isEmpty()) break; + } + assertTrue("right-click with a resolved SatelliteOreMapping must open the " + + "OreMapping GUI on the client; screen='" + screen + "'", + screen.contains("OreMapping")); + + bot().closeScreen(); } } From 8a0c9644006f4706de9d50c0a8e19015e7213361 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Thu, 11 Jun 2026 10:23:27 +0200 Subject: [PATCH 44/47] test: relabel 4 probe-driven 'client' tests to the server tier + fake-player infra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per honest-client-e2e.md (user-approved): AdvancementsE2ETest, LowGravFallDamageE2ETest, AtmospherePlayerEventE2ETest and VacuumGuardsE2ETest drove server-side handler contracts exclusively through server probes — the client harness only supplied a connected player. They move down the pyramid as AdvancementsTriggerTest, LowGravFallDamageTest, AtmospherePlayerEventTest, VacuumGuardsTest. Player supply on the headless tier: new artest verbs - player ensure-fake — persistent bare EntityPlayerMP (NOT a Forge FakePlayer: PlayerAdvancements.grantCriterion hard-refuses those), never spawned into a world (a connectionless player in the EntityTracker NPEs on send-to-self), invulnerable, cross-dim moves fire the same PlayerChangedDimensionEvent Forge's transfer fires; - player tick-living — posts one LivingUpdateEvent per server tick, reproducing a ticking player's cadence (%20 trigger windows included). Found and fixed along the way (production hardening, all NPE-on- connectionless-player crashes any mod-spawned FakePlayer could hit): - EntityEventHandler.onJoinWorld / onPlayerChangedDimension sendPacket on null connection (+ CCE-tightening to instanceof EntityPlayerMP); - PlanetWeatherManager.syncToPlayer same; - AtmosphereHandler.onTick: effect paths (potion sync, PacketOxygenState via new AtmosphereType.sendToRealPlayer) crash the server tick loop for connectionless players in non-breathable dims — effects now skip them, cache/sync bookkeeping still runs. Also documented-by-test: 'artest server wait' executes ON the server thread, so its sleep loop blocks ticking — the relocated tests wait off-thread in the test JVM instead (probe defect noted for follow-up). 12 methods green across the four classes. --- .../atmosphere/AtmosphereHandler.java | 12 +- .../AtmosphereHighPressureNoOxygen.java | 2 +- .../atmosphere/AtmosphereLowOxygen.java | 2 +- .../atmosphere/AtmosphereNoOxygen.java | 2 +- .../AtmosphereSuperHighPressure.java | 2 +- .../AtmosphereSuperHighPressureNoOxygen.java | 2 +- .../AtmosphereSuperheatedNoOxygen.java | 2 +- .../atmosphere/AtmosphereType.java | 13 + .../atmosphere/AtmosphereVacuum.java | 2 +- .../atmosphere/AtmosphereVeryHotNoOxygen.java | 2 +- .../command/test/TestProbeCommand.java | 121 +++++++- .../event/EntityEventHandler.java | 15 +- .../world/weather/PlanetWeatherManager.java | 2 + .../test/client/AdvancementsE2ETest.java | 233 ---------------- .../client/AtmospherePlayerEventE2ETest.java | 258 ------------------ .../test/client/LowGravFallDamageE2ETest.java | 202 -------------- .../test/client/VacuumGuardsE2ETest.java | 215 --------------- .../test/server/AdvancementsTriggerTest.java | 150 ++++++++++ .../server/AtmospherePlayerEventTest.java | 150 ++++++++++ .../test/server/LowGravFallDamageTest.java | 130 +++++++++ .../test/server/VacuumGuardsTest.java | 141 ++++++++++ 21 files changed, 737 insertions(+), 921 deletions(-) delete mode 100644 src/test/java/zmaster587/advancedRocketry/test/client/AdvancementsE2ETest.java delete mode 100644 src/test/java/zmaster587/advancedRocketry/test/client/AtmospherePlayerEventE2ETest.java delete mode 100644 src/test/java/zmaster587/advancedRocketry/test/client/LowGravFallDamageE2ETest.java delete mode 100644 src/test/java/zmaster587/advancedRocketry/test/client/VacuumGuardsE2ETest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/AdvancementsTriggerTest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/AtmospherePlayerEventTest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/LowGravFallDamageTest.java create mode 100644 src/test/java/zmaster587/advancedRocketry/test/server/VacuumGuardsTest.java diff --git a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereHandler.java b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereHandler.java index 7d72ae260..7d4d65434 100644 --- a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereHandler.java +++ b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereHandler.java @@ -239,10 +239,20 @@ public void onTick(LivingUpdateEvent event) { IAtmosphere atmosType = getAtmosphereType(entity); if (entity instanceof EntityPlayer && atmosType != prevAtmosphere.get(entity)) { - PacketHandler.sendToPlayer(new PacketAtmSync(atmosType.getUnlocalizedName(), getAtmospherePressure(entity)), (EntityPlayer) entity); + AtmosphereType.sendToRealPlayer(new PacketAtmSync(atmosType.getUnlocalizedName(), getAtmospherePressure(entity)), (EntityPlayer) entity); prevAtmosphere.put((EntityPlayer) entity, atmosType); } + // Connectionless player-shaped entities (FakePlayers, headless + // test players) can't receive the packets the effect paths send + // (potion sync, oxygen state) — vanilla NPEs in connection.sendPacket + // and takes the server tick loop down. They still get the cache/ + // sync bookkeeping above; only the effects are skipped. + if (entity instanceof net.minecraft.entity.player.EntityPlayerMP + && ((net.minecraft.entity.player.EntityPlayerMP) entity).connection == null) { + return; + } + if (atmosType.canTick() && !(event.getEntityLiving().isInLava() || event.getEntityLiving().isInsideOfMaterial(Material.WATER))) { AtmosphereEvent event2 = new AtmosphereEvent.AtmosphereTickEvent(entity, atmosType); diff --git a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereHighPressureNoOxygen.java b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereHighPressureNoOxygen.java index 1fb91a72f..80f7acfba 100644 --- a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereHighPressureNoOxygen.java +++ b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereHighPressureNoOxygen.java @@ -39,7 +39,7 @@ public void onTick(EntityLivingBase player) { player.addPotionEffect(new PotionEffect(Potion.getPotionById(9), 400, 2)); } if (player instanceof EntityPlayer) - PacketHandler.sendToPlayer(new PacketOxygenState(), (EntityPlayer) player); + AtmosphereType.sendToRealPlayer(new PacketOxygenState(), (EntityPlayer) player); } } } diff --git a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereLowOxygen.java b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereLowOxygen.java index 3c2fd43d3..5291773bf 100644 --- a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereLowOxygen.java +++ b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereLowOxygen.java @@ -27,7 +27,7 @@ public void onTick(EntityLivingBase player) { player.addPotionEffect(new PotionEffect(Potion.getPotionById(2), 40, 2)); player.addPotionEffect(new PotionEffect(Potion.getPotionById(4), 40, 2)); if (player instanceof EntityPlayer) - PacketHandler.sendToPlayer(new PacketOxygenState(), (EntityPlayer) player); + AtmosphereType.sendToRealPlayer(new PacketOxygenState(), (EntityPlayer) player); } } diff --git a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereNoOxygen.java b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereNoOxygen.java index c0609c16d..2a4771a4b 100644 --- a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereNoOxygen.java +++ b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereNoOxygen.java @@ -33,7 +33,7 @@ public void onTick(EntityLivingBase player) { player.addPotionEffect(new PotionEffect(Potion.getPotionById(9), 400, 1)); } if (player instanceof EntityPlayer) - PacketHandler.sendToPlayer(new PacketOxygenState(), (EntityPlayer) player); + AtmosphereType.sendToRealPlayer(new PacketOxygenState(), (EntityPlayer) player); } } diff --git a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereSuperHighPressure.java b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereSuperHighPressure.java index 58eb0c636..75a3f04d0 100644 --- a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereSuperHighPressure.java +++ b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereSuperHighPressure.java @@ -33,7 +33,7 @@ public void onTick(EntityLivingBase player) { player.addPotionEffect(new PotionEffect(Potion.getPotionById(4), 40, 3)); player.attackEntityFrom(AtmosphereHandler.oxygenToxicityDamage, 1); if (player instanceof EntityPlayer) - PacketHandler.sendToPlayer(new PacketOxygenState(), (EntityPlayer) player); + AtmosphereType.sendToRealPlayer(new PacketOxygenState(), (EntityPlayer) player); } } } diff --git a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereSuperHighPressureNoOxygen.java b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereSuperHighPressureNoOxygen.java index 2994d5c8a..ff1b7d497 100644 --- a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereSuperHighPressureNoOxygen.java +++ b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereSuperHighPressureNoOxygen.java @@ -41,7 +41,7 @@ public void onTick(EntityLivingBase player) { player.addPotionEffect(new PotionEffect(Potion.getPotionById(9), 400, 2)); } if (player instanceof EntityPlayer) - PacketHandler.sendToPlayer(new PacketOxygenState(), (EntityPlayer) player); + AtmosphereType.sendToRealPlayer(new PacketOxygenState(), (EntityPlayer) player); } } } diff --git a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereSuperheatedNoOxygen.java b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereSuperheatedNoOxygen.java index 97fc8f6a8..41b187c7f 100644 --- a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereSuperheatedNoOxygen.java +++ b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereSuperheatedNoOxygen.java @@ -42,7 +42,7 @@ public void onTick(EntityLivingBase player) { player.addPotionEffect(new PotionEffect(Potion.getPotionById(9), 400, 1)); } if (player instanceof EntityPlayer) - PacketHandler.sendToPlayer(new PacketOxygenState(), (EntityPlayer) player); + AtmosphereType.sendToRealPlayer(new PacketOxygenState(), (EntityPlayer) player); } } } diff --git a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereType.java b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereType.java index 2286c94d4..6ddf41134 100644 --- a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereType.java +++ b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereType.java @@ -8,6 +8,19 @@ public class AtmosphereType implements IAtmosphere { + /** Packet-safe send for atmosphere effects: FakePlayers / headless test + * players have no network connection — a raw sendToPlayer would NPE in + * the netty pipeline and crash the server tick loop. */ + public static void sendToRealPlayer(zmaster587.libVulpes.network.BasePacket packet, + net.minecraft.entity.player.EntityPlayer player) { + if (player instanceof net.minecraft.entity.player.EntityPlayerMP + && ((net.minecraft.entity.player.EntityPlayerMP) player).connection == null) { + return; + } + zmaster587.libVulpes.network.PacketHandler.sendToPlayer(packet, player); + } + + //We're probably not getting a polluted atmosphere type public static final AtmosphereType AIR = new AtmosphereType(false, true, "air"); public static final AtmosphereType PRESSURIZEDAIR = new AtmosphereType(false, true, true, "PressurizedAir"); diff --git a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereVacuum.java b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereVacuum.java index 4438c9745..728e21dc8 100644 --- a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereVacuum.java +++ b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereVacuum.java @@ -33,7 +33,7 @@ public void onTick(EntityLivingBase player) { player.addPotionEffect(new PotionEffect(Potion.getPotionById(9), 400, 1)); } if (player instanceof EntityPlayer) - PacketHandler.sendToPlayer(new PacketOxygenState(), (EntityPlayer) player); + AtmosphereType.sendToRealPlayer(new PacketOxygenState(), (EntityPlayer) player); } } diff --git a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereVeryHotNoOxygen.java b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereVeryHotNoOxygen.java index c0e45f170..d8baefc63 100644 --- a/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereVeryHotNoOxygen.java +++ b/src/main/java/zmaster587/advancedRocketry/atmosphere/AtmosphereVeryHotNoOxygen.java @@ -42,7 +42,7 @@ public void onTick(EntityLivingBase player) { player.addPotionEffect(new PotionEffect(Potion.getPotionById(9), 400, 1)); } if (player instanceof EntityPlayer) - PacketHandler.sendToPlayer(new PacketOxygenState(), (EntityPlayer) player); + AtmosphereType.sendToRealPlayer(new PacketOxygenState(), (EntityPlayer) player); } } } diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index eab775002..9b85f977f 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -3667,6 +3667,9 @@ private void handleAtmosphere(MinecraftServer server, ICommandSender sender, Str // (or null) for the first connected player. java.util.List ps = server.getPlayerList().getPlayers(); + if (ps.isEmpty() && fakePlayer != null) { + ps = java.util.Collections.singletonList(fakePlayer); + } if (ps.isEmpty()) { send(sender, "{\"error\":\"no players connected\"}"); return; @@ -10460,13 +10463,91 @@ private void handlePlayer(MinecraftServer server, ICommandSender sender, String[ return; } String sub = args[0].toLowerCase(java.util.Locale.ROOT); + if ("ensure-fake".equals(sub) && args.length >= 5) { + // /artest player ensure-fake + // + // Headless-server-tier player: creates (or moves) a persistent + // FakePlayer so player-shaped probes work without a connected + // client. Cross-dim moves fire PlayerChangedDimensionEvent — + // the same FML event Forge's transfer path fires last — so + // per-player dim-change handlers run their production path. + int dim = parseIntOr(args[1], Integer.MIN_VALUE); + double x = Double.parseDouble(args[2]); + double y = Double.parseDouble(args[3]); + double z = Double.parseDouble(args[4]); + net.minecraftforge.common.DimensionManager.keepDimensionLoaded(dim, true); + if (net.minecraftforge.common.DimensionManager.getWorld(dim) == null) { + net.minecraftforge.common.DimensionManager.initDimension(dim); + } + net.minecraft.world.WorldServer world = server.getWorld(dim); + if (world == null) { + send(sender, "{\"error\":\"world not loaded\",\"dim\":" + dim + "}"); + return; + } + // Deliberately NOT world.spawnEntity()'d: a connectionless + // EntityPlayerMP in the EntityTracker NPEs in + // EntityTrackerEntry.sendToTrackingAndSelf (it sends metadata to + // ITSELF through player.connection). The probes only need the + // player object to carry a world + position; per-tick events come + // from `tick-living` and the dim-change event is fired here. + int fromDim = Integer.MIN_VALUE; + if (fakePlayer == null) { + fakePlayer = new net.minecraft.entity.player.EntityPlayerMP(server, world, + new com.mojang.authlib.GameProfile( + java.util.UUID.nameUUIDFromBytes("ARTestFakePlayer".getBytes()), + "ARTestFakePlayer"), + new net.minecraft.server.management.PlayerInteractionManager(world)); + // Invulnerable like a FakePlayer: damage paths (vacuum + // suffocation etc.) end in connection.sendPacket → NPE on a + // connectionless player and crash the server tick loop. + fakePlayer.capabilities.disableDamage = true; + fakePlayer.setLocationAndAngles(x, y, z, 0, 0); + } else { + fromDim = fakePlayer.world.provider.getDimension(); + fakePlayer.setWorld(world); + fakePlayer.dimension = dim; + fakePlayer.setLocationAndAngles(x, y, z, 0, 0); + fakePlayer.setPosition(x, y, z); + if (fromDim != dim) { + net.minecraftforge.fml.common.FMLCommonHandler.instance() + .firePlayerChangedDimensionEvent(fakePlayer, fromDim, dim); + } + } + send(sender, "{\"ok\":true,\"dim\":" + dim + ",\"fromDim\":" + fromDim + + ",\"x\":" + x + ",\"y\":" + y + ",\"z\":" + z + "}"); + return; + } + if ("tick-living".equals(sub) && args.length >= 2) { + // /artest player tick-living + // + // The test player is never spawned into a world, so nothing ticks + // it and it never fires LivingUpdateEvent on its own. This verb posts ONE + // LivingUpdateEvent per server tick for the next ticks — + // the same event, on the same bus, at the same once-per-tick + // cadence a ticking player produces. Pair with `server wait`. + if (fakePlayer == null) { + send(sender, "{\"error\":\"no fake player — run ensure-fake first\"}"); + return; + } + int ticks = parseIntOr(args[1], 0); + if (!fakeTickerRegistered) { + net.minecraftforge.common.MinecraftForge.EVENT_BUS.register(new FakePlayerTicker()); + fakeTickerRegistered = true; + } + fakeLivingTicksRemaining = ticks; + send(sender, "{\"ok\":true,\"ticks\":" + ticks + "}"); + return; + } java.util.List players = server.getPlayerList().getPlayers(); - if (players.isEmpty()) { + if (players.isEmpty() && fakePlayer == null) { send(sender, "{\"error\":\"no players connected\"}"); return; } - net.minecraft.entity.player.EntityPlayerMP player = players.get(0); + // Headless tier: fall back to the persistent FakePlayer when no real + // client is connected (see ensure-fake above). + net.minecraft.entity.player.EntityPlayerMP player = + players.isEmpty() ? fakePlayer : players.get(0); if ("inv-bypass".equals(sub) && args.length >= 2) { String action = args[1].toLowerCase(java.util.Locale.ROOT); switch (action) { @@ -10679,6 +10760,17 @@ private void handlePlayer(MinecraftServer server, ICommandSender sender, String[ + ",\"canceled\":" + ev.isCanceled() + "}"); return; } + if ("advancement-trigger-direct".equals(sub)) { + // Debug verb: invoke WENT_TO_THE_MOON.trigger(player) directly and + // report listener wiring — separates the handler-gate path from + // the grant path when diagnosing fake-player advancement tests. + zmaster587.advancedRocketry.advancements.ARAdvancements.WENT_TO_THE_MOON.trigger(player); + net.minecraft.advancements.Advancement adv = server.getAdvancementManager() + .getAdvancement(new net.minecraft.util.ResourceLocation("advancedrocketry:normal/wenttothemoon")); + boolean done = adv != null && player.getAdvancements().getProgress(adv).isDone(); + send(sender, "{\"ok\":true,\"isDone\":" + done + "}"); + return; + } if ("advancement".equals(sub) && args.length >= 2) { // /artest player advancement // /artest player advancement reset @@ -13071,6 +13163,31 @@ private static boolean classResourcePresent(String slashed) { * "*EventDelta" fields in their responses for inline cause-effect * verification. */ + /** Headless-tier test player (see `/artest player ensure-fake`). + * A BARE EntityPlayerMP, deliberately NOT a Forge FakePlayer: + * PlayerAdvancements.grantCriterion hard-refuses FakePlayer instances + * (Forge policy), and advancement grants are part of what the server + * tier pins. It is never spawned into a world (a connectionless player + * in the EntityTracker NPEs), so the FakePlayer no-ops aren't needed. */ + private static net.minecraft.entity.player.EntityPlayerMP fakePlayer; + private static volatile int fakeLivingTicksRemaining = 0; + private static boolean fakeTickerRegistered = false; + + /** Posts one LivingUpdateEvent per server tick for the fake player while + * `tick-living` has remaining budget — the un-spawned test player never + * ticks, so this supplies the once-per-tick cadence a real player has. */ + public static final class FakePlayerTicker { + @net.minecraftforge.fml.common.eventhandler.SubscribeEvent + public void onServerTick(net.minecraftforge.fml.common.gameevent.TickEvent.ServerTickEvent event) { + if (event.phase != net.minecraftforge.fml.common.gameevent.TickEvent.Phase.END) return; + if (fakeLivingTicksRemaining > 0 && fakePlayer != null) { + fakeLivingTicksRemaining--; + net.minecraftforge.common.MinecraftForge.EVENT_BUS.post( + new net.minecraftforge.event.entity.living.LivingEvent.LivingUpdateEvent(fakePlayer)); + } + } + } + public static final class RocketEventRecorder { public static volatile int launchCount = 0; public static volatile int preLaunchCount = 0; diff --git a/src/main/java/zmaster587/advancedRocketry/event/EntityEventHandler.java b/src/main/java/zmaster587/advancedRocketry/event/EntityEventHandler.java index edf310199..48e37251d 100644 --- a/src/main/java/zmaster587/advancedRocketry/event/EntityEventHandler.java +++ b/src/main/java/zmaster587/advancedRocketry/event/EntityEventHandler.java @@ -12,8 +12,15 @@ public class EntityEventHandler { @SubscribeEvent public void onJoinWorld(EntityJoinWorldEvent event) { - if (event.getEntity() instanceof EntityPlayer && !event.getWorld().isRemote) { + if (event.getEntity() instanceof EntityPlayerMP && !event.getWorld().isRemote) { EntityPlayerMP player = (EntityPlayerMP) event.getEntity(); + // FakePlayers (turtles, block placers, test harnesses, …) join + // worlds with no network connection — sendPacket would NPE. + // Non-MP EntityPlayer impls would CCE on the cast above, hence + // the tightened instanceof as well. + if (player.connection == null) { + return; + } World world = event.getWorld(); // if (world.isRaining()) { // player.connection.sendPacket(new SPacketChangeGameState(2, )); @@ -32,8 +39,12 @@ public void onJoinWorld(EntityJoinWorldEvent event) { @SubscribeEvent public void onPlayerChangedDimension(PlayerEvent.PlayerChangedDimensionEvent event) { - if (!event.player.world.isRemote) { + if (event.player instanceof EntityPlayerMP && !event.player.world.isRemote) { EntityPlayerMP player = (EntityPlayerMP) event.player; + // FakePlayers have no network connection — sendPacket would NPE. + if (player.connection == null) { + return; + } World world = player.world; // if (world.isRaining()) { // player.connection.sendPacket(new SPacketChangeGameState(2, )); diff --git a/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java b/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java index 5e696e42e..23cceccf3 100644 --- a/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java +++ b/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java @@ -292,6 +292,8 @@ public NBTTagCompound writeToNBT(NBTTagCompound compound) { */ public static void syncToPlayer(EntityPlayerMP player) { if (player == null || player.world == null || player.world.isRemote) return; + // FakePlayers have no network connection — sendPacket would NPE. + if (player.connection == null) return; if (!(player.world instanceof WorldServer)) return; WorldServer ws = (WorldServer) player.world; WorldInfo info = ws.getWorldInfo(); diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/AdvancementsE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/AdvancementsE2ETest.java deleted file mode 100644 index b9532b914..000000000 --- a/src/test/java/zmaster587/advancedRocketry/test/client/AdvancementsE2ETest.java +++ /dev/null @@ -1,233 +0,0 @@ -package zmaster587.advancedRocketry.test.client; - -import com.github.stannismod.forge.testing.client.RealClientHarness; -import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; -import com.github.stannismod.forge.testing.junit.AbstractHeadlessServerTest; -import com.github.stannismod.forge.testing.server.RealDedicatedServerHarness; -import com.google.gson.JsonObject; -import org.junit.After; -import org.junit.Assume; -import org.junit.Before; -import org.junit.FixMethodOrder; -import org.junit.Test; -import org.junit.runners.MethodSorters; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -/** - * TASK-10b Phase 3 — advancement triggers fired by player-event gameplay. - * - *

      Pins the - * {@link zmaster587.advancedRocketry.event.PlanetEventHandler} - * passive trigger at lines 203-208: when a player ticks in a dim - * named {@code "Luna"} within distanceSq < 512 of (2347, 80, 67), - * the {@code WENT_TO_THE_MOON} custom-trigger fires every 20 server - * ticks.

      - * - *

      Two gates are exercised:

      - *
        - *
      • Name gate — the dim's - * {@link zmaster587.advancedRocketry.dimension.DimensionProperties#getName()} - * must equal {@code "Luna"}. {@link #cNonLunaArDimDoesNotFireWentToTheMoon} - * pins the negative.
      • - *
      • Distance gate — player must stand within ~22 blocks of - * the lander coords. {@link #dFarFromLanderCoordsOnLunaDoesNotFire} - * pins the negative.
      • - *
      - * - *

      The {@code MOON_LANDING} trigger is intentionally NOT pinned here - * — it fires only from {@link zmaster587.advancedRocketry.entity.EntityRocket}'s - * deorbit branch (with a human passenger), which is the rocket - * flight-cycle suite's domain (TASK-07), not player-event handler's.

      - */ -@FixMethodOrder(MethodSorters.NAME_ASCENDING) -public class AdvancementsE2ETest { - - private static final int DIM_LUNA = 9501; - private static final int DIM_OTHER = 9502; - - private static final String ADV_WENT = "advancedrocketry:normal/wenttothemoon"; - - private static final Pattern IS_DONE = Pattern.compile("\"isDone\":(true|false)"); - - private Path workDir; - private RealDedicatedServerHarness serverHarness; - private RealClientHarness clientHarness; - - @Before - public void startBoth() throws Exception { - Assume.assumeTrue("Server harness disabled", - Boolean.parseBoolean(System.getProperty( - AbstractHeadlessServerTest.PROP_HARNESS_ENABLED, "false"))); - Assume.assumeTrue("Client harness disabled", - Boolean.parseBoolean(System.getProperty( - AbstractClientE2ETest.PROP_CLIENT_ENABLED, "false"))); - - workDir = Files.createTempDirectory("forge-client-adv-pin-"); - Path arConfigDir = workDir.resolve("config").resolve("advRocketry"); - Files.createDirectories(arConfigDir); - // The PlanetEventHandler.WENT_TO_THE_MOON gate is keyed on the - // dim name string "Luna", NOT on ARConfiguration.MoonId. So we - // explicitly name one custom dim "Luna" and a second one - // "AlsoNotLuna" for the name-gate counter-test. - String xml = "\n" - + "\n" - + " \n" - + planetXml("Luna", DIM_LUNA, 0) - + planetXml("AlsoNotLuna", DIM_OTHER, 0) - + " \n" - + "\n"; - Files.write(arConfigDir.resolve("planetDefs.xml"), xml.getBytes(StandardCharsets.UTF_8)); - - serverHarness = RealDedicatedServerHarness.startWith(workDir, false); - try { - clientHarness = RealClientHarness.start(serverHarness); - } catch (Exception ex) { - try { serverHarness.close(); } catch (Exception cleanup) { ex.addSuppressed(cleanup); } - serverHarness = null; - throw ex; - } - } - - @After - public void stopBoth() throws Exception { - Exception deferred = null; - if (clientHarness != null) { - try { clientHarness.close(); } catch (Exception e) { deferred = e; } - clientHarness = null; - } - if (serverHarness != null) { - try { serverHarness.close(); } - catch (Exception e) { if (deferred == null) deferred = e; else deferred.addSuppressed(e); } - serverHarness = null; - } - if (deferred != null) throw deferred; - } - - private static String planetXml(String name, int dim, int atmosDensity) { - return " \n" - + " true\n" - + " 0.5,0.5,0.5\n" - + " 0.4,0.6,0.9\n" - + " 100\n" - + " 100\n" - + " 0\n" - + " 0\n" - + " false\n" - + " 250\n" - + " 24000\n" - + " " + atmosDensity + "\n" - + " false\n" - + " true\n" - + " false\n" - + " \n"; - } - - private String exec(String cmd) throws Exception { - return String.join("\n", serverHarness.client().execute(cmd)); - } - - private boolean isDone(String src) { - Matcher m = IS_DONE.matcher(src); - assertTrue("isDone field missing in: " + src, m.find()); - return "true".equals(m.group(1)); - } - - /** Block until the client reports the expected dim id or budget elapses. */ - private void waitForClientDim(int dim) throws Exception { - for (int i = 0; i < 200; i++) { - JsonObject w = clientHarness.bot().reportWeather(); - if (w != null && w.has("dim") && w.get("dim").getAsInt() == dim) return; - clientHarness.bot().waitTicks(2); - } - } - - /** Baseline: a freshly-spawned player in the overworld has the - * WENT_TO_THE_MOON advancement NOT done — guards against state - * bleed-through between test classes (each gets a fresh workdir - * but the assertion locks the precondition explicitly). */ - @Test - public void aBaselineWentToTheMoonNotDoneInOverworld() throws Exception { - clientHarness.bot().waitForWorld(); - String resp = exec("artest player advancement " + ADV_WENT); - assertEquals("baseline: WENT_TO_THE_MOON must not be granted yet; " + resp, - false, isDone(resp)); - } - - /** Pin: standing on a Luna-named AR dim within ~22 blocks of the - * lander coords (2347, 80, 67) causes - * {@code PlanetEventHandler.fallEvent} (the LivingUpdateEvent - * branch wrapped at lines 203-208) to call - * {@code WENT_TO_THE_MOON.trigger(player)} within one - * {@code worldTime % 20 == 0} window. */ - @Test - public void bStandingNearLanderOnLunaFiresWentToTheMoon() throws Exception { - clientHarness.bot().waitForWorld(); - - exec("artest tp " + DIM_LUNA); - waitForClientDim(DIM_LUNA); - // Move to a safe y above the magic spot but still well within - // distanceSq < 512 (sqrt(512) ≈ 22.6). Δy=15 → distSq=225 — clear - // of any moon terrain block at y=80 and inside the gate. - exec("tp @a 2347 95 67"); - // The trigger gate runs only when worldTime % 20 == 0. Wait - // 40 ticks ≥ 2 cycles to make hitting the gate effectively - // certain, plus a small buffer for the trigger criterion to - // propagate through AdvancementManager. - clientHarness.bot().waitTicks(50); - - String resp = exec("artest player advancement " + ADV_WENT); - assertEquals("standing near (2347,80,67) on Luna must grant " - + "WENT_TO_THE_MOON within 1-2 trigger cycles; " + resp, - true, isDone(resp)); - } - - /** Counter-test: AR dim that is NOT named "Luna" never fires the - * trigger regardless of player coords — pins the name-gate at - * line 204 ({@code getName().equals("Luna")}). */ - @Test - public void cNonLunaArDimDoesNotFireWentToTheMoon() throws Exception { - clientHarness.bot().waitForWorld(); - - exec("artest tp " + DIM_OTHER); - waitForClientDim(DIM_OTHER); - // Same coords as the positive test — only the dim name differs, - // so any failure here is a name-gate regression (the trigger - // started firing for non-moon AR dims). - exec("tp @a 2347 95 67"); - clientHarness.bot().waitTicks(50); - - String resp = exec("artest player advancement " + ADV_WENT); - assertEquals("non-Luna AR dim must NOT fire WENT_TO_THE_MOON " - + "even at the magic coords; " + resp, - false, isDone(resp)); - } - - /** Counter-test: standing on Luna but OUTSIDE the distance gate - * (distanceSq ≥ 512) doesn't fire — pins the distance gate at - * line 205. */ - @Test - public void dFarFromLanderCoordsOnLunaDoesNotFire() throws Exception { - clientHarness.bot().waitForWorld(); - - exec("artest tp " + DIM_LUNA); - waitForClientDim(DIM_LUNA); - // 100 blocks in z from (2347, 80, 67) → distSq=10000 > 512 ✗ - exec("tp @a 2347 95 167"); - clientHarness.bot().waitTicks(50); - - String resp = exec("artest player advancement " + ADV_WENT); - assertEquals("standing far from lander coords on Luna must NOT " - + "grant WENT_TO_THE_MOON; " + resp, - false, isDone(resp)); - } -} diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/AtmospherePlayerEventE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/AtmospherePlayerEventE2ETest.java deleted file mode 100644 index cced8ed40..000000000 --- a/src/test/java/zmaster587/advancedRocketry/test/client/AtmospherePlayerEventE2ETest.java +++ /dev/null @@ -1,258 +0,0 @@ -package zmaster587.advancedRocketry.test.client; - -import com.github.stannismod.forge.testing.client.RealClientHarness; -import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; -import com.github.stannismod.forge.testing.junit.AbstractHeadlessServerTest; -import com.github.stannismod.forge.testing.server.RealDedicatedServerHarness; -import com.google.gson.JsonObject; -import org.junit.After; -import org.junit.Assume; -import org.junit.Before; -import org.junit.FixMethodOrder; -import org.junit.Test; -import org.junit.runners.MethodSorters; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * TASK-10b Phase 1 — atmosphere player-event behaviour pins (AR-side). - * - *

      Scope

      - * - *

      Pins production - * {@link zmaster587.advancedRocketry.atmosphere.AtmosphereHandler} - * hooks that touch the EntityPlayerMP lifecycle directly. The - * damage application itself lives in libVulpes (a binary - * dependency — {@code ItemAirWrapper.protectsFromSubstance} drains - * the suit's O2 buffer and applies fall-back damage when empty), - * so the tests here intentionally do NOT exercise that path. Instead - * they pin the AR-owned bookkeeping that surrounds the libVulpes - * call: cross-dim cache invalidation, per-dim atmosphere selection, - * and the {@code PacketAtmSync} that pushes the dim's atmosphere - * type to the client.

      - * - *

      Stages two AR planets via XML: a vacuum dim ({@link #DIM_VAC}, - * atmosphereDensity=0) and a breathable dim ({@link #DIM_AIR}, - * atmosphereDensity=100). Drives a real client through {@code /artest - * tp} between them and asserts the production - * {@link zmaster587.advancedRocketry.atmosphere.AtmosphereHandler#onPlayerChangeDim} - * and per-dim {@code prevAtmosphere} bookkeeping behave correctly.

      - * - *

      Pinned behaviours

      - * - *
        - *
      • {@link #aArDimWithoutVisitDoesNotCacheAtmosphereForPlayer} — - * baseline: cache is empty until the player ticks in an AR - * dim with an {@link zmaster587.advancedRocketry.atmosphere.AtmosphereHandler} - * registered.
      • - *
      • {@link #bArDimTickPopulatesPerPlayerCache} — - * after a player ticks in an AR dim, the per-player - * {@code prevAtmosphere} entry is populated with that dim's - * atmosphere name (the {@code != prevAtmosphere.get(entity)} - * branch fired and stored).
      • - *
      • {@link #cDimChangeClearsAtmosphereCacheForPlayer} — - * {@code onPlayerChangeDim} drops the cache so the new dim's - * atmosphere takes effect on the next onTick (not via stale - * cache lag).
      • - *
      - * - *

      Follows the manual server+client harness pattern from - * {@link WeatherClientSyncE2ETest} (extending {@code AbstractClientE2ETest} - * forces an empty workdir with no AR planet XML, which we need - * controlled here).

      - */ -@FixMethodOrder(MethodSorters.NAME_ASCENDING) -public class AtmospherePlayerEventE2ETest { - - private static final int DIM_VAC = 9401; - private static final int DIM_AIR = 9402; - - private static final Pattern CACHED_ATMOS = - Pattern.compile("\"cachedAtmosphere\":\"([^\"]*)\""); - private static final Pattern HAS_CACHED = - Pattern.compile("\"hasCachedAtmosphere\":(true|false)"); - - private Path workDir; - private RealDedicatedServerHarness serverHarness; - private RealClientHarness clientHarness; - - @Before - public void startBoth() throws Exception { - Assume.assumeTrue("Server harness disabled", - Boolean.parseBoolean(System.getProperty( - AbstractHeadlessServerTest.PROP_HARNESS_ENABLED, "false"))); - Assume.assumeTrue("Client harness disabled", - Boolean.parseBoolean(System.getProperty( - AbstractClientE2ETest.PROP_CLIENT_ENABLED, "false"))); - - workDir = Files.createTempDirectory("forge-client-atmos-pin-"); - Path arConfigDir = workDir.resolve("config").resolve("advRocketry"); - Files.createDirectories(arConfigDir); - String xml = "\n" - + "\n" - + " \n" - + planetXml("VacuumPlanet", DIM_VAC, 0) - + planetXml("AirPlanet", DIM_AIR, 100) - + " \n" - + "\n"; - Files.write(arConfigDir.resolve("planetDefs.xml"), xml.getBytes(StandardCharsets.UTF_8)); - - serverHarness = RealDedicatedServerHarness.startWith(workDir, false); - try { - clientHarness = RealClientHarness.start(serverHarness); - } catch (Exception ex) { - try { serverHarness.close(); } catch (Exception cleanup) { ex.addSuppressed(cleanup); } - serverHarness = null; - throw ex; - } - } - - @After - public void stopBoth() throws Exception { - Exception deferred = null; - if (clientHarness != null) { - try { clientHarness.close(); } catch (Exception e) { deferred = e; } - clientHarness = null; - } - if (serverHarness != null) { - try { serverHarness.close(); } - catch (Exception e) { if (deferred == null) deferred = e; else deferred.addSuppressed(e); } - serverHarness = null; - } - if (deferred != null) throw deferred; - } - - private static String planetXml(String name, int dim, int atmosDensity) { - return " \n" - + " true\n" - + " 0.5,0.5,0.5\n" - + " 0.4,0.6,0.9\n" - + " 100\n" - + " 100\n" - + " 0\n" - + " 0\n" - + " false\n" - + " 250\n" - + " 24000\n" - + " " + atmosDensity + "\n" - + " false\n" - + " true\n" - + " false\n" - + " \n"; - } - - private String exec(String cmd) throws Exception { - return String.join("\n", serverHarness.client().execute(cmd)); - } - - private String stringField(Pattern p, String src) { - Matcher m = p.matcher(src); - return m.find() ? m.group(1) : ""; - } - - /** Block until the client reports the expected dim id or budget elapses. */ - private void waitForClientDim(int dim) throws Exception { - for (int i = 0; i < 200; i++) { - JsonObject w = clientHarness.bot().reportWeather(); - if (w != null && w.has("dim") && w.get("dim").getAsInt() == dim) return; - clientHarness.bot().waitTicks(2); - } - } - - /** - * Baseline: with the player in the overworld (no AR atmosphere - * subscription fires for overworld in default config), the - * per-player cache must be empty. Guards against a regression - * where AtmosphereHandler.onTick spuriously fires for vanilla - * dims and pollutes the map. - */ - @Test - public void aArDimWithoutVisitDoesNotCacheAtmosphereForPlayer() throws Exception { - clientHarness.bot().waitForWorld(); - // The bot starts in the overworld (dim 0). - String cache = exec("artest atmosphere cached-for-player"); - String has = stringField(HAS_CACHED, cache); - // Either no cache entry OR an entry that's empty/blank — both - // acceptable; what we're ruling out is "vacuum atmosphere - // somehow cached for player while still in overworld". - String atmos = stringField(CACHED_ATMOS, cache); - assertTrue("overworld baseline: cache must be empty or non-AR; " - + "hasCached=" + has + " atmos=" + atmos + " " + cache, - "false".equals(has) || atmos.isEmpty() || !atmos.contains("vacuum")); - } - - /** - * Pin: after the player ticks in an AR dim, the AtmosphereHandler - * for that dim populates the per-player cache with the dim's - * atmosphere name. Exercises the - * {@code atmosType != prevAtmosphere.get(entity)} branch in - * {@code AtmosphereHandler.onTick} (line 217) — i.e. proves the - * subscription fired AND the put() happened. - */ - @Test - public void bArDimTickPopulatesPerPlayerCache() throws Exception { - clientHarness.bot().waitForWorld(); - - exec("artest tp " + DIM_VAC); - waitForClientDim(DIM_VAC); - // 40 ticks easily covers the first onTick dispatch for the - // newly arrived player (LivingUpdateEvent fires every tick). - clientHarness.bot().waitTicks(40); - - String cache = exec("artest atmosphere cached-for-player"); - String has = stringField(HAS_CACHED, cache); - String atmos = stringField(CACHED_ATMOS, cache); - assertEquals("after >=1 tick in an AR dim the per-player cache " - + "MUST be populated (AtmosphereHandler.onTick must have " - + "fired for the EntityPlayerMP); cache=" + cache, - "true", has); - assertFalse("cached atmosphere name must be non-empty: " + cache, - atmos.isEmpty()); - } - - /** - * Pin: changing dims clears the per-player cache via - * {@code AtmosphereHandler.onPlayerChangeDim}; the new dim's - * AtmosphereHandler then repopulates with its own atmosphere. - * The two dims here have opposite atmosphereDensity (0 vacuum vs - * 100 breathable) so the post-teleport cache name MUST differ - * from the pre-teleport one. - */ - @Test - public void cDimChangeClearsAtmosphereCacheForPlayer() throws Exception { - clientHarness.bot().waitForWorld(); - - exec("artest tp " + DIM_VAC); - waitForClientDim(DIM_VAC); - clientHarness.bot().waitTicks(40); - - String cacheVac = exec("artest atmosphere cached-for-player"); - String atmoVac = stringField(CACHED_ATMOS, cacheVac); - assertFalse("vacuum-dim cache must populate before the second tp: " - + cacheVac, atmoVac.isEmpty()); - - exec("artest tp " + DIM_AIR); - waitForClientDim(DIM_AIR); - clientHarness.bot().waitTicks(40); - - String cacheAir = exec("artest atmosphere cached-for-player"); - String atmoAir = stringField(CACHED_ATMOS, cacheAir); - assertFalse("breathable-dim cache must repopulate after dim change: " - + cacheAir, atmoAir.isEmpty()); - assertFalse("the vacuum-dim atmosphere name must NOT carry over " - + "into the breathable dim's cache slot (onPlayerChangeDim " - + "must have cleared the per-player entry); vacuumAtmos=" - + atmoVac + " breathableAtmos=" + atmoAir, - atmoVac.equals(atmoAir)); - } -} diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/LowGravFallDamageE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/LowGravFallDamageE2ETest.java deleted file mode 100644 index 5e3d4e625..000000000 --- a/src/test/java/zmaster587/advancedRocketry/test/client/LowGravFallDamageE2ETest.java +++ /dev/null @@ -1,202 +0,0 @@ -package zmaster587.advancedRocketry.test.client; - -import com.github.stannismod.forge.testing.client.RealClientHarness; -import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; -import com.github.stannismod.forge.testing.junit.AbstractHeadlessServerTest; -import com.github.stannismod.forge.testing.server.RealDedicatedServerHarness; -import com.google.gson.JsonObject; -import org.junit.After; -import org.junit.Assume; -import org.junit.Before; -import org.junit.FixMethodOrder; -import org.junit.Test; -import org.junit.runners.MethodSorters; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -/** - * TASK-10b Phase 5 — gravity-scaled fall damage pin. - * - *

      Production: - * {@link zmaster587.advancedRocketry.event.PlanetEventHandler#fallEvent} - * (lines 611-618). On any - * {@link zmaster587.advancedRocketry.api.IPlanetaryProvider} dim the - * handler scales {@code LivingFallEvent.getDistance()} by the planet's - * gravitational multiplier — so a 20-block fall on a Luna-like - * 0.166-grav dim resolves as a ~3.32-block fall (no damage past the - * vanilla 3-block exempt window). Overworld is not an - * IPlanetaryProvider, so the handler skips it entirely and the - * distance is unchanged.

      - * - *

      Drives the handler through {@code /artest player try-fall} — - * posts a synthetic LivingFallEvent at the player's position and - * reports the post-handler distance plus the dim's gravity multiplier - * (for cross-check).

      - */ -@FixMethodOrder(MethodSorters.NAME_ASCENDING) -public class LowGravFallDamageE2ETest { - - private static final int DIM_LOW_GRAV = 9701; - - private static final Pattern RESULT_DIST = - Pattern.compile("\"resultDistance\":(-?[0-9.eE+-]+)"); - private static final Pattern INPUT_DIST = - Pattern.compile("\"inputDistance\":(-?[0-9.eE+-]+)"); - private static final Pattern GRAVITY = - Pattern.compile("\"gravityMultiplier\":(-?[0-9.eE+-]+)"); - private static final Pattern IS_PLANETARY = - Pattern.compile("\"isPlanetaryProvider\":(true|false)"); - - private Path workDir; - private RealDedicatedServerHarness serverHarness; - private RealClientHarness clientHarness; - - @Before - public void startBoth() throws Exception { - Assume.assumeTrue("Server harness disabled", - Boolean.parseBoolean(System.getProperty( - AbstractHeadlessServerTest.PROP_HARNESS_ENABLED, "false"))); - Assume.assumeTrue("Client harness disabled", - Boolean.parseBoolean(System.getProperty( - AbstractClientE2ETest.PROP_CLIENT_ENABLED, "false"))); - - workDir = Files.createTempDirectory("forge-client-fall-grav-"); - Path arConfigDir = workDir.resolve("config").resolve("advRocketry"); - Files.createDirectories(arConfigDir); - // gravitationalMultiplier in planetDefs.xml is an integer - // percentage: 17 ≈ 0.17, i.e. Luna-like. - String xml = "\n" - + "\n" - + " \n" - + " \n" - + " true\n" - + " 0.5,0.5,0.5\n" - + " 0.4,0.6,0.9\n" - + " 17\n" - + " 100\n" - + " 0\n" - + " 0\n" - + " false\n" - + " 250\n" - + " 24000\n" - + " 100\n" - + " false\n" - + " true\n" - + " false\n" - + " \n" - + " \n" - + "\n"; - Files.write(arConfigDir.resolve("planetDefs.xml"), xml.getBytes(StandardCharsets.UTF_8)); - - serverHarness = RealDedicatedServerHarness.startWith(workDir, false); - try { - clientHarness = RealClientHarness.start(serverHarness); - } catch (Exception ex) { - try { serverHarness.close(); } catch (Exception cleanup) { ex.addSuppressed(cleanup); } - serverHarness = null; - throw ex; - } - } - - @After - public void stopBoth() throws Exception { - Exception deferred = null; - if (clientHarness != null) { - try { clientHarness.close(); } catch (Exception e) { deferred = e; } - clientHarness = null; - } - if (serverHarness != null) { - try { serverHarness.close(); } - catch (Exception e) { if (deferred == null) deferred = e; else deferred.addSuppressed(e); } - serverHarness = null; - } - if (deferred != null) throw deferred; - } - - private String exec(String cmd) throws Exception { - return String.join("\n", serverHarness.client().execute(cmd)); - } - - private double doubleField(Pattern p, String src, String name) { - Matcher m = p.matcher(src); - assertTrue("field " + name + " missing in: " + src, m.find()); - return Double.parseDouble(m.group(1)); - } - - private String stringField(Pattern p, String src, String name) { - Matcher m = p.matcher(src); - assertTrue("field " + name + " missing in: " + src, m.find()); - return m.group(1); - } - - private void waitForClientDim(int dim) throws Exception { - for (int i = 0; i < 200; i++) { - JsonObject w = clientHarness.bot().reportWeather(); - if (w != null && w.has("dim") && w.get("dim").getAsInt() == dim) return; - clientHarness.bot().waitTicks(2); - } - } - - /** Counter-test: vanilla overworld is NOT an IPlanetaryProvider, so - * PlanetEventHandler.fallEvent skips the scaling branch entirely — - * the post-handler distance equals the input. */ - @Test - public void aOverworldDoesNotScaleFallDistance() throws Exception { - clientHarness.bot().waitForWorld(); - String resp = exec("artest player try-fall 20"); - // Sanity: overworld provider is not an IPlanetaryProvider. - assertEquals("overworld must NOT be an IPlanetaryProvider; " + resp, - "false", stringField(IS_PLANETARY, resp, "isPlanetaryProvider")); - double input = doubleField(INPUT_DIST, resp, "inputDistance"); - double result = doubleField(RESULT_DIST, resp, "resultDistance"); - assertEquals("overworld fall distance must be unchanged by AR " - + "handler; input=" + input + " result=" + result + " " + resp, - input, result, 0.001); - } - - /** Pin: on a low-grav AR dim the handler scales LivingFallEvent.distance - * by the provider's gravitational multiplier. With grav=0.17 and a - * 20-block input fall, expected post-handler distance ≈ 3.4. */ - @Test - public void bLowGravDimScalesFallDistanceByGravityMultiplier() throws Exception { - clientHarness.bot().waitForWorld(); - exec("artest tp " + DIM_LOW_GRAV); - waitForClientDim(DIM_LOW_GRAV); - // Let the dim settle so the WorldProvider is fully initialised - // before posting the synthetic event. - clientHarness.bot().waitTicks(20); - - String resp = exec("artest player try-fall 20"); - assertEquals("low-grav AR dim must report as IPlanetaryProvider; " + resp, - "true", stringField(IS_PLANETARY, resp, "isPlanetaryProvider")); - double input = doubleField(INPUT_DIST, resp, "inputDistance"); - double result = doubleField(RESULT_DIST, resp, "resultDistance"); - double gravity = doubleField(GRAVITY, resp, "gravityMultiplier"); - // Cross-check the configured multiplier — planetDefs.xml had - // 17 which AR - // normalises to 0.17. Tolerate ±0.02 for any rounding inside - // DimensionProperties. - assertEquals("gravity multiplier must be ~0.17; " + resp, - 0.17, gravity, 0.02); - // Pin the scaling: result = input * gravity, within a small - // floating-point epsilon. - assertEquals("low-grav AR dim must scale fall distance by gravity; " - + "input=" + input + " gravity=" + gravity - + " expected=" + (input * gravity) + " result=" + result - + " " + resp, - input * gravity, result, 0.05); - // Sanity: result MUST be strictly less than input. - assertTrue("scaled distance must be strictly less than input on a " - + "low-grav dim; input=" + input + " result=" + result, - result < input); - } -} diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/VacuumGuardsE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/VacuumGuardsE2ETest.java deleted file mode 100644 index 6781f928f..000000000 --- a/src/test/java/zmaster587/advancedRocketry/test/client/VacuumGuardsE2ETest.java +++ /dev/null @@ -1,215 +0,0 @@ -package zmaster587.advancedRocketry.test.client; - -import com.github.stannismod.forge.testing.client.RealClientHarness; -import com.github.stannismod.forge.testing.junit.AbstractClientE2ETest; -import com.github.stannismod.forge.testing.junit.AbstractHeadlessServerTest; -import com.github.stannismod.forge.testing.server.RealDedicatedServerHarness; -import com.google.gson.JsonObject; -import org.junit.After; -import org.junit.Assume; -import org.junit.Before; -import org.junit.FixMethodOrder; -import org.junit.Test; -import org.junit.runners.MethodSorters; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertTrue; - -/** - * TASK-10b Phase 4 — sleep and flint-and-steel guards in vacuum dims. - * - *

      Pins two production handlers in - * {@link zmaster587.advancedRocketry.event.PlanetEventHandler}:

      - * - *
        - *
      • {@code sleepEvent} (lines 237-249) — a vacuum - * (non-breathable) AR dim must refuse sleep via - * {@code event.setResult(SleepResult.OTHER_PROBLEM)}.
      • - *
      • {@code blockRightClicked} (lines 281-294) — a vacuum - * (no-combustion) AR dim must cancel right-clicks holding - * flint+steel / fire-charge / blaze-powder / blaze-rod.
      • - *
      - * - *

      Both guards fire only when the dim has an {@code AtmosphereHandler} - * registered AND the atmosphere is non-breathable / no-combustion, so - * the breathable AR-dim counter-tests prove the gate is atmosphere-typed - * (not just "always cancel on AR dim").

      - * - *

      Drives the guards through {@code /artest player try-sleep} and - * {@code /artest player try-ignite}: synthetic event posts that exercise - * the AR handler in isolation, sidestepping the vanilla bed-right-click - * pre-checks (night-time, hostile-mobs nearby) and the flint+steel block - * mutation. The pin is on the AR handler's decision, not on the - * downstream vanilla bookkeeping.

      - */ -@FixMethodOrder(MethodSorters.NAME_ASCENDING) -public class VacuumGuardsE2ETest { - - private static final int DIM_VAC = 9601; - private static final int DIM_AIR = 9602; - - private static final Pattern SLEEP_RESULT = - Pattern.compile("\"resultStatus\":\"([^\"]*)\""); - private static final Pattern CANCELED = - Pattern.compile("\"canceled\":(true|false)"); - - private Path workDir; - private RealDedicatedServerHarness serverHarness; - private RealClientHarness clientHarness; - - @Before - public void startBoth() throws Exception { - Assume.assumeTrue("Server harness disabled", - Boolean.parseBoolean(System.getProperty( - AbstractHeadlessServerTest.PROP_HARNESS_ENABLED, "false"))); - Assume.assumeTrue("Client harness disabled", - Boolean.parseBoolean(System.getProperty( - AbstractClientE2ETest.PROP_CLIENT_ENABLED, "false"))); - - workDir = Files.createTempDirectory("forge-client-vac-guards-"); - Path arConfigDir = workDir.resolve("config").resolve("advRocketry"); - Files.createDirectories(arConfigDir); - String xml = "\n" - + "\n" - + " \n" - + planetXml("VacuumPlanet", DIM_VAC, 0) - + planetXml("AirPlanet", DIM_AIR, 100) - + " \n" - + "\n"; - Files.write(arConfigDir.resolve("planetDefs.xml"), xml.getBytes(StandardCharsets.UTF_8)); - - serverHarness = RealDedicatedServerHarness.startWith(workDir, false); - try { - clientHarness = RealClientHarness.start(serverHarness); - } catch (Exception ex) { - try { serverHarness.close(); } catch (Exception cleanup) { ex.addSuppressed(cleanup); } - serverHarness = null; - throw ex; - } - } - - @After - public void stopBoth() throws Exception { - Exception deferred = null; - if (clientHarness != null) { - try { clientHarness.close(); } catch (Exception e) { deferred = e; } - clientHarness = null; - } - if (serverHarness != null) { - try { serverHarness.close(); } - catch (Exception e) { if (deferred == null) deferred = e; else deferred.addSuppressed(e); } - serverHarness = null; - } - if (deferred != null) throw deferred; - } - - private static String planetXml(String name, int dim, int atmosDensity) { - return " \n" - + " true\n" - + " 0.5,0.5,0.5\n" - + " 0.4,0.6,0.9\n" - + " 100\n" - + " 100\n" - + " 0\n" - + " 0\n" - + " false\n" - + " 250\n" - + " 24000\n" - + " " + atmosDensity + "\n" - + " false\n" - + " true\n" - + " false\n" - + " \n"; - } - - private String exec(String cmd) throws Exception { - return String.join("\n", serverHarness.client().execute(cmd)); - } - - private String stringField(Pattern p, String src, String name) { - Matcher m = p.matcher(src); - assertTrue("field " + name + " missing in: " + src, m.find()); - return m.group(1); - } - - private void waitForClientDim(int dim) throws Exception { - for (int i = 0; i < 200; i++) { - JsonObject w = clientHarness.bot().reportWeather(); - if (w != null && w.has("dim") && w.get("dim").getAsInt() == dim) return; - clientHarness.bot().waitTicks(2); - } - } - - /** Ensures the dim's AtmosphereHandler is installed and the player's - * per-tick atmosphere refresh has run, so the handler-side guards - * see a fully-initialised atmosphere when they query it. */ - private void enterDim(int dim) throws Exception { - exec("artest tp " + dim); - waitForClientDim(dim); - clientHarness.bot().waitTicks(40); - } - - /** Pin: posting PlayerSleepInBedEvent at a vacuum-dim coordinate - * goes through PlanetEventHandler.sleepEvent and emerges with - * {@code resultStatus == OTHER_PROBLEM}. */ - @Test - public void aSleepInVacuumDimIsRefused() throws Exception { - enterDim(DIM_VAC); - String resp = exec("artest player try-sleep"); - String status = stringField(SLEEP_RESULT, resp, "resultStatus"); - assertEquals("sleep in vacuum dim must be refused with OTHER_PROBLEM; " - + resp, "OTHER_PROBLEM", status); - } - - /** Counter-test: a breathable AR dim must NOT refuse with - * OTHER_PROBLEM — the vacuum gate must depend on - * isBreathable(), not on \"is AR dim\". */ - @Test - public void bSleepInBreathableArDimNotRefusedByVacuumGate() throws Exception { - enterDim(DIM_AIR); - String resp = exec("artest player try-sleep"); - String status = stringField(SLEEP_RESULT, resp, "resultStatus"); - // Vanilla EntityPlayer.SleepResult has OK, NOT_POSSIBLE_HERE, - // NOT_POSSIBLE_NOW, TOO_FAR_AWAY, OTHER_PROBLEM, NOT_SAFE. - // The AR handler ONLY sets OTHER_PROBLEM in vacuum; in a - // breathable dim it leaves the result alone (null when no - // other handler ran). Any value EXCEPT OTHER_PROBLEM proves - // the AR guard didn't fire. - assertNotEquals("breathable AR dim must NOT be refused by the " - + "vacuum-sleep gate; resultStatus=" + status + " " + resp, - "OTHER_PROBLEM", status); - } - - /** Pin: posting RightClickBlock with flint+steel in a vacuum dim - * emerges canceled. */ - @Test - public void cFlintInVacuumDimDoesNotIgnite() throws Exception { - enterDim(DIM_VAC); - String resp = exec("artest player try-ignite"); - String canceled = stringField(CANCELED, resp, "canceled"); - assertEquals("flint-and-steel right-click in vacuum dim must be " - + "canceled by PlanetEventHandler.blockRightClicked; " + resp, - "true", canceled); - } - - /** Counter-test: same right-click in a breathable AR dim must NOT - * be canceled by the no-combustion gate. */ - @Test - public void dFlintInBreathableArDimDoesIgnite() throws Exception { - enterDim(DIM_AIR); - String resp = exec("artest player try-ignite"); - String canceled = stringField(CANCELED, resp, "canceled"); - assertEquals("flint-and-steel right-click in breathable AR dim " - + "must NOT be canceled (combustion allowed); " + resp, - "false", canceled); - } -} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/AdvancementsTriggerTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/AdvancementsTriggerTest.java new file mode 100644 index 000000000..0d2b24ed9 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/AdvancementsTriggerTest.java @@ -0,0 +1,150 @@ +package zmaster587.advancedRocketry.test.server; + +import com.github.stannismod.forge.testing.junit.AbstractHeadlessServerTest; +import com.github.stannismod.forge.testing.server.RealDedicatedServerHarness; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * {@code PlanetEventHandler.playerTick} WENT_TO_THE_MOON trigger — server + * tier. Relabeled down the pyramid from the old client-harness + * {@code AdvancementsE2ETest} per honest-client-e2e.md: the contract + * (name gate "Luna", distanceSq < 512 of (2347,80,67), %20-tick window, + * advancement grant) is entirely server-side; the old client test drove it + * exclusively through server probes anyway. + * + *

      Player supply: {@code artest player ensure-fake} stations a persistent + * FakePlayer in the target dim; {@code artest player tick-living} posts one + * {@code LivingUpdateEvent} per server tick (Forge's FakePlayer no-ops + * {@code onUpdate}), reproducing a ticking player's cadence so the + * {@code worldTime % 20 == 0} gate is crossed naturally.

      + */ +public class AdvancementsTriggerTest { + + private static final int DIM_LUNA = 9511; + private static final int DIM_OTHER = 9512; + private static final String ADV_WENT = "advancedrocketry:normal/wenttothemoon"; + private static final Pattern IS_DONE = Pattern.compile("\"isDone\":(true|false)"); + + private Path workDir; + private RealDedicatedServerHarness harness; + + @Before + public void startServer() throws Exception { + Assume.assumeTrue("Server harness disabled", + Boolean.parseBoolean(System.getProperty( + AbstractHeadlessServerTest.PROP_HARNESS_ENABLED, "false"))); + workDir = Files.createTempDirectory("forge-server-advancements-"); + Path arConfigDir = workDir.resolve("config").resolve("advRocketry"); + Files.createDirectories(arConfigDir); + String xml = "\n" + + "\n" + + " \n" + + planetXml("Luna", DIM_LUNA) + + planetXml("AlsoNotLuna", DIM_OTHER) + + " \n" + + "\n"; + Files.write(arConfigDir.resolve("planetDefs.xml"), xml.getBytes(StandardCharsets.UTF_8)); + harness = RealDedicatedServerHarness.startWith(workDir, /*cleanupOnClose=*/true); + } + + private static String planetXml(String name, int dim) { + return " \n" + + " true\n" + + " 0.5,0.5,0.5\n" + + " 0.4,0.6,0.9\n" + + " 100\n" + + " 100\n" + + " 0\n" + + " 0\n" + + " false\n" + + " 250\n" + + " 24000\n" + + " 0\n" + + " false\n" + + " true\n" + + " false\n" + + " \n"; + } + + @After + public void stopServer() throws Exception { + if (harness != null) harness.close(); + } + + private String exec(String cmd) throws Exception { + return String.join("\n", harness.client().execute(cmd)); + } + + /** Stations the fake player and runs {@code ticks} living-updates worth of + * real server ticks. A forceload ticket keeps the otherwise-empty planet + * dim loaded AND TICKING — without it AR's per-tick unload flickers the + * world and its clock never crosses the %20 trigger window. */ + private void stationAndTick(int dim, double x, double y, double z, int ticks) throws Exception { + String fake = exec("artest player ensure-fake " + dim + " " + x + " " + y + " " + z); + assertTrue("ensure-fake must succeed: " + fake, fake.contains("\"ok\":true")); + exec("artest chunk forceload " + dim + " " + (((int) x) >> 4) + " " + (((int) z) >> 4)); + assertTrue("tick-living must succeed", + exec("artest player tick-living " + ticks).contains("\"ok\":true")); + // Wait OFF the server thread: `artest server wait` runs inside a + // console command, i.e. ON the server thread — its sleep loop blocks + // ticking entirely. Sleeping in the test JVM lets the server + // free-run the requested ticks. + Thread.sleep(ticks * 50L + 500L); + } + + private boolean isDone(String src) { + Matcher m = IS_DONE.matcher(src); + assertTrue("isDone field missing in: " + src, m.find()); + return Boolean.parseBoolean(m.group(1)); + } + + /** Standing on Luna within the distance gate grants WENT_TO_THE_MOON + * within 1–2 %20-tick trigger windows. Baseline asserted first. */ + @Test + public void standingNearLanderOnLunaFiresWentToTheMoon() throws Exception { + stationAndTick(DIM_LUNA, 2347, 95, 67, 0 + 1); // station only, 1 tick + assertEquals("baseline: WENT_TO_THE_MOON must not be granted yet", + false, isDone(exec("artest player advancement " + ADV_WENT))); + + // Δy=15 from (2347,80,67) → distSq=225 < 512 ✓. 60 ticks ≥ 3 windows. + assertTrue(exec("artest player tick-living 60").contains("\"ok\":true")); + // Poll off-thread — the server free-runs while the test JVM sleeps. + boolean done = false; + for (int waited = 0; waited < 15_000 && !done; waited += 1000) { + Thread.sleep(1000L); + done = isDone(exec("artest player advancement " + ADV_WENT)); + } + assertEquals("standing near (2347,80,67) on Luna must grant WENT_TO_THE_MOON", + true, done); + } + + /** Name gate: an AR dim NOT named "Luna" never fires, same coords. */ + @Test + public void nonLunaArDimDoesNotFireWentToTheMoon() throws Exception { + stationAndTick(DIM_OTHER, 2347, 95, 67, 50); + assertEquals("non-Luna AR dim must NOT fire WENT_TO_THE_MOON at the magic coords", + false, isDone(exec("artest player advancement " + ADV_WENT))); + } + + /** Distance gate: Luna but distSq ≥ 512 (100 blocks off in z) never fires. */ + @Test + public void farFromLanderCoordsOnLunaDoesNotFire() throws Exception { + stationAndTick(DIM_LUNA, 2347, 95, 167, 50); + assertEquals("far from lander coords on Luna must NOT grant WENT_TO_THE_MOON", + false, isDone(exec("artest player advancement " + ADV_WENT))); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/AtmospherePlayerEventTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/AtmospherePlayerEventTest.java new file mode 100644 index 000000000..38a4a7ac2 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/AtmospherePlayerEventTest.java @@ -0,0 +1,150 @@ +package zmaster587.advancedRocketry.test.server; + +import com.github.stannismod.forge.testing.junit.AbstractHeadlessServerTest; +import com.github.stannismod.forge.testing.server.RealDedicatedServerHarness; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * {@code AtmosphereHandler} per-player cache bookkeeping — server tier. + * Relabeled down the pyramid from the old client-harness + * {@code AtmospherePlayerEventE2ETest} per honest-client-e2e.md: the + * contract (onTick populates {@code prevAtmosphere} for players in AR dims; + * {@code onPlayerChangeDim} clears the entry so the next dim repopulates) + * is server-side handler state the old test read through server probes + * anyway. + * + *

      Player supply: {@code ensure-fake} (cross-dim moves fire the same + * {@code PlayerChangedDimensionEvent} Forge's transfer fires); + * {@code tick-living} supplies the per-tick {@code LivingUpdateEvent} + * cadence {@code AtmosphereHandler.onTick} subscribes to.

      + */ +public class AtmospherePlayerEventTest { + + private static final int DIM_VAC = 9411; + private static final int DIM_AIR = 9412; + + private static final Pattern HAS_CACHED = Pattern.compile("\"hasCachedAtmosphere\":(true|false)"); + private static final Pattern CACHED_ATMOS = Pattern.compile("\"cachedAtmosphere\":\"([^\"]*)\""); + + private Path workDir; + private RealDedicatedServerHarness harness; + + @Before + public void startServer() throws Exception { + Assume.assumeTrue("Server harness disabled", + Boolean.parseBoolean(System.getProperty( + AbstractHeadlessServerTest.PROP_HARNESS_ENABLED, "false"))); + workDir = Files.createTempDirectory("forge-server-atm-player-"); + Path arConfigDir = workDir.resolve("config").resolve("advRocketry"); + Files.createDirectories(arConfigDir); + String xml = "\n" + + "\n" + + " \n" + + planetXml("VacuumPlanet", DIM_VAC, 0) + + planetXml("AirPlanet", DIM_AIR, 100) + + " \n" + + "\n"; + Files.write(arConfigDir.resolve("planetDefs.xml"), xml.getBytes(StandardCharsets.UTF_8)); + harness = RealDedicatedServerHarness.startWith(workDir, /*cleanupOnClose=*/true); + } + + private static String planetXml(String name, int dim, int atmosDensity) { + return " \n" + + " true\n" + + " 0.5,0.5,0.5\n" + + " 0.4,0.6,0.9\n" + + " 100\n" + + " 100\n" + + " 0\n" + + " 0\n" + + " false\n" + + " 250\n" + + " 24000\n" + + " " + atmosDensity + "\n" + + " false\n" + + " true\n" + + " false\n" + + " \n"; + } + + @After + public void stopServer() throws Exception { + if (harness != null) harness.close(); + } + + private String exec(String cmd) throws Exception { + return String.join("\n", harness.client().execute(cmd)); + } + + /** Stations the fake player in {@code dim} and ticks it {@code ticks} times. */ + private void enterDimAndTick(int dim, int ticks) throws Exception { + String fake = exec("artest player ensure-fake " + dim + " 8.5 120 8.5"); + assertTrue("ensure-fake must succeed: " + fake, fake.contains("\"ok\":true")); + assertTrue(exec("artest player tick-living " + ticks).contains("\"ok\":true")); + // Off-thread wait — the server free-runs the ticks meanwhile. + Thread.sleep(ticks * 50L + 500L); + } + + private String field(Pattern p, String src) { + Matcher m = p.matcher(src); + assertTrue("field " + p.pattern() + " missing in: " + src, m.find()); + return m.group(1); + } + + /** Overworld baseline: no AR atmosphere may be cached for the player. */ + @Test + public void arDimWithoutVisitDoesNotCacheAtmosphereForPlayer() throws Exception { + enterDimAndTick(0, 10); + String cache = exec("artest atmosphere cached-for-player"); + String has = field(HAS_CACHED, cache); + String atmos = field(CACHED_ATMOS, cache); + assertTrue("overworld baseline: cache must be empty or non-AR; hasCached=" + has + + " atmos=" + atmos + " " + cache, + "false".equals(has) || atmos.isEmpty() || !atmos.contains("vacuum")); + } + + /** Ticking in an AR dim populates the per-player cache. */ + @Test + public void arDimTickPopulatesPerPlayerCache() throws Exception { + enterDimAndTick(DIM_VAC, 40); + String cache = exec("artest atmosphere cached-for-player"); + assertEquals("after >=1 living-update in an AR dim the per-player cache " + + "MUST be populated; cache=" + cache, "true", field(HAS_CACHED, cache)); + assertFalse("cached atmosphere name must be non-empty: " + cache, + field(CACHED_ATMOS, cache).isEmpty()); + } + + /** Dim change clears the entry; the new dim repopulates with its own. */ + @Test + public void dimChangeClearsAtmosphereCacheForPlayer() throws Exception { + enterDimAndTick(DIM_VAC, 40); + String cacheVac = exec("artest atmosphere cached-for-player"); + String atmoVac = field(CACHED_ATMOS, cacheVac); + assertFalse("vacuum-dim cache must populate before the dim change: " + cacheVac, + atmoVac.isEmpty()); + + enterDimAndTick(DIM_AIR, 40); + String cacheAir = exec("artest atmosphere cached-for-player"); + String atmoAir = field(CACHED_ATMOS, cacheAir); + assertFalse("breathable-dim cache must repopulate after dim change: " + cacheAir, + atmoAir.isEmpty()); + assertFalse("the vacuum-dim atmosphere must NOT carry over into the breathable " + + "dim's cache slot (onPlayerChangeDim must clear); vacuumAtmos=" + atmoVac + + " breathableAtmos=" + atmoAir, atmoVac.equals(atmoAir)); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/LowGravFallDamageTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/LowGravFallDamageTest.java new file mode 100644 index 000000000..331a0dcaf --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/LowGravFallDamageTest.java @@ -0,0 +1,130 @@ +package zmaster587.advancedRocketry.test.server; + +import com.github.stannismod.forge.testing.junit.AbstractHeadlessServerTest; +import com.github.stannismod.forge.testing.server.RealDedicatedServerHarness; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * {@code PlanetEventHandler.fallEvent} fall-distance scaling — server tier. + * Relabeled down the pyramid from the old client-harness + * {@code LowGravFallDamageE2ETest} per honest-client-e2e.md: the contract + * (LivingFallEvent.distance × gravity multiplier on IPlanetaryProvider dims, + * untouched elsewhere) is server-authoritative event-handler logic, and the + * old client test drove it exclusively through the {@code try-fall} probe + * anyway. Player supply: {@code ensure-fake}. + */ +public class LowGravFallDamageTest { + + private static final int DIM_LOW_GRAV = 9701; + private static final Pattern IS_PLANETARY = Pattern.compile("\"isPlanetaryProvider\":(true|false)"); + private static final Pattern INPUT_DIST = Pattern.compile("\"inputDistance\":(-?\\d+(?:\\.\\d+)?)"); + private static final Pattern RESULT_DIST = Pattern.compile("\"resultDistance\":(-?\\d+(?:\\.\\d+)?)"); + private static final Pattern GRAVITY = Pattern.compile("\"gravityMultiplier\":(-?\\d+(?:\\.\\d+)?)"); + + private Path workDir; + private RealDedicatedServerHarness harness; + + @Before + public void startServer() throws Exception { + Assume.assumeTrue("Server harness disabled", + Boolean.parseBoolean(System.getProperty( + AbstractHeadlessServerTest.PROP_HARNESS_ENABLED, "false"))); + workDir = Files.createTempDirectory("forge-server-lowgrav-fall-"); + Path arConfigDir = workDir.resolve("config").resolve("advRocketry"); + Files.createDirectories(arConfigDir); + String xml = "\n" + + "\n" + + " \n" + + " \n" + + " true\n" + + " 0.5,0.5,0.5\n" + + " 0.4,0.6,0.9\n" + + " 17\n" + + " 100\n" + + " 0\n" + + " 0\n" + + " false\n" + + " 250\n" + + " 24000\n" + + " 100\n" + + " false\n" + + " true\n" + + " false\n" + + " \n" + + " \n" + + "\n"; + Files.write(arConfigDir.resolve("planetDefs.xml"), xml.getBytes(StandardCharsets.UTF_8)); + harness = RealDedicatedServerHarness.startWith(workDir, /*cleanupOnClose=*/true); + } + + @After + public void stopServer() throws Exception { + if (harness != null) harness.close(); + } + + private String exec(String cmd) throws Exception { + return String.join("\n", harness.client().execute(cmd)); + } + + private void stationFake(int dim) throws Exception { + String fake = exec("artest player ensure-fake " + dim + " 8.5 120 8.5"); + assertTrue("ensure-fake must succeed: " + fake, fake.contains("\"ok\":true")); + // Off-thread settle (see AdvancementsTriggerTest: `artest server wait` + // blocks the server thread and must not be used to advance ticks). + Thread.sleep(1000L); + } + + /** Overworld: not an IPlanetaryProvider → distance untouched. */ + @Test + public void overworldDoesNotScaleFallDistance() throws Exception { + stationFake(0); + String resp = exec("artest player try-fall 20"); + assertEquals("overworld must NOT be an IPlanetaryProvider; " + resp, + false, boolField(IS_PLANETARY, resp)); + assertEquals("overworld fall distance must be unchanged by the AR handler; " + resp, + doubleField(INPUT_DIST, resp), doubleField(RESULT_DIST, resp), 0.001); + } + + /** Low-grav AR dim: distance × multiplier (17 → 0.17). */ + @Test + public void lowGravDimScalesFallDistanceByGravityMultiplier() throws Exception { + stationFake(DIM_LOW_GRAV); + String resp = exec("artest player try-fall 20"); + assertEquals("low-grav AR dim must report as IPlanetaryProvider; " + resp, + true, boolField(IS_PLANETARY, resp)); + double input = doubleField(INPUT_DIST, resp); + double result = doubleField(RESULT_DIST, resp); + double gravity = doubleField(GRAVITY, resp); + assertEquals("gravity multiplier must be ~0.17; " + resp, 0.17, gravity, 0.02); + assertEquals("low-grav AR dim must scale fall distance by gravity; " + resp, + input * gravity, result, 0.05); + assertTrue("scaled distance must be strictly less than input; input=" + input + + " result=" + result, result < input); + } + + private static boolean boolField(Pattern p, String src) { + Matcher m = p.matcher(src); + assertTrue("field " + p.pattern() + " missing in: " + src, m.find()); + return Boolean.parseBoolean(m.group(1)); + } + + private static double doubleField(Pattern p, String src) { + Matcher m = p.matcher(src); + assertTrue("field " + p.pattern() + " missing in: " + src, m.find()); + return Double.parseDouble(m.group(1)); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/VacuumGuardsTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/VacuumGuardsTest.java new file mode 100644 index 000000000..89bfddc26 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/VacuumGuardsTest.java @@ -0,0 +1,141 @@ +package zmaster587.advancedRocketry.test.server; + +import com.github.stannismod.forge.testing.junit.AbstractHeadlessServerTest; +import com.github.stannismod.forge.testing.server.RealDedicatedServerHarness; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +/** + * TASK-10b Phase 4 — sleep and flint-and-steel guards in vacuum dims — + * server tier. Relabeled down the pyramid from the old client-harness + * {@code VacuumGuardsE2ETest} per honest-client-e2e.md: the old test's own + * javadoc said its probes were "synthetic event posts … sidestepping the + * vanilla bed-right-click pre-checks" — that's a server-side handler + * contract, and synthetic event posts ARE the honest stimulus at this tier. + * Player supply: {@code ensure-fake}. + */ +public class VacuumGuardsTest { + + private static final int DIM_VAC = 9611; + private static final int DIM_AIR = 9612; + + private static final Pattern SLEEP_RESULT = Pattern.compile("\"resultStatus\":\"([^\"]*)\""); + private static final Pattern CANCELED = Pattern.compile("\"canceled\":(true|false)"); + + private Path workDir; + private RealDedicatedServerHarness harness; + + @Before + public void startServer() throws Exception { + Assume.assumeTrue("Server harness disabled", + Boolean.parseBoolean(System.getProperty( + AbstractHeadlessServerTest.PROP_HARNESS_ENABLED, "false"))); + workDir = Files.createTempDirectory("forge-server-vacuum-guards-"); + Path arConfigDir = workDir.resolve("config").resolve("advRocketry"); + Files.createDirectories(arConfigDir); + String xml = "\n" + + "\n" + + " \n" + + planetXml("VacuumPlanet", DIM_VAC, 0) + + planetXml("AirPlanet", DIM_AIR, 100) + + " \n" + + "\n"; + Files.write(arConfigDir.resolve("planetDefs.xml"), xml.getBytes(StandardCharsets.UTF_8)); + harness = RealDedicatedServerHarness.startWith(workDir, /*cleanupOnClose=*/true); + } + + private static String planetXml(String name, int dim, int atmosDensity) { + return " \n" + + " true\n" + + " 0.5,0.5,0.5\n" + + " 0.4,0.6,0.9\n" + + " 100\n" + + " 100\n" + + " 0\n" + + " 0\n" + + " false\n" + + " 250\n" + + " 24000\n" + + " " + atmosDensity + "\n" + + " false\n" + + " true\n" + + " false\n" + + " \n"; + } + + @After + public void stopServer() throws Exception { + if (harness != null) harness.close(); + } + + private String exec(String cmd) throws Exception { + return String.join("\n", harness.client().execute(cmd)); + } + + private String stringField(Pattern p, String src, String name) { + Matcher m = p.matcher(src); + assertTrue("field " + name + " missing in: " + src, m.find()); + return m.group(1); + } + + /** Stations the fake player in the dim and lets the dim's + * AtmosphereHandler settle so the guards query a live atmosphere. */ + private void enterDim(int dim) throws Exception { + String fake = exec("artest player ensure-fake " + dim + " 8.5 120 8.5"); + assertTrue("ensure-fake must succeed: " + fake, fake.contains("\"ok\":true")); + exec("artest player tick-living 40"); + // Off-thread wait — the server free-runs the ticks meanwhile. + Thread.sleep(2500L); + } + + /** Sleep in a vacuum dim is refused with OTHER_PROBLEM. */ + @Test + public void sleepInVacuumDimIsRefused() throws Exception { + enterDim(DIM_VAC); + String resp = exec("artest player try-sleep"); + assertEquals("sleep in vacuum dim must be refused with OTHER_PROBLEM; " + resp, + "OTHER_PROBLEM", stringField(SLEEP_RESULT, resp, "resultStatus")); + } + + /** The vacuum gate keys on isBreathable(), not "is AR dim". */ + @Test + public void sleepInBreathableArDimNotRefusedByVacuumGate() throws Exception { + enterDim(DIM_AIR); + String resp = exec("artest player try-sleep"); + assertNotEquals("breathable AR dim must NOT be refused by the vacuum-sleep gate; " + + resp, "OTHER_PROBLEM", stringField(SLEEP_RESULT, resp, "resultStatus")); + } + + /** Flint-and-steel right-click in a vacuum dim is canceled. */ + @Test + public void flintInVacuumDimDoesNotIgnite() throws Exception { + enterDim(DIM_VAC); + String resp = exec("artest player try-ignite"); + assertEquals("flint right-click in vacuum dim must be canceled by " + + "PlanetEventHandler.blockRightClicked; " + resp, + "true", stringField(CANCELED, resp, "canceled")); + } + + /** Counter-test: breathable AR dim does not cancel ignition. */ + @Test + public void flintInBreathableArDimDoesIgnite() throws Exception { + enterDim(DIM_AIR); + String resp = exec("artest player try-ignite"); + assertEquals("flint right-click in a breathable AR dim must NOT be canceled; " + + resp, "false", stringField(CANCELED, resp, "canceled")); + } +} From 3e5eb3bcda177174f9aae433aee5b0af8a0f9572 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Thu, 11 Jun 2026 10:33:06 +0200 Subject: [PATCH 45/47] =?UTF-8?q?test:=20finish=20the=20PARTIAL=20client?= =?UTF-8?q?=20e2e=20=E2=80=94=20player-layer=20completion=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RocketBuilderGuiE2ETest: after Scan→Build the CLIENT world must render the assembled EntityRocket (report_entities) — the spawn reaching the player's screen, not just the server registry. - ItemSpaceArmorUseFluidE2ETest / ItemSpaceChestSubInventoryDrainE2ETest / GasChargePadFillsPressureTankE2ETest: the CLIENT-rendered chest-slot NBT (report_player_items armor[2], 'air:'/'Amount:') now asserts the drain/hold/refill the suit HUD draws from, with the server probes kept as cross-side oracles. 8 methods green. --- .../GasChargePadFillsPressureTankE2ETest.java | 18 ++++++++++++++++++ .../client/ItemSpaceArmorUseFluidE2ETest.java | 19 +++++++++++++++++++ ...temSpaceChestSubInventoryDrainE2ETest.java | 17 +++++++++++++++++ .../test/client/RocketBuilderGuiE2ETest.java | 11 +++++++++++ 4 files changed, 65 insertions(+) diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/GasChargePadFillsPressureTankE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/GasChargePadFillsPressureTankE2ETest.java index 286c91cba..48ae55416 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/GasChargePadFillsPressureTankE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/GasChargePadFillsPressureTankE2ETest.java @@ -43,6 +43,19 @@ private String exec(String cmd) throws Exception { return String.join("\n", serverClient().execute(cmd)); } + /** CLIENT-rendered chest-slot air: parses the synced armor[2] NBT string + * ("air:" for the suit buffer, "Amount:" for the fluid tank) — the + * state the HUD/inventory screen draw from. Returns -1 if absent. */ + private int clientChestAir() throws Exception { + com.google.gson.JsonObject items = bot().reportPlayerItems(); + String nbt = items.getAsJsonArray("armor").get(2).getAsJsonObject().get("nbt").getAsString(); + java.util.regex.Matcher m = java.util.regex.Pattern.compile("\\bair:(\\d+)").matcher(nbt); + if (m.find()) return Integer.parseInt(m.group(1)); + m = java.util.regex.Pattern.compile("\\bAmount:(\\d+)").matcher(nbt); + if (m.find()) return Integer.parseInt(m.group(1)); + return -1; + } + private int readChestAir() throws Exception { String resp = exec("artest player held-air-component-route"); Matcher m = CHEST_AIR.matcher(resp); @@ -104,6 +117,11 @@ public void standingOnPoweredPadRefillsSuitAir() throws Exception { bot().waitTicks(100); int airAfter = readChestAir(); + // Player truth: the CLIENT-rendered tank state rose as well. + int clientAfter = clientChestAir(); + assertTrue("client-rendered chest tank must show the refill; client=" + + clientAfter + " serverBefore=" + airBefore, + clientAfter > airBefore); assertTrue("chest air must increase after standing on powered+" + "filled GasChargePad; before=" + airBefore + " after=" + airAfter, diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/ItemSpaceArmorUseFluidE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/ItemSpaceArmorUseFluidE2ETest.java index de9c214c2..d7e342183 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/ItemSpaceArmorUseFluidE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/ItemSpaceArmorUseFluidE2ETest.java @@ -69,6 +69,19 @@ private String exec(String cmd) throws Exception { return String.join("\n", serverClient().execute(cmd)); } + /** CLIENT-rendered chest-slot air: parses the synced armor[2] NBT string + * ("air:" for the suit buffer, "Amount:" for the fluid tank) — the + * state the HUD/inventory screen draw from. Returns -1 if absent. */ + private int clientChestAir() throws Exception { + com.google.gson.JsonObject items = bot().reportPlayerItems(); + String nbt = items.getAsJsonArray("armor").get(2).getAsJsonObject().get("nbt").getAsString(); + java.util.regex.Matcher m = java.util.regex.Pattern.compile("\\bair:(\\d+)").matcher(nbt); + if (m.find()) return Integer.parseInt(m.group(1)); + m = java.util.regex.Pattern.compile("\\bAmount:(\\d+)").matcher(nbt); + if (m.find()) return Integer.parseInt(m.group(1)); + return -1; + } + private int readChestAir() throws Exception { String resp = exec("artest player held-air"); Matcher m = CHEST_AIR.matcher(resp); @@ -138,6 +151,10 @@ public void suitedPlayerInVacuumLosesChestAirOverTime() throws Exception { assertTrue("chest air must decrease in vacuum with suit; " + "before=1000 after=" + chestAirAfter, chestAirAfter < 1000); + // Player truth: the CLIENT-rendered chest NBT shows the drain too. + int clientAir = clientChestAir(); + assertTrue("client-rendered chest air must reflect the drain; client=" + + clientAir, clientAir >= 0 && clientAir < 1000); // Health must hold — suit absorbed; if isImmune returned // false the vacuum-damage tick would have shaved hearts. double healthAfter = health(bot().reportState()); @@ -171,6 +188,8 @@ public void suitedPlayerInBreathableDimDoesNotLoseChestAir() throws Exception { bot().waitTicks(80); int chestAirAfter = readChestAir(); + assertEquals("client-rendered chest air must hold in breathable atmosphere", + 1000, clientChestAir()); assertEquals("chest air must be unchanged in breathable atmosphere; " + "before=1000 after=" + chestAirAfter, 1000, chestAirAfter); diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/ItemSpaceChestSubInventoryDrainE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/ItemSpaceChestSubInventoryDrainE2ETest.java index 4a630d6c1..e54c978e4 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/ItemSpaceChestSubInventoryDrainE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/ItemSpaceChestSubInventoryDrainE2ETest.java @@ -54,6 +54,19 @@ private String exec(String cmd) throws Exception { return String.join("\n", serverClient().execute(cmd)); } + /** CLIENT-rendered chest-slot air: parses the synced armor[2] NBT string + * ("air:" for the suit buffer, "Amount:" for the fluid tank) — the + * state the HUD/inventory screen draw from. Returns -1 if absent. */ + private int clientChestAir() throws Exception { + com.google.gson.JsonObject items = bot().reportPlayerItems(); + String nbt = items.getAsJsonArray("armor").get(2).getAsJsonObject().get("nbt").getAsString(); + java.util.regex.Matcher m = java.util.regex.Pattern.compile("\\bair:(\\d+)").matcher(nbt); + if (m.find()) return Integer.parseInt(m.group(1)); + m = java.util.regex.Pattern.compile("\\bAmount:(\\d+)").matcher(nbt); + if (m.find()) return Integer.parseInt(m.group(1)); + return -1; + } + private int readChestAir() throws Exception { // For ItemSpaceChest (capability route), use the component-aware // probe — the static "air" NBT route used by /artest player @@ -126,7 +139,10 @@ public void vacuumDrainsOxygenFromChestSubInventoryTank() throws Exception { // decrement the pressure-tank FluidStack by 1. bot().waitTicks(80); + int clientAirAfter = clientChestAir(); int chestAirAfter = readChestAir(); + assertTrue("client-rendered chest state must reflect the drain; client=" + + clientAirAfter, clientAirAfter < 1000); assertTrue("chest air must decrease through the CHEST sub-inventory " + "route in vacuum; before=1000 after=" + chestAirAfter, chestAirAfter < 1000); @@ -154,6 +170,7 @@ public void breathableAtmosphereDoesNotDrainChestTank() throws Exception { assertTrue("equip-space-chest must succeed: " + equip, equip.contains("\"ok\":true")); assertEquals("baseline chestAir", 1000, readChestAir()); + assertEquals("client-rendered baseline must agree", 1000, clientChestAir()); bot().waitTicks(80); diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/RocketBuilderGuiE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/RocketBuilderGuiE2ETest.java index 15fba2255..49131a13b 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/RocketBuilderGuiE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/RocketBuilderGuiE2ETest.java @@ -84,5 +84,16 @@ public void clickingScanThenBuildAssemblesRocket() throws Exception { } assertTrue("clicking Scan then Build did not assemble a rocket: " + rocketList, !rocketList.contains("\"rockets\":[]") && rocketList.contains("\"id\":")); + + // Player truth: the CLIENT world renders the assembled rocket entity — + // the spawn was synced to the player's screen, not just the registry. + int seen = -1; + for (int waited = 0; waited < 100; waited += 10) { + bot().waitTicks(10); + seen = bot().reportEntities("EntityRocket", 64).get("count").getAsInt(); + if (seen >= 1) break; + } + assertTrue("the client must see the assembled EntityRocket near the pad; count=" + + seen, seen >= 1); } } From 51ceb86a537ce07da847f06bba7f180d2f4110bc Mon Sep 17 00:00:00 2001 From: StannisMod Date: Thu, 11 Jun 2026 10:34:00 +0200 Subject: [PATCH 46/47] docs: 2026-06-11 honest-e2e delta audit + SOP refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New audits/2026-06-11-honest-e2e-delta.md: sweep verdicts (9 honest / 6 partial / 13 false-green / 1 mislabeled → all remediated), framework capabilities added, the four production FakePlayer-compat NPEs fixed, the open 'artest server wait' blocks-the-server-thread defect, and the accepted RailgunCargoTransit PARTIAL. - 2026-05-27 master audit: stale ARWeatherWorldInfoTest pin reference fixed to ARDimensionWorldInfoTest. - honest-client-e2e SOP: harness-extension section rewritten for the vendored testframework/ flow + current capability list; documented the server-wait trap. --- .../audits/2026-05-27-full-coverage-audit.md | 2 +- .agent/audits/2026-06-11-honest-e2e-delta.md | 69 +++++++++++++++++++ .agent/sops/development/honest-client-e2e.md | 21 ++++-- 3 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 .agent/audits/2026-06-11-honest-e2e-delta.md diff --git a/.agent/audits/2026-05-27-full-coverage-audit.md b/.agent/audits/2026-05-27-full-coverage-audit.md index 1a9fb4031..8310e1e6b 100644 --- a/.agent/audits/2026-05-27-full-coverage-audit.md +++ b/.agent/audits/2026-05-27-full-coverage-audit.md @@ -250,7 +250,7 @@ assembly + at least one recipe end-to-end + power-drain pin via | Per-dimension weather isolation | Deep (TASK-09 `PerDimensionWeatherIsolationTest`) | | Non-AR dimension exclusion | Deep (`NonARDimensionIsolationTest`) | | Weather persistence | Deep (`WeatherPersistenceTest`, `PlanetWeatherSavedDataTest`) | -| Weather sync to client | Deep (`WeatherClientSyncE2ETest`, `ARWeatherWorldInfoTest`) | +| Weather sync to client | Deep (`WeatherClientSyncE2ETest`, `ARDimensionWorldInfoTest`) | | Worldgen determinism (within-session) | Deep (`WorldgenDeterminismAndSamplingTest`) | | Worldgen cross-session reboot determinism | **Non-goal** (README §"Conscious non-goals") | | OreGen properties registry | Deep (`OreGenPropertiesTest`) | diff --git a/.agent/audits/2026-06-11-honest-e2e-delta.md b/.agent/audits/2026-06-11-honest-e2e-delta.md new file mode 100644 index 000000000..bc23ea91c --- /dev/null +++ b/.agent/audits/2026-06-11-honest-e2e-delta.md @@ -0,0 +1,69 @@ +# Honest-client-e2e delta — 2026-06-11 + +**Branch**: `fix/various` +**Parent audits**: [`2026-05-27-full-coverage-audit.md`](./2026-05-27-full-coverage-audit.md), +[`2026-05-29-coverage-delta.md`](./2026-05-29-coverage-delta.md) +**Trigger**: full sweep of the testClient tier against +[`sops/development/honest-client-e2e.md`](../sops/development/honest-client-e2e.md) +(the SOP postdates most of the tier — written 2026-06-03, tier mostly built in May). + +## Sweep verdicts (before → after) + +Of 29 client e2e classes audited: 9 honest, 6 partial, 13 false-green +risk, 1 mislabeled. All 20 non-honest were remediated the same day: + +| Group | Tests | Remediation | +|---|---|---| +| `/ar` command tests | WorldCommandFetchTest, WorldCommandFetchModeratorTest, WorldCommandPlayerEquippedE2ETest | `exec-as-player` stimulus → real client chat (`sendChat`); outcomes read at the player layer (client position/dim/inventory/chat overlay), server probes demoted to oracles | +| Ride tests | HovercraftRideE2ETest, ElevatorCapsuleRideE2ETest | throttle = real W key, dismount = real sneak; assertions via `reportRidingEntity` (mount stays a probe — SOP-allowed arrange) | +| Item-use tests | ItemHovercraftSpawn, OreScannerRightClick, ItemAtmosphereAnalzer, ItemSealDetector, ItemBiomeChanger | real `useItem`/`interactBlock` clicks (+ `setLook` aim); observations via client entities / client screen / client chat (i18n resolved); arrange-only probe splits (`equip-orescanner`, `equip-biomechanger`, `satellite poslist-size`) | +| Relabeled to testServer (user-approved) | VacuumGuards, Advancements→AdvancementsTrigger, LowGravFallDamage, AtmospherePlayerEvent | server-side handler contracts that the client tier drove via probes anyway; headless player supplied by `artest player ensure-fake` + `tick-living` | +| Partial completions | RocketBuilderGui (client sees the spawned EntityRocket), suit ×2 + GasChargePad (client-rendered chest NBT) | player-layer completion asserts added | + +Remaining PARTIAL (accepted): `RailgunCargoTransitE2ETest` — stimulus and +assertions are server probes; the cargo-transit contract is double-pinned at +the server tier (`RailgunFiringContractTest`), and the client-visible surface +(hatch GUI contents) needs a dedicated GUI leg. Candidate follow-up, not a +silent exception. + +## Framework capabilities added (vendored testframework/) + +`send_chat`, `report_mods`, `use_item`, `interact_block` (week of 06-10), +`report_chat`, `report_player_items`, `report_entities` (06-11) — each landed +in the same commit as the first test using it. + +## Production bugs found by the sweep (all fixed on `fix/various`) + +Connectionless player-shaped entities (Forge FakePlayers — spawned by +turtles/block-breakers/test harnesses) crashed AR server-side: + +1. `EntityEventHandler.onJoinWorld` / `onPlayerChangedDimension` — + unconditional `player.connection.sendPacket` (+ CCE-prone cast for non-MP + EntityPlayer impls). Guarded. +2. `PlanetWeatherManager.syncToPlayer` — same. Guarded. +3. `AtmosphereHandler.onTick` effect paths — potion sync + + `PacketOxygenState` (now via `AtmosphereType.sendToRealPlayer`) took the + server tick loop down for connectionless players in non-breathable dims. + Effects now skip them; cache/sync bookkeeping still runs. +4. (Latent, found by the OreScanner rewrite) `ItemOreScanner.onItemRightClick` + casts the stored satellite id to `int` before the registry lookup — long + ids silently never resolve and the GUI never opens. Documented at the + probe; production fix not yet decided (ids are int-safe in practice). + +## Probe defect found (open) + +`artest server wait ` executes ON the server thread +(console command), so its sleep-poll loop blocks ticking entirely: it +returns `elapsedTicks:0` after burning its wall budget and stalls the +server for the duration. Every existing caller got a silent no-op wait. +Relocated tests wait off-thread (test-JVM sleep) instead. Follow-up: +fix or retire the probe and sweep its callers (RocketDescentLandingTest +et al.). + +## Coverage-audit cross-check (same sweep) + +The 2026-05-27/29/31 audit trio remains trustworthy: all Deep/Partial pins +exist (one stale name fixed: `ARWeatherWorldInfoTest` → +`ARDimensionWorldInfoTest`), every accepted §3 proposal shipped or tracked, +no dangling debt. Pyramid counter: trust `tasks/README.md` (regen 2026-06-03) +over audit snapshots. diff --git a/.agent/sops/development/honest-client-e2e.md b/.agent/sops/development/honest-client-e2e.md index c92e0ba91..e4a9735f4 100644 --- a/.agent/sops/development/honest-client-e2e.md +++ b/.agent/sops/development/honest-client-e2e.md @@ -92,12 +92,14 @@ client contract beats ten server probes pretending. ## If the harness can't observe it honestly — extend the harness, don't fake it If a client contract has no honest observation/stimulus yet, add the -capability to ForgeTestFramework (its functional changes go straight to -`master`), bump its version, `publishToMavenLocal`, bump the AR dep — then -write the honest test. Recent examples: `setKey/holdKey` (real key path), -`setLook` (real mouse aim), `report_state.player*` and -`reportRidingEntity.rotation*` (client-observed look/orientation). Never -weaken the test to fit a missing capability. +capability to the vendored framework (`testframework/src/main/java/...`, +a git subtree since 2026-06-10) in the SAME commit as the first test that +uses it — no version bumps, no publishing. Recent examples: `setKey/holdKey` +(real key path), `setLook` (real mouse aim), `sendChat` (real chat/command +path), `useItem`/`interactBlock` (real right-clicks), `reportRidingEntity`, +`reportChat` (i18n-resolved overlay), `reportPlayerItems` (client-rendered +stacks incl. NBT), `reportEntities` (client-world entity presence), +`reportMods`. Never weaken the test to fit a missing capability. ## Prevention @@ -107,6 +109,13 @@ weaken the test to fit a missing capability. - [ ] Server probes appear only as setup or as a cross-side oracle. - [ ] Missing observability was added to FTF, not worked around. +## Known trap: `artest server wait` + +`/artest server wait ` runs ON the server thread and therefore +blocks ticking while it waits — it is a no-op stall (returns +`elapsedTicks:0`). To let server ticks elapse, wait OFF-thread: client-tier +tests use `bot().waitTicks(n)`; server-tier tests sleep in the test JVM. + ## Related documents - [testing-principles](./testing-principles.md) — contracts vs impl details. From 27a1a4640eccb3f9d89dcc2299d781c4931dd482 Mon Sep 17 00:00:00 2001 From: StannisMod Date: Thu, 11 Jun 2026 12:37:29 +0200 Subject: [PATCH 47/47] test: harden seal-detector chat poll; record shared-box flake verdicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-suite validation: testServer fully green (1h11m). testClient green except three load-shaped failures under sibling-session contention: GuidanceComputerGuiE2ETest and one ItemSealDetector method passed on re-run (the latter's chat poll widened to a 20-line window / 200-tick cap — harness FORGE_TEST_DONE markers share the overlay and can push the reply down); WorldCommandFetchModeratorTest's first client bridge dies at world-join only while sibling Minecraft clients hold the same display — green standalone after the rewrite, recorded in the 2026-06-11 delta as environment contention, re-check on a quiet box. --- .agent/audits/2026-06-11-honest-e2e-delta.md | 15 +++++++++++++++ .../ItemSealDetectorPlayerMessagesE2ETest.java | 7 +++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.agent/audits/2026-06-11-honest-e2e-delta.md b/.agent/audits/2026-06-11-honest-e2e-delta.md index bc23ea91c..88bec5806 100644 --- a/.agent/audits/2026-06-11-honest-e2e-delta.md +++ b/.agent/audits/2026-06-11-honest-e2e-delta.md @@ -60,6 +60,21 @@ Relocated tests wait off-thread (test-JVM sleep) instead. Follow-up: fix or retire the probe and sweep its callers (RocketDescentLandingTest et al.). +## Flake note (same day) + +`WorldCommandFetchModeratorTest` (3 JVMs: server + 2 GL clients, ~7 GB): +green standalone at 11:05 after the sendChat rewrite; from ~12:30 the +first client's bridge dies seconds after world-join ("Client bridge +closed unexpectedly" at the first waitTicks), reproducibly, while TWO +sibling-session Minecraft clients run on the same box/display (pgrep +evidence per the shared-box memory). Code unchanged between green and +red. Verdict: shared-box display/RAM contention, not a test defect — +re-run when the box is quiet before treating as a regression. +`GuidanceComputerGuiE2ETest` and one `ItemSealDetector` method failed +once in the full-suite run under the same load and passed on re-run +(the seal test's chat poll was also hardened: 20-line window, 200-tick +cap). + ## Coverage-audit cross-check (same sweep) The 2026-05-27/29/31 audit trio remains trustworthy: all Deep/Partial pins diff --git a/src/test/java/zmaster587/advancedRocketry/test/client/ItemSealDetectorPlayerMessagesE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/ItemSealDetectorPlayerMessagesE2ETest.java index 286b845e5..af35d3fce 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/client/ItemSealDetectorPlayerMessagesE2ETest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/client/ItemSealDetectorPlayerMessagesE2ETest.java @@ -133,9 +133,12 @@ private void assertSealDetectorBranch(int x, String fixtureBlock, String expecte // The player must READ the branch's resolved message on their chat. boolean found = false; String newest = ""; - for (int waited = 0; waited < 100 && !found; waited += 10) { + // 20-line window + 200-tick poll: the harness' console markers + // ([Server] FORGE_TEST_DONE …) also land on the overlay and can + // push the reply down, and a loaded box stretches the roundtrip. + for (int waited = 0; waited < 200 && !found; waited += 10) { bot().waitTicks(10); - com.google.gson.JsonArray lines = bot().reportChat(5).getAsJsonArray("lines"); + com.google.gson.JsonArray lines = bot().reportChat(20).getAsJsonArray("lines"); for (int i = 0; i < lines.size(); i++) { String line = lines.get(i).getAsString(); if (newest.isEmpty()) newest = line;