diff --git a/.agent/DEVELOPMENT-README.md b/.agent/DEVELOPMENT-README.md
index 9f6461ddf..51ef7ed41 100644
--- a/.agent/DEVELOPMENT-README.md
+++ b/.agent/DEVELOPMENT-README.md
@@ -8,6 +8,37 @@
## ⚠️ 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 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/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..88bec5806
--- /dev/null
+++ b/.agent/audits/2026-06-11-honest-e2e-delta.md
@@ -0,0 +1,84 @@
+# 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.).
+
+## 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
+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/history/known-bugs-ledger.md b/.agent/history/known-bugs-ledger.md
index 74a4974b4..cab7c4cbb 100644
--- a/.agent/history/known-bugs-ledger.md
+++ b/.agent/history/known-bugs-ledger.md
@@ -4,9 +4,11 @@
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,
-#6 fixed by TASK-43 Phase 3 (see per-entry notes below).
+**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)
and append it to Batch #2 here AND to the README summary.
@@ -255,3 +257,81 @@ 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. ✅ **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.
+ 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), 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).
+
+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/knowledge/graph.json b/.agent/knowledge/graph.json
index be355556c..e1e99ca7e 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-10T12:28:26.822412Z",
"stats": {
- "total_nodes": 116,
- "total_edges": 400,
+ "total_nodes": 121,
+ "total_edges": 423,
"memory_count": 8
},
"nodes": {
@@ -590,6 +590,64 @@
"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": [
+ "api",
+ "tom",
+ "deployment",
+ "testing"
+ ]
+ },
+ "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"
+ ]
+ },
+ "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": {},
@@ -3606,6 +3664,121 @@
"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"
+ },
+ {
+ "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": {
@@ -3692,7 +3865,11 @@
"TASK-37",
"TASK-38",
"TASK-39",
- "TASK-40"
+ "TASK-40",
+ "TASK-45",
+ "TASK-46",
+ "TASK-48",
+ "TASK-50"
],
"knowledge": [
"TASK-09",
@@ -3751,7 +3928,8 @@
"TASK-38",
"TASK-39",
"TASK-40",
- "TASK-44"
+ "TASK-44",
+ "TASK-45"
],
"testing": [
"TASK-09",
@@ -3851,7 +4029,11 @@
"TASK-41",
"TASK-42",
"TASK-43",
- "TASK-44"
+ "TASK-44",
+ "TASK-45",
+ "TASK-46",
+ "TASK-47",
+ "TASK-50"
],
"backend": [
"TASK-09",
@@ -3934,7 +4116,9 @@
"2026-05-14-1150_client-e2e-fg6-harness",
"2026-05-20-2030_task04-terraformer-orbitallaser",
"TASK-42",
- "TASK-43"
+ "TASK-43",
+ "TASK-48",
+ "TASK-50"
],
"markers": [
"TASK-09",
@@ -4098,7 +4282,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 +4317,11 @@
"TASK-40",
"TASK-42",
"TASK-43",
- "TASK-44"
+ "TASK-44",
+ "TASK-45",
+ "TASK-46",
+ "TASK-47",
+ "TASK-50"
],
"deployment": [
"TASK-03",
@@ -4167,7 +4356,12 @@
"TASK-41",
"TASK-42",
"TASK-43",
- "TASK-44"
+ "TASK-44",
+ "TASK-45",
+ "TASK-46",
+ "TASK-47",
+ "TASK-48",
+ "TASK-50"
],
"frontend": [
"TASK-03",
@@ -4251,7 +4445,9 @@
"TASK-41",
"TASK-42",
"TASK-43",
- "TASK-44"
+ "TASK-44",
+ "TASK-47",
+ "TASK-48"
],
"diagnosis": [
"mem-006"
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.
diff --git a/.agent/tasks/README.md b/.agent/tasks/README.md
index f0302a48c..12f06ce3e 100644
--- a/.agent/tasks/README.md
+++ b/.agent/tasks/README.md
@@ -14,8 +14,16 @@ Bug-ledger history lives in
## Current state
-- **Pyramid**: 856 (testUnit **288** / testIntegration 81 /
- testServer **426** / testClient **61**). +1 on 2026-05-29 from
+- **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). 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
TASK-40d Batch 4 (Gap L force field projector). +8 on 2026-05-29 from
@@ -142,11 +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**: 4 live bugs. Arithmetic: 7 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)
- = 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
+ 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 —
@@ -307,6 +317,20 @@ 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) ✅ **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
+ 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 +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) — 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
@@ -374,6 +399,11 @@ 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. |
+| [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. |
## 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/.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/.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..b333ac102
--- /dev/null
+++ b/.agent/tasks/TASK-47-per-dim-time-and-sleep.md
@@ -0,0 +1,142 @@
+# 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: ✅ **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: ✅ 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)
+
+- `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.
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..1256972df
--- /dev/null
+++ b/.agent/tasks/TASK-49-railgun-silent-fire-failure.md
@@ -0,0 +1,140 @@
+# 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.
+- 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
+
+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).
+- **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 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
+
+- 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. Repro touched `TestProbeCommand.java` + new test files; the fix
+ touched `TileRailgun` (firing path + GUI status) + `en_US.lang`.
+
+## Bug ledger
+
+Logged as Batch #2 entry #8 in `.agent/history/known-bugs-ledger.md`.
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
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
diff --git a/src/main/java/zmaster587/advancedRocketry/asm/AdvancedRocketryPlugin.java b/src/main/java/zmaster587/advancedRocketry/asm/AdvancedRocketryPlugin.java
index 66567c186..bc30027f4 100644
--- a/src/main/java/zmaster587/advancedRocketry/asm/AdvancedRocketryPlugin.java
+++ b/src/main/java/zmaster587/advancedRocketry/asm/AdvancedRocketryPlugin.java
@@ -40,7 +40,9 @@ public String[] getASMTransformerClass() {
@Override
public String getModContainerClass() {
- return "zmaster587.advancedRocketry.asm.ModContainer";
+ // fix/various removed the vestigial dummy ModContainer (7f8ee7f0);
+ // pointing FML at the deleted class crashes mod identification.
+ 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) {
- }
-}
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 6a6208425..9b85f977f 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) {
@@ -2583,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);
@@ -3616,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;
@@ -6326,6 +6380,167 @@ 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;
+ }
+
+ // 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().
+ 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;
+ }
+ // 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;
+ // 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) {
+ 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
+ + ",\"destLoadedBefore\":" + destLoadedBefore
+ + ",\"destLoaded\":" + destLoaded
+ + ",\"destIsRailgun\":" + destIsRailgun
+ + ",\"destMatched\":" + destMatched
+ + ",\"fireStatus\":\"" + escapeJson(fireStatus) + "\"}");
+ 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 +6713,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 -----------------------------------------
@@ -10248,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) {
@@ -10467,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
@@ -10584,6 +10888,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]
//
@@ -12197,6 +12579,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
@@ -12378,7 +12789,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 +12854,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)
@@ -12752,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/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/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/mixin/MixinWorldServer.java b/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServer.java
new file mode 100644
index 000000000..f5ca2e093
--- /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.ARDimensionWorldInfo;
+
+/**
+ * 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 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 ARDimensionWorldInfo} 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(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/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
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/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/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 67%
rename from src/main/java/zmaster587/advancedRocketry/world/weather/ARWeatherWorldInfo.java
rename to src/main/java/zmaster587/advancedRocketry/world/weather/ARDimensionWorldInfo.java
index 1584f89b4..8c114e7a9 100644
--- a/src/main/java/zmaster587/advancedRocketry/world/weather/ARWeatherWorldInfo.java
+++ b/src/main/java/zmaster587/advancedRocketry/world/weather/ARDimensionWorldInfo.java
@@ -32,13 +32,22 @@
* 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;
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 ARDimensionWorldInfo(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/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:
*
*
* - 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.
+ * - {@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.
*
*/
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) {
diff --git a/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java b/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java
index ec52a7204..23cceccf3 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}
@@ -120,13 +120,17 @@ 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();
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;
@@ -144,7 +148,20 @@ public static boolean shouldWrap(WorldServer world) {
}
/**
- * Idempotent + safe. Installs (or refreshes) {@link ARWeatherWorldInfo} on
+ * 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 ARDimensionWorldInfo} on
* the given world.
*
* "Refresh" — if the world somehow gets a fresh {@link WorldInfo} after
@@ -165,11 +182,29 @@ public static void wrapWorldInfoIfNeeded(WorldServer world) {
PlanetWeatherState state = saved.getOrCreate(dim);
WorldInfo current = world.getWorldInfo();
- ARWeatherWorldInfo wrapped = new ARWeatherWorldInfo(current, state,
- () -> markDirty(world));
+ ARDimensionWorldInfo wrapped = new ARDimensionWorldInfo(current, state,
+ () -> markDirty(world), isWeatherManaged(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,
@@ -180,8 +215,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();
}
}
@@ -257,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/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherState.java b/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherState.java
index d6498dac4..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.
@@ -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/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/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/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/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/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/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());
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..af35d3fce 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,62 @@ 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 = "";
+ // 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(20).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 +160,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 +168,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 +176,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 +191,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 +201,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/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/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/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/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();
}
}
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/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..a54914f05
--- /dev/null
+++ b/src/test/java/zmaster587/advancedRocketry/test/client/RailgunCargoTransitE2ETest.java
@@ -0,0 +1,163 @@
+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}. 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
+ * ({@link zmaster587.advancedRocketry.tile.multiblock.TileRailgun#attemptCargoTransfer}).
+ * {@link zmaster587.advancedRocketry.test.server.RailgunFiringContractTest}
+ * 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. 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 on the harness server → production cannot load it. */
+ private static final int UNREGISTERED_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+)");
+ 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 (status FIRED) — 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)));
+ 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
+ + " 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);
+ }
+
+ /**
+ * 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 railgunReportsUnavailableForUnloadableDestinationClientSide() throws Exception {
+ bot().waitForWorld();
+ forceloadFootprints();
+
+ buildAndComplete(SX, SY, SZ);
+
+ String fire = exec("artest infra railgun-fire 0 " + SX + " " + SY + " " + SZ
+ + " " + UNREGISTERED_DIM + " 0 64 0 minecraft:cobblestone " + CARGO);
+ assertTrue("railgun-fire probe must succeed: " + fire,
+ fire.contains("\"ok\":true"));
+
+ assertTrue("railgun 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 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. */
+ 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 " + 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));
+ }
+}
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);
}
}
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/client/WeatherClientSyncE2ETest.java b/src/test/java/zmaster587/advancedRocketry/test/client/WeatherClientSyncE2ETest.java
index 3e92e7cfd..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));
@@ -136,7 +144,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 +160,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,
@@ -195,14 +203,58 @@ 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",
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"));
+
+ // ── 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/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/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/src/test/java/zmaster587/advancedRocketry/test/integration/ARWeatherWorldInfoTest.java b/src/test/java/zmaster587/advancedRocketry/test/integration/ARDimensionWorldInfoTest.java
similarity index 56%
rename from src/test/java/zmaster587/advancedRocketry/test/integration/ARWeatherWorldInfoTest.java
rename to src/test/java/zmaster587/advancedRocketry/test/integration/ARDimensionWorldInfoTest.java
index 15742637a..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,20 +17,20 @@
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;
* - (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}
* 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);
+ 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.
@@ -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();
+ ARDimensionWorldInfo 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();
+ AtomicInteger dirtyHits = new AtomicInteger();
+ ARDimensionWorldInfo 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();
- ARWeatherWorldInfo wrapper = wrap(delegate, new PlanetWeatherState(), () -> {});
+ PlanetWeatherState state = new PlanetWeatherState();
+ ARDimensionWorldInfo wrapper =
+ new ARDimensionWorldInfo(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
@@ -137,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);
@@ -155,20 +204,19 @@ 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);
- 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());
}
@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/integration/XMLPlanetLoaderTest.java b/src/test/java/zmaster587/advancedRocketry/test/integration/XMLPlanetLoaderTest.java
index acf521f55..8f91e1ab7 100644
--- a/src/test/java/zmaster587/advancedRocketry/test/integration/XMLPlanetLoaderTest.java
+++ b/src/test/java/zmaster587/advancedRocketry/test/integration/XMLPlanetLoaderTest.java
@@ -1,5 +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;
@@ -166,23 +172,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 +332,173 @@ 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"));
+ }
+ }
+
+ // ---- 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) {
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/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/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/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/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\""));
+ }
+}
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/RailgunFiringContractTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/RailgunFiringContractTest.java
new file mode 100644
index 000000000..b62329102
--- /dev/null
+++ b/src/test/java/zmaster587/advancedRocketry/test/server/RailgunFiringContractTest.java
@@ -0,0 +1,215 @@
+package zmaster587.advancedRocketry.test.server;
+
+import org.junit.Assume;
+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,
+ * now pinning the FIXED behaviour (TASK-49).
+ *
+ * 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}).
+ *
+ * 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
+ * (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 sources for the cross-dimension cases (shared server JVM).
+ private static final int UX = 5020;
+ private static final int LX = 5080;
+ private static final int XZ = 4900;
+
+ /** 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;
+
+ 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+)");
+ 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);
+ 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; "
+ + "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
+ + " 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 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 railgunReportsUnavailableForUnloadableDestination() throws Exception {
+ buildAndComplete(UX, SY, XZ);
+
+ 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("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 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);
+ 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 " + 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));
+ }
+}
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"));
+ }
+}
diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/WeatherBaselineTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/WeatherBaselineTest.java
index 28ef82f5e..777ceb5e6 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,23 @@ 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"));
+
+ // 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"));
}
}
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/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..1f72a7506
--- /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.ARDimensionWorldInfo;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * TASK-47 / issue #66 — pure-math unit pins for
+ * {@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 = 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 + ")",
+ 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, ARDimensionWorldInfo.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 = 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, ARDimensionWorldInfo.computeSleepWakeTime(0L, 0));
+ assertEquals(24000L, ARDimensionWorldInfo.computeSleepWakeTime(0L, -5));
+ }
+}
diff --git a/testframework/.gitignore b/testframework/.gitignore
new file mode 100644
index 000000000..b45be2369
--- /dev/null
+++ b/testframework/.gitignore
@@ -0,0 +1,8 @@
+.gradle/
+.gradle_home/
+build/
+out/
+logs/
+run/
+*.iml
+.idea/
diff --git a/testframework/README.md b/testframework/README.md
new file mode 100644
index 000000000..cb5ea70c0
--- /dev/null
+++ b/testframework/README.md
@@ -0,0 +1,134 @@
+# Forge Test Framework
+
+Reusable testing infrastructure for Forge 1.12.2 mods. The framework can be
+consumed in three ways (in order of preference for downstream mods):
+
+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.
+
+## 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`:
+
+```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) |
+
+## Scope
+
+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
+
+Use Java 8.
+
+```bash
+./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.3.0:dev")
+}
+```
+
+### Verify a publication
+
+```bash
+ls ~/.m2/repository/com/github/stannismod/forge/forge-test-framework/
+```
+
+Expected layout for version `0.3.0`:
+
+```
+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/testframework/TEST_FRAMEWORK.md b/testframework/TEST_FRAMEWORK.md
new file mode 100644
index 000000000..5a968d07c
--- /dev/null
+++ b/testframework/TEST_FRAMEWORK.md
@@ -0,0 +1,48 @@
+# 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.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.
+
+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.
+
+## JUnit Base Classes
+
+`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`.
+
+`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`.
+
+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
+
+`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/testframework/build.gradle b/testframework/build.gradle
new file mode 100644
index 000000000..0946125ee
--- /dev/null
+++ b/testframework/build.gradle
@@ -0,0 +1,82 @@
+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.4.5'
+
+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) {
+ // 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'
+ }
+ }
+ }
+ }
+}
diff --git a/testframework/dependencies.gradle b/testframework/dependencies.gradle
new file mode 100644
index 000000000..89f56bf28
--- /dev/null
+++ b/testframework/dependencies.gradle
@@ -0,0 +1,12 @@
+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'
+
+ // 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/testframework/gradle.properties b/testframework/gradle.properties
new file mode 100644
index 000000000..f9ec7a35c
--- /dev/null
+++ b/testframework/gradle.properties
@@ -0,0 +1,2 @@
+org.gradle.logging.stacktrace=all
+org.gradle.jvmargs=-Xmx3G
diff --git a/testframework/gradle/wrapper/gradle-wrapper.jar b/testframework/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000..deedc7fa5
Binary files /dev/null and b/testframework/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/testframework/gradle/wrapper/gradle-wrapper.properties b/testframework/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..48bb4e36a
--- /dev/null
+++ b/testframework/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/testframework/gradlew b/testframework/gradlew
new file mode 100755
index 000000000..9aa616c27
--- /dev/null
+++ b/testframework/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/testframework/gradlew.bat b/testframework/gradlew.bat
new file mode 100644
index 000000000..f9553162f
--- /dev/null
+++ b/testframework/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/testframework/repositories.gradle b/testframework/repositories.gradle
new file mode 100644
index 000000000..9ee8351b2
--- /dev/null
+++ b/testframework/repositories.gradle
@@ -0,0 +1,7 @@
+repositories {
+ mavenCentral()
+ maven {
+ name = 'GTNH Maven'
+ url = 'https://nexus.gtnewhorizons.com/repository/public/'
+ }
+}
diff --git a/testframework/settings.gradle b/testframework/settings.gradle
new file mode 100644
index 000000000..924063e14
--- /dev/null
+++ b/testframework/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/testframework/src/main/java/com/github/stannismod/forge/testing/TestAssertions.java b/testframework/src/main/java/com/github/stannismod/forge/testing/TestAssertions.java
new file mode 100644
index 000000000..b76b60b1a
--- /dev/null
+++ b/testframework/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/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
new file mode 100644
index 000000000..ddba45810
--- /dev/null
+++ b/testframework/src/main/java/com/github/stannismod/forge/testing/client/ClientBot.java
@@ -0,0 +1,405 @@
+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));
+ }
+
+ /**
+ * 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);
+ 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")));
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * 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},
+ * {@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));
+ }
+
+ /**
+ * 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,
+ * 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
+ * "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}
+ * (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},
+ * {@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);
+ 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/testframework/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java b/testframework/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java
new file mode 100644
index 000000000..d3c69b316
--- /dev/null
+++ b/testframework/src/main/java/com/github/stannismod/forge/testing/client/RealClientHarness.java
@@ -0,0 +1,942 @@
+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.Map;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+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;
+ 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 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;
+ 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 {
+ 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);
+
+ 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,
+ clientUsername);
+
+ ClientBot bot = awaitClientBot(controlSocket);
+ bot.waitForWorld();
+ 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."
+ + (preservedLog != null ? " Full log: " + preservedLog : "")
+ + "\nRecent client log:\n" + logTail, 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 {
+ // 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);
+ }
+ }
+ }
+
+ /**
+ * 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";
+
+ /**
+ * 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,
+ String clientUsername) throws IOException {
+ Path javaBinary = resolveJavaBinary();
+ 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-");
+ 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());
+ // 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);
+ 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());
+ // 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");
+ 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("--accessToken");
+ javaArgs.add("FML");
+ javaArgs.add("--userProperties");
+ javaArgs.add("{}");
+ javaArgs.add("--profileProperties");
+ javaArgs.add("{}");
+ // --username + --uuid are now passed unconditionally above
+ // (FG6's MainClient also honours them).
+ 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());
+ command.addAll(javaArgs);
+
+ // 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) {
+ // fall through to the logged ProcessBuilder path
+ }
+ }
+
+ ProcessBuilder builder = new ProcessBuilder(command);
+ builder.directory(root.toFile());
+ builder.redirectErrorStream(true);
+ 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 {
+ 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 is consumed by the first ShowWindow(hwnd,
+ // SW_SHOWDEFAULT) call in the child process. LWJGL2's Display.create()
+ // ultimately issues SW_SHOWDEFAULT on the OpenGL window, so this also
+ // controls how the Minecraft client appears (not just the JVM console).
+ String startState = System.getProperty(PROP_WINDOW_START_STATE, "minimized")
+ .toLowerCase(java.util.Locale.ROOT);
+ startupInfo.wShowWindow = (short) ("normal".equals(startState)
+ ? SW_SHOWNOACTIVATE
+ : SW_SHOWMINNOACTIVE);
+
+ 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 {
+ // 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: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 {
+ 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 {
+ 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"));
+
+ String[] markers = {
+ "lwjgl64.dll", "lwjgl.dll",
+ "liblwjgl64.so", "liblwjgl.so",
+ "liblwjgl.dylib"
+ };
+ for (Path candidate : candidates) {
+ for (String marker : markers) {
+ if (Files.isRegularFile(candidate.resolve(marker))) {
+ return candidate;
+ }
+ }
+ }
+
+ 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 {
+ 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 "";
+ }
+ // 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) {
+ 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/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
new file mode 100644
index 000000000..4d66b497d
--- /dev/null
+++ b/testframework/src/main/java/com/github/stannismod/forge/testing/client/bridge/ForgeTestClientBootstrap.java
@@ -0,0 +1,1133 @@
+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;
+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.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;
+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.ArrayList;
+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 "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();
+ 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("playerYaw", mc.player.rotationYaw);
+ response.addProperty("playerPitch", mc.player.rotationPitch);
+ 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 "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);
+ 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");
+ 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 "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 "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
+ // 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
+ // 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,
+ // 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
+ // 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();
+ 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;
+ }
+
+ /** {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);
+ 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);
+ }
+ }
+
+ /**
+ * 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);
+ 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) {
+ if (CLIENT_TICKS.get() == 0L) {
+ // First END-phase tick: Display.create() has returned and
+ // the LWJGL window is up. Honour the start-state override.
+ applyInitialWindowState();
+ }
+ CLIENT_TICKS.incrementAndGet();
+ }
+ }
+ }
+
+ private static final AtomicBoolean WINDOW_STATE_APPLIED = new AtomicBoolean(false);
+
+ /**
+ * Minimises the LWJGL client window after Display.create() so tests don't
+ * steal focus from concurrent local work. LWJGL2's native createWindow
+ * calls {@code ShowWindow(SW_SHOW)} directly, ignoring our
+ * {@code STARTUPINFO.wShowWindow} hint — so we have to issue
+ * {@code ShowWindow(SW_MINIMIZE)} ourselves once the window exists.
+ *
+ * Controlled 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;
+
+ 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/testframework/src/main/java/com/github/stannismod/forge/testing/junit/AbstractClientE2ETest.java b/testframework/src/main/java/com/github/stannismod/forge/testing/junit/AbstractClientE2ETest.java
new file mode 100644
index 000000000..06955167a
--- /dev/null
+++ b/testframework/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/testframework/src/main/java/com/github/stannismod/forge/testing/junit/AbstractHeadlessServerTest.java b/testframework/src/main/java/com/github/stannismod/forge/testing/junit/AbstractHeadlessServerTest.java
new file mode 100644
index 000000000..2e626be55
--- /dev/null
+++ b/testframework/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();
+ }
+}
diff --git a/testframework/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java b/testframework/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java
new file mode 100644
index 000000000..e95ab35b8
--- /dev/null
+++ b/testframework/src/main/java/com/github/stannismod/forge/testing/server/RealDedicatedServerHarness.java
@@ -0,0 +1,373 @@
+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 final boolean cleanupOnClose;
+
+ 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 {
+ if (bootstrap) {
+ 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));
+ }
+ }
+ throw new AssertionError("Timed out waiting for server to become ready. Recent output: "
+ + tailOf(transcript));
+ }
+
+ 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() {
+ 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();
+ }
+ if (cleanupOnClose) {
+ deleteRecursively(root);
+ }
+ }
+ }
+
+ /**
+ * 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");
+ 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(javaName)
+ : Paths.get(javaExe, "bin", javaName);
+ 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());
+ 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(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 above in startInternal) and gameDir is the cwd.
+ command.add("--nogui");
+ }
+
+ 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 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) {
+ 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/testframework/src/main/java/com/github/stannismod/forge/testing/server/TestClient.java b/testframework/src/main/java/com/github/stannismod/forge/testing/server/TestClient.java
new file mode 100644
index 000000000..49f3a5162
--- /dev/null
+++ b/testframework/src/main/java/com/github/stannismod/forge/testing/server/TestClient.java
@@ -0,0 +1,137 @@
+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 {
+ // 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));
+ 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));
+ }
+}
+