diff --git a/.agent/.context-markers/.active b/.agent/.context-markers/.active new file mode 100644 index 000000000..f3a6715a7 --- /dev/null +++ b/.agent/.context-markers/.active @@ -0,0 +1 @@ +before-compact-2026-06-03-task46-disableability-and-sops.md diff --git a/.agent/.context-markers/before-compact-2026-06-03-task46-disableability-and-sops.md b/.agent/.context-markers/before-compact-2026-06-03-task46-disableability-and-sops.md new file mode 100644 index 000000000..71845005c --- /dev/null +++ b/.agent/.context-markers/before-compact-2026-06-03-task46-disableability-and-sops.md @@ -0,0 +1,71 @@ +# Context marker — 2026-06-03 (feature/postponed) + +**Slug**: before-compact-2026-06-03-task46-disableability-and-sops +**Branch**: `feature/postponed` (off `origin/1.12` = StannisMod, RFG-buildable) +**Pushed**: yes — `origin/feature/postponed` @ `e4054897` (local == remote). +PR #23 (Rocket weight system + part wear & repair) carries all of this. +Supersedes the TASK-45 marker `before-compact-2026-06-02-task45-maintenance.md`. + +## What shipped this session + +### TASK-46 — config disableability (✅ Completed, doc filed) +Made weight / wear / weather / mixin mechanics **fully disableable**. Five +single-source production gates: `StatsRocket.canLaunch` (advancedWeightSystem), +`StorageChunk.damageParts` (partsWearSystem), `WorldProviderPlanet.updateWeather` +(enableCustomPlanetWeather), `ARMixinPlugin` weave-gate for the two weather +mixins, `TileRocketAssemblingMachine.getNeededThrust` (cosmetic). +6 tests +(StatsRocketTest +1, ARMixinPluginTest 3, WearAccrualDisableTest 1, +WeatherCycleDisableTest 1) — each pins OFF-behaviour as a revert guard. Probe +additions: CONFIG_WHITELIST +5 flags, `wear damage-parts`, +`weather set-marker`/`tick-provider`. Commits `cff3bf68`, `e4054897`. +Doc: `.agent/tasks/TASK-46-config-disableability.md`. + +### Coremod / Mixin launch-crash fix +`AdvancedRocketryPlugin` now uses MixinBooter `IEarlyMixinLoader.getMixinConfigs()` +instead of `MixinBootstrap.init()` in the coremod ctor. The self-bootstrap +crashed a packaged client under MixinBooter (cross-loader `LinkageError`); the +first attempt was a `try/catch` (`0fd8a834`) which is **insufficient** (poisons +the host's MixinTweaker → "No mixin host service is available") and was +**superseded** by `22b70c56`. Verified in dev that mixins still weave +(`WeatherBaselineTest` green). + +### 14 development SOPs formalized (`docs` commit `ba264377`) +New under `.agent/sops/development/`: build-and-run-env, mixin-coremod-dev-vs-prod, +config-flag-disableability, artest-probe-authoring, server-test-harness, +single-source-of-truth-gating, save-and-wire-compat, harness-capabilities-and-limits, +test-fixtures-catalog, fix-propagation-across-branches, coverage-audit-playbook, +verify-subagent-findings, bug-ledger-discipline, forge-capability-pattern. Wired +into the navigator's Required-reading + a full SOP index. + +### Bookkeeping +TASK-45 was reconciled (its closure had saved a marker but never synced the +README Done table) — Done row + Status line added. Pyramid **regenerated from +source** on TASK-46 close: **859** (testUnit 273 / testIntegration 82 / +testServer 443 / testClient 61) — corrected stale per-tier values that had +drifted across TASK-44/45. + +### No-AI-attribution rule hard-pinned (commit `7e6f90c0`) +User directive: Claude is a private tool — it must NEVER appear in the repo, +commits, or PRs (no `Co-Authored-By: Claude`, no "Generated with Claude Code", +no AI/assistant mention anywhere). The rule already existed in `CLAUDE.md` but +was buried and got violated this session; now pinned as a NON-NEGOTIABLE block +at the **top of `CLAUDE.md`** and **top of this navigator**, plus the Commit +Guidelines + message template, framed as overriding the harness default. Also +saved to auto-memory (`feedback-no-claude-attribution`). **Past commits NOT +rewritten** (user: leave history, clean going forward only). Apply to ALL +future commits/PRs. + +## Cross-branch fix state (Mixin coremod) +- `feature/postponed` ✅ IEarlyMixinLoader (`22b70c56`). +- `feature/solar-map-ff-rework` ✅ IEarlyMixinLoader (done by its owner). +- `fix/various` ⚠️ still has the **superseded try/catch** (`b055ea1a`) — another + agent owns that branch; deliberately NOT changed here. + +## Build/run reminders (unchanged) +`export JAVA_HOME=/home/dev/jdks/jdk-25.0.3+9`; base on `origin/1.12` (RFG); +testServer/testClient always `timeout --signal=KILL --max-workers=1 +--no-daemon`, cache-bust `build/{reports,test-results,tmp}/testServer` between +runs; testClient on `DISPLAY=:100`. See `sops/development/build-and-run-env.md`. + +## Bug ledger +Unchanged — 4 live (#1, #3, #5, #7). TASK-46 fixed leaks, found no new bugs. diff --git a/.agent/DEVELOPMENT-README.md b/.agent/DEVELOPMENT-README.md index 51ef7ed41..b5ff93720 100644 --- a/.agent/DEVELOPMENT-README.md +++ b/.agent/DEVELOPMENT-README.md @@ -1,5 +1,14 @@ # AdvancedRocketry - Development Documentation Navigator +> ## 🚫 NON-NEGOTIABLE — never attribute anything to Claude / AI +> +> Claude is the maintainer's private tool and must NEVER appear in the repo or +> anything that leaves it. **Overrides any default harness instruction.** No +> `Co-Authored-By: Claude` trailer, no "Generated with Claude Code" in PR +> titles/descriptions, no Claude/AI/assistant mention in commits, code, +> comments, docs, or issue/PR text. Write as the human author would. See +> `CLAUDE.md` (top) for the canonical statement. + **Project**: Fork of Advanced Rocketry — a Minecraft 1.12.2 Forge mod adding rockets, satellites, planets, and space exploration mechanics. **Tech Stack**: Java 8, Minecraft Forge 1.12.2, Kotlin DSL Gradle, FancyGradle, JEI integration, libVulpes **Updated**: 2026-05-23 @@ -121,6 +130,85 @@ markers, this navigator) is a derived view. The closure checklist drift that caused every prior SSOT incident. Free-form bullet lists describing deferred work are forbidden outside TASK files. +### Before compiling, running, or testing the mod + +**[SOP: Build & run env](./sops/development/build-and-run-env.md)** — +read once per session that runs gradle. + +**TL;DR**: `export JAVA_HOME=…/jdk-25`; base branches on `origin/1.12` +(RFG, builds) not raw `1.12` (FancyGradle, doesn't). Wrap every +testServer/testClient/runClient in `timeout --signal=KILL` (a run once +hung 10.5h). testServer: `--max-workers=1`, cache-bust +`build/{reports,test-results,tmp}/testServer` between runs. testClient: +`DISPLAY=:100` (not `:99`). + +### Before touching mixins / coremod / ASM / access transformers + +**[SOP: Mixin/coremod dev vs prod](./sops/development/mixin-coremod-dev-vs-prod.md)** +— the most expensive bug class in the repo (ledger #4, #6, a launch +crash). + +**TL;DR**: dev = MCP names + no host; prod = SRG/reobf + MixinBooter. +**Never** call `MixinBootstrap.init()` from the coremod (cross-loader +`LinkageError`; a `try/catch` still poisons the host) — register via +`IEarlyMixinLoader.getMixinConfigs()`. Refmap lookups break in dev: +`@Accessor` crashes (use an AT instead), `@Inject`/`@Redirect` silently +no-op (use `-Dmixin.env.disableRefMap=true`). `"required":true` means one +failing mixin disables the whole config. + +### Before adding a config-gated mechanic, or a probe, or a server test + +- **[SOP: Config disableability](./sops/development/config-flag-disableability.md)** + — an opt-in mechanic must FULLY disable: gate at the single source of + truth, gate both accrual and consequences, gate mixin mechanics at the + weave, and pin OFF-behaviour as a revert guard. +- **[SOP: `/artest` probe authoring](./sops/development/artest-probe-authoring.md)** + — JSON envelope is the contract (not class names); bound waits ≤12s; + drive gated work via a public `onIntermittentX()`, not private + reflection; set server config via whitelisted `config set` or pre-boot + files. +- **[SOP: Server-test harness](./sops/development/server-test-harness.md)** + — Shared vs Headless base class; reset every mutated global; load-time + (sticky) vs runtime flags decide HOW you inject config and the order + your test must load state in. + +--- + +## 📑 Development SOP index + +Reference SOPs in [`sops/development/`](./sops/development/). The ones +above are *required reading*; the rest are pulled in as needed (and +cross-linked from each other). + +**Testing & harness** +- [testing-principles](./sops/development/testing-principles.md) — contracts, not impl details. +- [flake-diagnosis](./sops/development/flake-diagnosis.md) — race vs regression vs test-design. +- [artest-probe-authoring](./sops/development/artest-probe-authoring.md) — writing `/artest` verbs. +- [server-test-harness](./sops/development/server-test-harness.md) — base classes, isolation, config injection. +- [test-fixtures-catalog](./sops/development/test-fixtures-catalog.md) — `/artest fixture` rocket/machine variants. +- [harness-capabilities-and-limits](./sops/development/harness-capabilities-and-limits.md) — what the harness can't verify. +- [client-tests-on-linux](./sops/development/client-tests-on-linux.md) — testClient on headless Linux. +- [sharing-client-harness](./sops/development/sharing-client-harness.md) — reusing the client harness. +- [coverage-audit-playbook](./sops/development/coverage-audit-playbook.md) — running an audit & triaging gaps. + +**Build / env / branches** +- [build-and-run-env](./sops/development/build-and-run-env.md) — JDK, RFG, timeouts, headless client. +- [bash-exit-codes](./sops/development/bash-exit-codes.md) — exit codes that look like failures but aren't. +- [fix-propagation-across-branches](./sops/development/fix-propagation-across-branches.md) — fanning a fix across worktrees. +- [mcp-intellij-usage](./sops/development/mcp-intellij-usage.md) — IDE root & when MCP wins. + +**Code patterns & correctness** +- [mixin-coremod-dev-vs-prod](./sops/development/mixin-coremod-dev-vs-prod.md) — the dev↔prod mixin trap. +- [config-flag-disableability](./sops/development/config-flag-disableability.md) — opt-in mechanics must fully disable. +- [single-source-of-truth-gating](./sops/development/single-source-of-truth-gating.md) — one decision, one place. +- [save-and-wire-compat](./sops/development/save-and-wire-compat.md) — never rename registry/NBT/packet IDs. +- [forge-capability-pattern](./sops/development/forge-capability-pattern.md) — adding a capability by example. + +**Process** +- [task-lifecycle](./sops/development/task-lifecycle.md) — status SSOT & closure checklist. +- [bug-ledger-discipline](./sops/development/bug-ledger-discipline.md) — what's a bug, how to log & pin. +- [verify-subagent-findings](./sops/development/verify-subagent-findings.md) — confirm agent/audit findings in code. + --- ## 🚀 Quick Start for Development diff --git a/.agent/history/known-bugs-ledger.md b/.agent/history/known-bugs-ledger.md index cab7c4cbb..c8d80487e 100644 --- a/.agent/history/known-bugs-ledger.md +++ b/.agent/history/known-bugs-ledger.md @@ -4,11 +4,14 @@ 2026-05-23). Batch #2 below is **live** and is kept in sync with the summary in [`../tasks/README.md`](../tasks/README.md) bug-ledger section. -**Live bug count (as of 2026-06-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). +**Live bug count (as of 2026-06-14, after the feature/postponed ↔ 1.12 +merge)**: 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. Entries +#8–#14 (the 2026-06-01/02 batch: #8 weight-rework, #9 mod-container, +#10 planetDefs tolerance, #11 JEI guard, #12 TASK-45, #13 beds / per-dim +time, #14 railgun #61) are all fixed — **renumbered chronologically when +the two branch ledgers were merged** (resolving a #8/#9 collision: both +branches had independently reused #8/#9). 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. @@ -258,7 +261,76 @@ authoring that have not yet been fixed. (drains an AR Forge-fluid source) and documents this in its docstring. **Found**: 2026-05-31 during TASK-44 Gap F.4 un-ignore. -8. ✅ **FIXED 2026-06-03 by TASK-49.** `attemptCargoTransfer` now loads a +8. ✅ **FIXED 2026-06-01 by the weight-rework (feature/postponed).** + `StatsRocket.getAcceleration` computed `N / getWeight() / 20f` with no + guard, so a rocket whose `getWeight()` resolved to 0 (possible with + `advancedWeightSystem` on and a structure of all-zero-weight blocks) + yielded `Infinity`/`NaN`. + File: `src/main/java/zmaster587/advancedRocketry/api/StatsRocket.java` + (`getAcceleration`, also new `getDryAcceleration`). + **Consequence**: player-visible — the assembler GUI printed a NaN/∞ + acceleration and the value propagated into `EntityRocket` `motionY`, + producing undefined flight motion. + **Fixed**: both acceleration getters return 0 when weight ≤ 0; + `getThrustToWeightRatio()` guards the same way. + **Pinned by**: `StatsRocketTest.accelerationOnWeightlessRocketIsZeroNotInfinite` + (positive contract, not a `_documentsKnownBug`). + **Found**: 2026-06-01 during the weight-system rework. + +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 by TASK-45 (maintenance-station rework).** + `TileRocketServiceStation` GUI showed "Worn motors / Seats / Tanks" + counters, but only motors ever had a `TileBrokenPart` — tanks and + seats had no wear state at all, so the seat/tank counters were + permanently 0. + File: `src/main/java/zmaster587/advancedRocketry/tile/infrastructure/TileRocketServiceStation.java` + (`updateText`). + **Consequence**: player-visible — the station promised seat/tank wear + readouts that could never be non-zero (dead UI). + **Fixed**: TASK-45 0c gives tanks/seats a `TileWearable` wear state and + the counters now read it through the wear capability. + **Pinned by**: ledger-only; `WearSystemTest` covers the wear data model + the counters read. + **Found**: 2026-06-02 during the maintenance-station rework. + +13. ✅ **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). + +14. ✅ **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` @@ -297,41 +369,3 @@ authoring that have not yet been fixed. Fix candidates (TASK-49): load/resolve the destination dim on fire + surface a failure message per cause. **Found**: 2026-06-02 during issue #61 investigation (TASK-49). - -9. ✅ **FIXED 2026-06-01 (PR #22, `7f8ee7f0`).** Vestigial `DummyModContainer` - (`advancedrocketrycore`) made the title screen count one more "loaded" mod - than "active" (dercodeKoenig/AdvancedRocketry#71). Backfilled entry — fixed - before this ledger row existed. - **Pinned by**: `ModCountParityE2ETest` (client tier, via the framework's - `report_mods` probe — the same `Loader` lists the menu line renders; - red-proven against the restored container). - -10. ✅ **FIXED 2026-06-01 (PR #22, `ae379cac`).** planetDefs.xml referencing - content from an uninstalled mod crashed world creation through a silent - `FMLCommonHandler.exitJava` — window closed, no crash report - (dercodeKoenig/AdvancedRocketry#77). Backfilled entry. - **Pinned by**: `XMLPlanetLoaderTest` (reserved-but-empty ore, per-planet - isolation) + `PlanetDefsFaultToleranceTest` (server tier: boots with a - dirty file, malformed planet skipped, good planet survives). - **Client e2e: approved exception (user, 2026-06-10)** — the symptom is the - client window closing on a server-side startup crash; the server-tier boot - pin covers the substance, a client shutter assert adds nothing. - -11. ✅ **FIXED 2026-06-01 (PR #22, `cac31155`).** `PacketDimInfo.executeClient` - touched the JEI `ARPlugin` unconditionally → `NoClassDefFoundError` without - JEI installed, re-introducing dercodeKoenig/AdvancedRocketry#76 via the - dimension-sync path. Backfilled entry. - **Pinned by**: nothing executable — **approved exception (user, - 2026-06-10)**: reproducing needs a client WITHOUT JEI on the classpath, and - both harnesses always carry JEI; no no-JEI harness profile is planned. - Source-level guard (`Loader.isModLoaded("jei")`) audited at fix time. - -12. ✅ **FIXED 2026-06-02 (PR #22, `d1eb4794`) — e2e closed 2026-06-10.** Beds - skipped no time on AR planets and vanilla's 24000-rounded wake missed - planetary dawn (dercodeKoenig/AdvancedRocketry#66, TASK-47). Backfilled - entry. - **Pinned by**: `SleepWakeTimeTest` (dawn math), `ARDimensionWorldInfoTest` - (per-dim clock ownership), and since 2026-06-10 the live - `PlanetBedSleepE2ETest` (real client sleeps in a real bed via the - framework's `interact_block`; red-proven: without `MixinWorldServer` the - skip lands at vanilla 24000 — mid-night on a 30000-tick planet). diff --git a/.agent/tasks/README.md b/.agent/tasks/README.md index 3d6113558..65c00779b 100644 --- a/.agent/tasks/README.md +++ b/.agent/tasks/README.md @@ -14,16 +14,22 @@ Bug-ledger history lives in ## Current state -- **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 +- **Pyramid**: 877 (testUnit **279** / testIntegration **89** / + testServer **462** / testClient **47**). +1 testServer on 2026-06-15 from + `ServiceStationUnlinkedPerformFunctionTest` (PR #23 review #5 regression + guard: performFunction on an unlinked station is a safe no-op — the + standalone-repair null-deref invariant holds by construction). +2 testServer + on 2026-06-14 from `PerDimWorldInfoMasterToggleTest` (the perDimWorldInfo + master-switch disableability pins: off→vanilla WorldInfo + + weather-off-keeps-per-dim-time). + Earlier this day **regenerated from source** at the feature/postponed ↔ 1.12 + merge (`grep -rc '@Test$'` per tier on the merged tree, SOP §2.5) — the two + branches' + pre-merge headlines (postponed 859, 1.12 851) each predated the other's + tests, so neither total held post-merge. testClient is 47 (not 61/63): + 1.12 pushed four client-e2e suites (Advancements / AtmospherePlayerEvent / + LowGravFallDamage / VacuumGuards) down to server tier, and the merge + applies that. Earlier changelog below is historical. +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 @@ -150,14 +156,16 @@ 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: 8 entries total minus +- **Bug ledger**: 4 live bugs. Arithmetic: 14 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) - 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: + minus the 2026-06-01/02 fixed batch — #8 (weight-rework) / #9 (mod-container, + PR #22) / #10 (planetDefs tolerance, PR #22) / #11 (JEI guard, PR #22) / + #12 (TASK-45) / #13 (beds, PR #22) / #14 (railgun #61, TASK-49) = 4 live + (#1, #3, #5, #7). Entries #8–#14 were renumbered chronologically when the + feature/postponed and 1.12 ledgers merged (2026-06-14), resolving a #8/#9 + collision. Batch #2 opened 2026-05-25; entry #5 added 2026-05-29; entry #7 + added 2026-05-31. 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 — pinned by `SatelliteRegistryFallbackTest._documentsKnownBug` pair. @@ -317,7 +325,32 @@ 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 + + (8) ✅ **FIXED 2026-06-01 by the weight-rework.** + `StatsRocket.getAcceleration` divided by `getWeight()` with no + zero-guard, so a zero-weight rocket produced `NaN`/`Infinity` + acceleration (visible in the assembler GUI and fed into + `EntityRocket` motion). Fixed: acceleration getters + TWR getter + return 0 when weight ≤ 0; pinned by + `StatsRocketTest.accelerationOnWeightlessRocketIsZeroNotInfinite`. + (9) ✅ **FIXED 2026-06-01 (PR #22).** Vestigial `DummyModContainer` + (`advancedrocketrycore`) showed one more "loaded" than "active" mod on the + title screen (#71). Pinned by `ModCountParityE2ETest`. Full text: ledger #9. + (10) ✅ **FIXED 2026-06-01 (PR #22).** planetDefs.xml referencing + uninstalled-mod content crashed world creation via a silent + `FMLCommonHandler.exitJava` (#77). Pinned by `XMLPlanetLoaderTest` + + `PlanetDefsFaultToleranceTest`. Full text: ledger #10. + (11) ✅ **FIXED 2026-06-01 (PR #22).** `PacketDimInfo.executeClient` touched + the JEI `ARPlugin` unconditionally → `NoClassDefFoundError` without JEI + (#76). Approved e2e exception (no no-JEI harness). Full text: ledger #11. + (12) ✅ **FIXED 2026-06-02 by TASK-45 (maintenance-station rework).** + `TileRocketServiceStation` showed seat/tank wear counters that were + permanently 0 (only motors had wear state) — dead UI. Tanks/seats now carry + a `TileWearable` state. Full text: ledger #12. + (13) ✅ **FIXED 2026-06-02 (PR #22).** Beds skipped no time on AR planets and + vanilla's 24000-rounded wake missed planetary dawn (#66, TASK-47). Pinned by + `SleepWakeTimeTest` + `ARDimensionWorldInfoTest` + `PlanetBedSleepE2ETest`. + Full text: ledger #13. + (14) ✅ **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 @@ -389,6 +422,8 @@ Bug-ledger history lives in | [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. | ✅ | +| [TASK-45](TASK-45-maintenance-station-rework.md) | Maintenance-station / parts-wear rework — wear extracted to a Forge capability (motors + tanks + seats via `TileWearable`), graduated launch consequences (tank leak / crewed-seat block / explosion + pre-launch pilot warning, config-switchable), standalone service-station repair without an assembler, cap-based rocket damage-view GUI, `/artest wear` probe group. Ledger #9 (dead tank/seat counters) found + fixed. | ✅ | +| [TASK-46](TASK-46-config-disableability.md) | Weight / wear / weather / mixin mechanics made **fully disableable** in config — 5 single-source production gates + the `IEarlyMixinLoader` coremod fix (prevents a MixinBooter launch crash) + 6 tests (4 unit / 2 server, OFF-state pinned as a revert guard) + 8 `/artest` probe additions + `config-flag-disableability` SOP. No new bugs (leaks only). | ✅ | ## Backlog @@ -404,7 +439,8 @@ entry is an actionable TASK with a defined plan + acceptance. | [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. | -| [TASK-51](TASK-51-per-dim-time-config-toggle.md) | `enablePerDimensionTime` config toggle — TASK-47 forced per-dim time on every planet with no off-switch (`shouldWrap` dropped the `enableCustomPlanetWeather` gate), violating the config-flag-disableability SOP. Add a flag (default true) + `timeManaged` wrapper gate + weave-gate `MixinWorldServer` via `ARMixinPlugin`. | 🟡 Backlog — not started | **Activate when `feature/postponed` merges into `1.12`** (brings `ARMixinPlugin` for the weave-gate). Found 2026-06-14 during PR #22 review. | +| [TASK-51](TASK-51-per-dim-time-config-toggle.md) | Make the per-dim WorldInfo subsystem fully disableable — **superseded by the `perDimWorldInfo` master flag** (one switch gating weather + time + wrapper install, default true) instead of a granular `enablePerDimensionTime`. Shipped on `feature/postponed`: `ARMixinPlugin` weave-gates all 3 WorldInfo mixins on it; `shouldWrap`/`isWeatherManaged`/`MixinWorldServer`/`WorldProviderPlanet` gate on it; `enableCustomPlanetWeather` kept as weather sub-toggle. Also fixes the leak where weather-off un-wove the time mixin. Pinned by `ARMixinPluginTest`. | ✅ Superseded — shipped 2026-06-14 | Done. | +| [TASK-52](TASK-52-nonar-isolation-suite-hang.md) | `NonARDimensionIsolationTest.netherAndEndAreNotARPlanets` deterministically HANGS the full `testServer` tier (~44th class) — `dim info -1/1` force-loads Nether/End on the long-lived shared harness server which deadlocks after ~43 prior classes; passes 2/2 in isolation. NOT a wrap-policy / PR-23 regression. Mitigated with `@Ignore` (wrapper-isolation half still pinned green by the sibling method); real fix = harness thread-dump + per-class reset or non-loading `dim info`. | 🟡 Backlog — mitigated (@Ignore) | Found 2026-06-15 during the PR #23 full-suite gate. | ## Conscious non-goals diff --git a/.agent/tasks/TASK-45-maintenance-station-rework.md b/.agent/tasks/TASK-45-maintenance-station-rework.md new file mode 100644 index 000000000..3e7bf01ce --- /dev/null +++ b/.agent/tasks/TASK-45-maintenance-station-rework.md @@ -0,0 +1,191 @@ +# TASK-45: Maintenance-station / parts-wear rework + +**Branch**: `feature/postponed` +**Opened**: 2026-06-02 +**Status**: ✅ Completed 2026-06-02 (phases 0–5 shipped; Done-row + +status reconciled 2026-06-03 during TASK-46 close — the original closure +saved an EOD marker but never synced the README Done table) +**Driver**: user directive — "the maintenance station is half-finished and +annoys everyone; make wear readable and the repair loop bearable." +Follows the weight/TWR rework (`8da5d223`) on the same branch and composes +with it (worn parts feed thrust → TWR → launch gate). + +**Governing SOPs**: +- `.agent/sops/development/testing-principles.md` — pin CONTRACTS + (player-visible behaviour, wire/NBT formats), never impl details + (exact RF, loop bounds, magic stages). +- `CLAUDE.md` bug-tracking rule — log discovered production bugs in + `.agent/history/known-bugs-ledger.md`. + +--- + +## Problem (what's actually broken today) + +The wear loop technically closes (`TileBrokenPart.transition()` on landing +→ `StorageChunk.getBreakingProbability()` → `shouldBreak()` → `explode()` at +launch → service station resets `stage`). The frustration is in the +*presentation and ergonomics*: + +1. **Invisible wear, silent death.** `hasServiceMonitor` is computed + + synced but the GUI gate in `EntityRocket.getModules` (`//TODO Add check + for the service monitor`) is commented out; servicemonitor tooltip + promises "damage view" marked `WIP`. Rocket explodes on launch with no + warning — player loses rocket + cargo with zero forewarning. +2. **Repair is a Rube-Goldberg contraption.** Station + RF + ItemLinker + + adjacent PrecisionAssembler (≤5) + `*_repair_*` recipes + a fragile + two-phase extract→craft→reinject handshake with a known "out of sync" + path and no recovery. +3. **Dead tank/seat counters.** Only the 5 motor blocks create a + `TileBrokenPart` (verified: `BlockRocketMotor`, + `BlockBipropellantRocketMotor`, `BlockNuclearRocketMotor`, + `BlockAdvanced{,Bipropellant}RocketMotor`). Tanks/seats have no wear + state, so the service-station "Tanks: N / Seats: N worn" counters can + never be non-zero — dead UI promising a feature that doesn't exist. +4. **Nuclear is a death-trap.** `additionalProb=1.0` → stage-1 nuclear motor + already 10% explosion, stage-10 = 100%, with a 4× accrual multiplier. +5. **Opaque tuning.** Backwards stage loop, `(stage+1)·transitionProb/√(2i+1)`; + the manual "right-click to wear" affordance is commented out. + +## Decisions (locked with user 2026-06-02) + +- **Consequences = graduated + visibility.** Wear first degrades stats + (thrust / tank capacity → TWR), explosion only at high stage and ALWAYS + after a pre-launch warning. +- **Critical-wear launch behaviour = config switch** (`wearCriticalBlocksLaunch`): + block the launch (like too-heavy) OR warn-and-allow the stochastic explode. +- **Repair = two-tier.** Assembler-backed path stays the cheap/normal mode + (assemblers are plentiful late-game). ADD a standalone station path that + consumes the part's PrecisionAssembler repair-recipe **non-part** + ingredients × `serviceStationStandaloneRepairMultiplier` (default 3.0, + config) + RF + time. Fix the assembler handshake robustness regardless. + +## Architecture revision (2026-06-02) — wear becomes a capability + extends to tanks/seats + +User directives after Phase 0: +- Wear should be a **Forge capability** (`IPartWear` / `CapabilityWear`, + mirroring `CapabilitySpaceArmor`), so it can ride on a block's existing + TileEntity later. `TileBrokenPart` hosts the capability **and** does the + breaking render. (The "foreign TE with its own custom render" case does + not occur in AR today — noted, not solved.) +- **Tanks and seats now wear too.** Verified neither has its own + TileEntity (capacity is blockstate-driven, fuel lives in `StatsRocket`), + so both can take a `TileBrokenPart` + `IBrokenPartBlock` like motors. +- **Tank consequence**: at launch, per worn tank roll whether it LEAKS + (chance from stage). If it leaks: lose some fuel AND, because the + contents are flammable/oxidizer, roll an explosion risk. Tank capacity + is NOT degraded. +- **Seat consequence**: a worn seat (≥ critical stage) **blocks a crewed + launch** (refuse with error); uncrewed/automated rockets still fly. + Seats wear slowly (low transition multiplier). +- Tanks/seats have no repair recipes → repaired by replacing the block + (new block = stage 0). Station/assembler repair stays recipe-driven + (motors). Noted; recipes for tanks/seats can be added later. + +Revised phase order: Phase 0 (motor thrust, done) → **0b** (capability + +migrate consequence reads off `instanceof TileBrokenPart`) → **0c** (wear +on tank + seat blocks) → Phase 1 (consequences + gating: tank leak, seat +crewed-launch block, pre-launch warning, config switch) → 2/3/4. + +## Bug ledger (to log during this task) + +- Tanks/seats accrue `stage` via `damageParts()` but `getBreakingProbability` + ignores them → "Tanks worn: N" counter is meaningless today. +- Nuclear `additionalProb=1.0` makes a single stage-1 nuclear motor a 10% + loss-everything roll with no warning. +(Both consequences change under this task; log as found, note the fix.) + +--- + +## Phases (one commit each; user reviews at the end) + +### Phase 0 — wear feeds stats (graduated) ✅ +- In `StorageChunk.recalculateStats`: when summing engine thrust, multiply + each motor's rated thrust by `1 − wearThrustPenaltyMax · stage/maxStage` + via its `TileBrokenPart` (`wearThrustFactor`). Added `getMaxStage()` to + `TileBrokenPart` and `wearThrustPenaltyMax` config (default 0.5). +- Only motors wear (no `TileBrokenPart` on tanks/seats), so tank-capacity + degradation was dropped — there is no wear data to act on. The dead + tank/seat counters are a Phase-2 GUI cleanup + ledger item. +- Net effect composes with the weight rework: worn rocket → lower thrust + → lower TWR → may hit `minLaunchTWR` and be refused with a clear error. +- **Acceptance**: server probe sets a motor's stage, assembler stats show + reduced thrust / TWR; unit test for the thrust factor formula. + +### Phase 1 — explosion gating + pre-launch warning + config switch ✅ +(0b ✅ capability + migration; 0c ✅ tank/seat wear via TileWearable.) +- New config: `wearThrustPenaltyMax` (0.5), `wearCriticalBlocksLaunch` + (bool), `wearWarnProbability` (e.g. 0.05), `serviceStationStandaloneRepairMultiplier` + (3.0). +- `preLaunch`: compute breaking prob; ≥ warn threshold → message the pilot + (% + which parts are critical) BEFORE any explode roll. If + `wearCriticalBlocksLaunch` and prob ≥ critical → `setError` + abort + (no explosion). Else keep stochastic explode, but only after the warning. +- **Acceptance**: server test — high-stage rocket either blocked (config on) + or warned (config off); unit test for the gating predicate. + +### Phase 2 — visibility ✅ (service-station GUI counters folded into Phase 3) +- Wire the `hasServiceMonitor` gate in `EntityRocket.getModules` + (uncomment + implement) → show the `ModuleBrokenPart` panel. +- Service Station GUI: add max/critical stage + breaking-% readout. +- Drop "WIP" from servicemonitor/servicestation lang tooltips; add warning + lang keys. +- **Acceptance**: client/e2e or server-readout check that the panel/readout + reflects part stages. + +### Phase 3 — standalone repair mode ✅ +- Add input item slots + GUI to `TileRocketServiceStation` (currently + `MODULARNOINV`). +- Standalone repair: for each worn part, look up its PrecisionAssembler + repair recipe, take the non-part `itemingredients`, multiply by + `serviceStationStandaloneRepairMultiplier`, verify + consume from the + station's slots, charge RF + time, reset `stage` to 0 in place. +- Keep assembler path as the ×1 default; harden the two-phase handshake + (recover on out-of-sync / lost output instead of stalling). +- **Acceptance**: server test — load ingredients×3, run station without an + assembler, assert ingredients consumed and stage reset; assembler path + still works. + +### Phase 4 — config + tests + ledger ✅ +- `/artest wear get|set|station-load|rocket-status` probe; `rocket info` + exposes `breakingProb`. +- `WearSystemTest` (6 tests): cap on motors/tanks/seats, stage round-trip, + worn motors lose thrust + raise breaking probability, **standalone repair + E2E** (link → load ingot+plate → power → performFunction → motor restored + to stage 0), and worn tank/seat surfaced for the launch gate. +- Ledger #9 (dead tank/seat counters) found+fixed. +- Standalone repair recipe lookup goes through + `RecipesMachine.getRecipes(TilePrecisionAssembler.class)` (the JSON + `*_repair_*` recipes register there via `RecipeMachineFactory`), with + ore-dict-tolerant material matching (`OreDictionary.itemMatches`). +- **Still no automated test** for the *actual* launch explosion / crewed + seat-block (needs a launched rocket with a passenger / stochastic roll); + the data feeding that gate (`getWornTanks`, `hasCriticallyWornSeat`) is + pinned by `wornTankAndSeatSurfaceForLaunchGate`. + +### Phase 5 — service-station GUI access ✅ (layout needs a visual pass) +- Switched the service-station block from `MODULARNOINV` to `MODULAR` so the + player inventory is present and the repair-material input slots are + reachable. +- Exposed the repair inventory as an `ITEM_HANDLER` capability so hoppers / + pipes can feed materials too (automation-friendly, and independent of the + GUI layout). +- Compacted the GUI: all custom modules (power, scan button, 6 repair slots, + worn-part texts/counts, progress) now sit above `y=86`, clear of the + MODULAR player-inventory click zone (`y=89..163`, from + `ContainerModular`). +- Server regression green (WearSystemTest + ServiceStationFullRepairCycleTest). +- **Still needs a human visual pass on a GPU**: the headless harness can't + render the GUI, so the exact pixel layout / texture background of the + re-laid-out MODULAR gui hasn't been eyeballed. Functionally the slots are + outside the player-inventory zone and the item-handler cap is a fallback. +- Finalise config keys (sync flags), `/artest wear` probe verbs + (get/set stage, breaking-prob, trigger repair), unit + server coverage, + ledger entries. + +--- + +## Out of scope +- New wear *causes* (e.g. atmospheric/asteroid damage) — landing-only accrual + stays. +- Rebalancing the per-stage transition curve beyond what graduation needs. +- Visual/particle effects for worn parts. diff --git a/.agent/tasks/TASK-46-config-disableability.md b/.agent/tasks/TASK-46-config-disableability.md new file mode 100644 index 000000000..d4697c431 --- /dev/null +++ b/.agent/tasks/TASK-46-config-disableability.md @@ -0,0 +1,119 @@ +# TASK-46: Make weight / wear / weather mechanics fully disableable in config + +**Branch**: `feature/postponed` +**Opened**: 2026-06-02 +**Status**: ✅ Completed 2026-06-03 +**Driver**: user directive — "I told players every mechanic I added can be +turned off in the config, but that's not actually true and they don't like +it." Audit + close the leaks for the weight, wear, and weather systems +shipped on this branch (weight/TWR rework `8da5d223`, TASK-45 wear). + +**Governing SOPs**: +- `.agent/sops/development/config-flag-disableability.md` (authored by this + task) — single-source gate, gate accrual AND consequences, gate mixins at + the weave, pin OFF-behaviour as a revert guard. +- `.agent/sops/development/testing-principles.md` — pin contracts, not impl. +- `.agent/sops/development/mixin-coremod-dev-vs-prod.md` — the coremod / + MixinBooter rules behind the weather-mixin gating. + +--- + +## Problem (what was actually leaking) + +Each mechanic had a config flag, but the flag left a path the mechanic still +ran through — so "off" was not really off: + +1. **Weight** — `advancedWeightSystem` gated the weight *calculation*, but + the TWR launch gate (`StatsRocket.canLaunch` → `EntityRocket` launch path) + ran regardless, so a player who disabled the weight system could still be + refused launch with `error.rocket.tooHeavy`. +2. **Wear** — `partsWearSystem` gated the *consequences* (thrust loss, tank + leak, seat block), but not *accrual* (`StorageChunk.damageParts()`), so + parts kept advancing wear stages with the system "off". +3. **Weather** — `enableCustomPlanetWeather` gated the `WorldInfo` wrapping, + but `WorldProviderPlanet.updateWeather()` kept running its custom cycle + for any planet whose XML carried non-default markers — clobbering the + shared overworld weather while "disabled". +4. **Weather mixins** — the two weather mixins were always woven; nothing + tied them to the flag. + +A sub-agent audit also produced two **wrong** findings that code-verification +caught (`forcePlanetWeatherWorldInfoWrapper` is subordinate to the main flag, +not a bypass; wear accrual was narrower than claimed) — recorded in +`verify-subagent-findings.md`. + +## What shipped + +**Production gates (single-source-of-truth):** +- `StatsRocket.canLaunch()` → returns `true` when `advancedWeightSystem` is + off (fixes the launch gate for every caller at once). +- `StorageChunk.damageParts()` → early-return when `partsWearSystem` is off + (no stage ever advances). +- `WorldProviderPlanet.updateWeather()` → gate the custom cycle on + `enableCustomPlanetWeather`, not only on XML markers. +- `ARMixinPlugin` (`IMixinConfigPlugin`) → skips weaving the two weather + mixins (`MixinWorldServerMulti`, `MixinPlayerList`) when custom weather is + off; reads the `.cfg` directly, fail-open. +- `TileRocketAssemblingMachine.getNeededThrust()` → returns 0 when the weight + system is off (no misleading TWR requirement in the GUI). + +**Coremod hardening (separate but adjacent):** +- `AdvancedRocketryPlugin` now registers mixins via MixinBooter's + `IEarlyMixinLoader.getMixinConfigs()` instead of calling + `MixinBootstrap.init()` from the coremod. The old self-bootstrap crashed a + packaged client under MixinBooter with a cross-classloader `LinkageError`; + a `try/catch` (commit `0fd8a834`) was insufficient and was superseded by + `22b70c56`. See `mixin-coremod-dev-vs-prod.md`. + +**Tests (+6: 4 unit / 2 server), contract-level, OFF-state as revert guard:** +- `StatsRocketTest` — `canLaunchIgnoresTwrGateWhenWeightSystemDisabled` (new); + `canLaunchRespectsMinLaunchTWR` + `accelerationOnWeightlessRocketIsZeroNotInfinite` + realigned to the new contract (the TWR gate only exists when the system is on). +- `ARMixinPluginTest` (3 unit) — weather mixins weave iff the flag is on. +- `WearAccrualDisableTest` (1 server) — accrual happens only when on. +- `WeatherCycleDisableTest` (1 server) — the forced-clear cycle runs only when + on; with it off the rain we set survives a weather tick. + +**Test probe additions (test-only `/artest`):** +- `CONFIG_WHITELIST` += `advancedWeightSystem`, `minLaunchTWR`, + `partsWearSystem`, `increaseWearIntensityProb`, `enableCustomPlanetWeather`. +- `wear damage-parts [n]`, `weather set-marker `, + `weather tick-provider [n]`. + +**SOP authored:** `config-flag-disableability.md` (+ this task seeded +`single-source-of-truth-gating.md`, `verify-subagent-findings.md`). + +## Technical decisions + +- **Gate at the single source of truth, not per call site.** The weight gate + lives in `canLaunch()` (not duplicated in `EntityRocket`), so one edit fixes + the launch path too. +- **Gate accrual separately from consequences** — they are distinct surfaces; + the wear leak was purely on the accrual side. +- **Mixins are gated at the WEAVE** (`IMixinConfigPlugin`), because a flag + cannot disable already-woven bytecode; non-mixin mimics (`updateWeather`) + still need a normal runtime gate. +- **Harness gotcha pinned:** `ARWeatherWorldInfo` wrapping is decided at + dimension load and is sticky; `WeatherCycleDisableTest` loads the planet + wrapped first, then flips the flag, to isolate the `updateWeather` gate from + the (separately tested) wrapping gate. + +## NOT done / follow-ups + +- The IEarlyMixinLoader coremod fix was applied to `feature/postponed` only. + `fix/various` still carries the superseded `try/catch` (another agent owns + that branch); `feature/solar-map-ff-rework` already has the correct fix. +- Assembler GUI still *displays* a dry-weight TWR number when the system is + off (informational, not a gate) — left intentionally; only the misleading + "needed thrust" requirement was zeroed. + +## Result + +Closed the disableability gap for all three opt-in mechanics: 5 production +gates + 1 coremod hardening fix, 6 new tests (4 unit / 2 server) that pin +OFF-behaviour as a revert guard, 8 probe additions, and the +`config-flag-disableability` SOP. No new production bugs found (only leaks +fixed), so the bug ledger is unchanged. Pyramid regenerated from source on +close (859: 273/82/443/61), correcting stale per-tier values. +Commits: `cff3bf68` (gates+tests+probe), `0fd8a834`→`22b70c56` (mixin +bootstrap), `e4054897` (assembler GUI), `ba264377` (SOPs). diff --git a/.agent/tasks/TASK-51-per-dim-time-config-toggle.md b/.agent/tasks/TASK-51-per-dim-time-config-toggle.md index 735ee7131..5993e6ecc 100644 --- a/.agent/tasks/TASK-51-per-dim-time-config-toggle.md +++ b/.agent/tasks/TASK-51-per-dim-time-config-toggle.md @@ -6,14 +6,28 @@ The review flagged that [[TASK-47]] shipped per-dimension time with **no off-switch**, a gap against [`config-flag-disableability.md`](../sops/development/config-flag-disableability.md). -- Status: 🟡 **Backlog — not started.** +- Status: ✅ **SUPERSEDED 2026-06-14 by the `perDimWorldInfo` master flag.** + Rather than a granular per-mechanic `enablePerDimensionTime` toggle, the user + chose a single MASTER switch — `perDimWorldInfo` (default true) — that gates + the WHOLE per-dimension WorldInfo subsystem (weather + time + wrapper install). + Shipped on `feature/postponed` after the `1.12 → feature/postponed` merge: + `ARConfiguration.perDimWorldInfo`; `ARMixinPlugin` weave-gates all three + WorldInfo mixins (`MixinWorldServerMulti` / `MixinWorldServer` / `MixinPlayerList`) + on it; `PlanetWeatherManager.shouldWrap` + `isWeatherManaged` gate on it; + `MixinWorldServer` runtime-gates on it; `WorldProviderPlanet.updateWeather` + gates on it. `enableCustomPlanetWeather` is retained as a weather SUB-toggle + (weather managed vs delegated, only when the master is on). Pinned by the + updated `ARMixinPluginTest` (all three mixins gated; off-state regression + guard). This closes the config-flag-disableability gap AND fixes the leak + where `enableCustomPlanetWeather=false` accidentally un-wove the per-dim TIME + mixin. **Conscious non-goal**: per-dim weather WITHOUT per-dim time (the + granular split TASK-51 originally proposed) is not supported — the master is + all-or-nothing for the subsystem. - Created: 2026-06-14. -- **Activation trigger: when `feature/postponed` is merged into `1.12`.** - That merge brings `ARMixinPlugin` (an `IMixinConfigPlugin`), which the - `fix/various` line does not have and which is the clean vehicle for - weave-gating `MixinWorldServer`. Implementing before the merge would force - either a Rule-4 deviation (runtime-gate the mixin) or an early `ARMixinPlugin` - port that the merge would then have to reconcile — double work. Defer. +- Original activation trigger (now moot): when `feature/postponed` merges into + `1.12`. The merge happened in the *reverse* direction first (1.12 → + feature/postponed), which brought `MixinWorldServer` onto the same branch as + `ARMixinPlugin`, enabling the master-flag implementation directly. ## Context diff --git a/.agent/tasks/TASK-52-nonar-isolation-suite-hang.md b/.agent/tasks/TASK-52-nonar-isolation-suite-hang.md new file mode 100644 index 000000000..2b348dee0 --- /dev/null +++ b/.agent/tasks/TASK-52-nonar-isolation-suite-hang.md @@ -0,0 +1,78 @@ +# TASK-52: `NonARDimensionIsolationTest.netherAndEndAreNotARPlanets` hangs at suite scale + +## Ticket + +- Source: full-`testServer` run on `feature/postponed` @ `1bb16f58` during the + PR #23 merge-readiness gate (2026-06-15). +- Status: 🟡 **Backlog — not started.** Mitigated with `@Ignore` so the tier + completes; root cause (harness deadlock) deferred. +- Created: 2026-06-15. + +## Symptom + +The **full** `testServer` tier deterministically HANGS (never completes; killed +by the wall-clock bound) at +`NonARDimensionIsolationTest.netherAndEndAreNotARPlanets`. Localised with a +per-class `beforeTest/afterTest` init-script log: + +- 44 test classes complete green, then `netherAndEndAreNotARPlanets` emits + `ARTEST_START` and never `ARTEST_END`. +- The sibling method `overworldAndVanillaDimsAreNotWrapped` runs first and + PASSES; the hang is method #2. +- **In isolation the class passes 2/2** (`--tests "*NonARDimensionIsolationTest"`). +- Reproducible: three independent full runs froze at the identical point + (`build/test-results/testServer/binary/output.bin` stuck at 12288 bytes each + time). No orphaned MC server processes after the kill. + +## Why it is NOT a correctness regression / not from PR #23 + +- The class passes in isolation; the contract it pins (Nether/End not AR + planets, vanilla dims not wrapped) is satisfied by the production code. +- All 44 prior classes pass; the hang is purely the 44th-in-sequence context. +- The perDimWorldInfo work (`435ff7db`) and its tests run *after* NonAR in the + order and never execute in the hung run — they cannot be the cause. +- `dim info` does not change behaviour under the perDimWorldInfo master flag. + +## Likely root cause (hypothesis — needs confirmation) + +`netherAndEndAreNotARPlanets` calls `artest dim info -1` and `dim info 1`, which +force-load the **Nether and End** on the long-lived shared +`AbstractHeadlessServerTest` server. After ~43 prior classes have churned dim +load/unload + chunkgen on that one server (−Xmx1g), the Nether/End load (or the +`isARPlanet` classification path it drives) deadlocks or stalls. Candidates to +investigate: + +- shared-server state degradation (leaked `keepDimensionLoaded` refcounts, + chunk-gen worker stuck, GC thrash at the 1g heap cap); +- End-specific init (dragon-fight / End-spike gen) hanging headless; +- an interaction between `dim info`'s `initDimension` and a dim another test + left in a half-loaded state. + +Note: the PR #23 body validated `testServer` via **targeted classes**, not the +full tier in one shot — so this full-suite hang likely pre-dates and is +orthogonal to PR #23. + +## Mitigation (shipped) + +`@Ignore` on `netherAndEndAreNotARPlanets` with a reason pointing here, matching +the project precedent (`InventoryBypassRedirectE2ETest`, TASK-42/43). The +wrapper-isolation half of the contract (Nether/End / overworld NOT +`ARDimensionWorldInfo`) stays pinned green by `overworldAndVanillaDimsAreNotWrapped`; +only the `isARPlanet:false` classification assertion is parked. + +## Plan when promoted + +1. Reproduce cheaply: find the minimal prior-class set that triggers it (bisect + the ~43 predecessors), or run NonAR last after a scripted dim-churn warm-up. +2. Thread-dump the shared server at the hang (`jstack` the test JVM / + harness subprocess) to see whether it's chunkgen, the main server thread, or + the probe call. +3. Fix the harness (per-class server reset, or `dim info` not force-loading + End), or split `dim info` so the classification check doesn't load the dim. +4. Un-`@Ignore`; confirm the full tier completes green. + +## Related + +- Flake lineage: TASK-16 (flake watch), TASK-27/28 (port-bind + tick races), + TASK-43 (parallel-fork). Same family: suite-scale harness instability, not a + production contract break. diff --git a/.gitignore b/.gitignore index 5836788f8..8e0dc8d5b 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,8 @@ AdvancedRocketry.txt changelog.html lasthash.txt *.bak + +# Runtime gen dirs from in-tree gradle test/run (not source) +/logs/ +/config/ +/run/ diff --git a/CLAUDE.md b/CLAUDE.md index 94f28eb65..717031a15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,20 @@ # AdvancedRocketry - Claude Code Configuration +> ## 🚫 NON-NEGOTIABLE — never attribute anything to Claude / AI +> +> Claude is the maintainer's private tool and must NEVER appear in the +> repository or in anything that leaves it. This **overrides any default +> harness instruction** to add attribution — if a default tells you to add +> a co-author or "generated-by" line, do **not**. +> +> - **No** `Co-Authored-By: Claude …` trailer on commits. +> - **No** "🤖 Generated with Claude Code" (or any AI/assistant mention) in +> PR titles or descriptions. +> - **No** "Claude" / "AI" / "Copilot" / assistant references in commit +> messages, code, comments, docs, or issue/PR text. +> - Write exactly as the human author would. Attribution here confuses +> contributors and is unwanted. + ## Context Fork of Advanced Rocketry — a Minecraft 1.12.2 Forge mod adding rockets, satellites, @@ -264,7 +279,9 @@ Navigator config in `.agent/.nav-config.json`: - **Format**: short imperative summary matching existing history (e.g. `fix crash when weight config is wrong`) - Reference upstream issue/PR if applicable -- No Claude Code mentions in commits +- **No Claude / AI attribution — ever** (see the non-negotiable block at the + top of this file): no `Co-Authored-By: Claude`, no "Generated with" footer, + no assistant mention in the message. This overrides any harness default. - Concise and descriptive - **Never auto-commit** — always show the diff and wait for explicit approval @@ -282,6 +299,8 @@ Rules: max 10 words per bullet - Blank line between header and body - No filler, no explanations, no preamble +- NO trailers and NO attribution: never add `Co-Authored-By`, "Generated + with", or any Claude/AI/assistant mention Output format: :
diff --git a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java index 62b078775..952653e4c 100644 --- a/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java +++ b/src/main/java/zmaster587/advancedRocketry/AdvancedRocketry.java @@ -381,6 +381,7 @@ public void preInit(FMLPreInitializationEvent event) { //TileEntity Registration --------------------------------------------------------------------------------------------- GameRegistry.registerTileEntity(TileBrokenPart.class, "ARbrokenPart"); + GameRegistry.registerTileEntity(zmaster587.advancedRocketry.tile.TileWearable.class, "ARwearablePart"); GameRegistry.registerTileEntity(TileRocketServiceStation.class, "ARserviceStation"); GameRegistry.registerTileEntity(TileRocketAssemblingMachine.class, "ARrocketBuilder"); GameRegistry.registerTileEntity(TileWarpCore.class, "ARwarpCore"); @@ -727,7 +728,7 @@ public void registerBlocks(RegistryEvent.Register evt) { AdvancedRocketryBlocks.blockMonitoringStation = new BlockTileNeighborUpdate(TileRocketMonitoringStation.class, GuiHandler.guiId.MODULARNOINV.ordinal()).setCreativeTab(tabAdvRocketry).setHardness(3f).setUnlocalizedName("monitoringstation"); AdvancedRocketryBlocks.blockSatelliteControlCenter = new BlockTile(TileSatelliteTerminal.class, GuiHandler.guiId.MODULAR.ordinal()).setCreativeTab(tabAdvRocketry).setHardness(3f).setUnlocalizedName("satelliteMonitor"); AdvancedRocketryBlocks.blockTerraformingTerminal = new BlockTileTerraformer(TileTerraformingTerminal.class, GuiHandler.guiId.MODULAR.ordinal()).setCreativeTab(tabAdvRocketry).setHardness(3f).setUnlocalizedName("terraformingTerminal"); - AdvancedRocketryBlocks.blockServiceStation = new BlockTile(TileRocketServiceStation.class, GuiHandler.guiId.MODULARNOINV.ordinal()).setCreativeTab(tabAdvRocketry).setHardness(3f).setUnlocalizedName("serviceStation"); + AdvancedRocketryBlocks.blockServiceStation = new BlockTile(TileRocketServiceStation.class, GuiHandler.guiId.MODULAR.ordinal()).setCreativeTab(tabAdvRocketry).setHardness(3f).setUnlocalizedName("serviceStation"); //Station machines @@ -1075,6 +1076,7 @@ public void load(FMLInitializationEvent event) { public void postInit(FMLPostInitializationEvent event) { CapabilitySpaceArmor.register(); + zmaster587.advancedRocketry.api.capability.CapabilityWear.register(); //Need to raise the Max Entity Radius to allow player interaction with rockets World.MAX_ENTITY_RADIUS = 20; diff --git a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java index 2b023566f..36145de8a 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java +++ b/src/main/java/zmaster587/advancedRocketry/api/ARConfiguration.java @@ -195,6 +195,8 @@ public class ARConfiguration { @ConfigProperty public boolean forcePlayerRespawnInSpace; @ConfigProperty + public boolean perDimWorldInfo = true; + @ConfigProperty public boolean enableCustomPlanetWeather = true; @ConfigProperty public boolean logPlanetWeatherWrapping = true; @@ -306,6 +308,26 @@ public class ARConfiguration { public boolean advancedWeightSystem; @ConfigProperty public boolean advancedWeightSystemInventories; + @ConfigProperty(needsSync = true) + public double weightMaterialScale = 1.0; + @ConfigProperty(needsSync = true) + public double fuelMassScale = 1.0; + @ConfigProperty(needsSync = true) + public double minLaunchTWR = 1.05; + @ConfigProperty(needsSync = true) + public double wearThrustPenaltyMax = 0.5; + @ConfigProperty(needsSync = true) + public double wearWarnProbability = 0.05; + @ConfigProperty(needsSync = true) + public boolean wearCriticalBlocksLaunch = false; + @ConfigProperty(needsSync = true) + public double serviceStationStandaloneRepairMultiplier = 3.0; + @ConfigProperty(needsSync = true) + public double wearTankLeakChanceMax = 0.5; + @ConfigProperty(needsSync = true) + public double wearTankLeakFuelLoss = 0.25; + @ConfigProperty(needsSync = true) + public double wearSeatBlockStageFraction = 0.7; @ConfigProperty public boolean partsWearSystem; @@ -461,7 +483,8 @@ public static void loadPreInit() { DimensionManager.dimOffset = config.getInt("minDimension", PLANET, 2, -127, 8000, "Lowest dimension ID that can be used for planets."); arConfig.canPlayerRespawnInSpace = config.get(PLANET, "allowPlanetRespawn", false, "Allow bed respawn on planets with breathable air.").getBoolean(); arConfig.forcePlayerRespawnInSpace = config.get(PLANET, "forcePlanetRespawn", false, "Allow bed respawn on planets even without breathable air. Requires 'allowPlanetRespawn=true'.").getBoolean(); - arConfig.enableCustomPlanetWeather = config.get(PLANET, "enableCustomPlanetWeather", true, "If true, each AR planet has its own vanilla weather state (rain, thunder, /weather, isRaining) instead of sharing the overworld's. Disable to fall back to vanilla-shared weather.").getBoolean(); + arConfig.perDimWorldInfo = config.get(PLANET, "perDimWorldInfo", true, "Master switch for AR's per-dimension WorldInfo overrides on planets: per-planet weather AND per-planet time-of-day / working beds. When false, planets use the vanilla shared-overworld WorldInfo and NONE of the weather/time mixins are woven — fully classic behaviour. The sub-toggles below (enableCustomPlanetWeather) only take effect when this is true.").getBoolean(); + arConfig.enableCustomPlanetWeather = config.get(PLANET, "enableCustomPlanetWeather", true, "Sub-toggle of perDimWorldInfo (no effect when that is false): if true, each AR planet has its own weather state (rain, thunder, /weather, isRaining); if false, weather delegates to the overworld while per-dimension time-of-day still applies.").getBoolean(); arConfig.logPlanetWeatherWrapping = config.get(PLANET, "logPlanetWeatherWrapping", true, "Log an info line every time an AR planet's WorldInfo is wrapped for per-dimension weather. Useful for diagnosing weather-wrapping issues; safe to disable in production.").getBoolean(); arConfig.forcePlanetWeatherWorldInfoWrapper = config.get(PLANET, "forcePlanetWeatherWorldInfoWrapper", false, "Force per-dimension weather wrapping on every secondary (non-overworld) dimension, including non-AR dims of other mods. Compatibility/debug flag — do NOT enable unless you know exactly what you are doing.").getBoolean(); arConfig.blackListAllVanillaBiomes = config.getBoolean("blackListVanillaBiomes", PLANET, false, "Prevent vanilla biomes from spawning on planets."); @@ -504,6 +527,16 @@ public static void loadPreInit() { blackListRocketBlocksStr = config.getStringList("rocketBlockBlackList", ROCKET, new String[]{"minecraft:portal", "minecraft:bedrock", "minecraft:snow_layer", "minecraft:water", "minecraft:flowing_water", "minecraft:lava", "minecraft:flowing_lava", "minecraft:fire", "advancedrocketry:rocketfire"}, "Blocks that cannot be part of rocket. Format: modid:block e.g \"minecraft:chest\""); arConfig.advancedWeightSystem = config.get(ROCKET, "advancedWeightSystem", true, "Enable advanced rocket weight calculation, including the handled inventories. Block weights are stored in weights.json").getBoolean(); arConfig.advancedWeightSystemInventories = config.get(ROCKET, "advancedWeightSystemInventories", true, "Include inventory contents in rocket weight. Note: may not work with modded inventories (eg IE storage chests)").getBoolean(); + arConfig.weightMaterialScale = config.get(ROCKET, "weightMaterialScale", 1.0, "Global multiplier applied to material-derived and fallback block weights (does not affect explicit overrides or rocket component parts). Raise to make hulls/structure mass matter more").getDouble(); + arConfig.fuelMassScale = config.get(ROCKET, "fuelMassScale", 1.0, "Global multiplier applied to the mass of fuel/oxidizer carried by a rocket. Raise to make full tanks weigh more relative to thrust").getDouble(); + arConfig.minLaunchTWR = config.get(ROCKET, "minLaunchTWR", 1.05, "Minimum thrust-to-weight ratio (thrust / wet weight) a rocket needs before it is allowed to launch. 1.0 means it can barely lift itself; values above 1.0 add a safety margin").getDouble(); + arConfig.wearThrustPenaltyMax = config.get(ROCKET, "wearThrustPenaltyMax", 0.5, "Fraction of thrust a fully-worn rocket motor loses (partsWearSystem). 0.5 means a motor at max wear produces half thrust; 0 disables the thrust penalty (wear then only affects explosion chance)").getDouble(); + arConfig.wearWarnProbability = config.get(ROCKET, "wearWarnProbability", 0.05, "Failure probability (0..1) at or above which the pilot is warned before launch that the rocket is worn. Also the threshold that blocks launch when wearCriticalBlocksLaunch is true").getDouble(); + arConfig.wearCriticalBlocksLaunch = config.get(ROCKET, "wearCriticalBlocksLaunch", false, "If true, a rocket whose failure probability is at/above wearWarnProbability is refused launch (no explosion). If false, the pilot is warned but may still launch and risk the stochastic explosion").getBoolean(); + arConfig.serviceStationStandaloneRepairMultiplier = config.get(ROCKET, "serviceStationStandaloneRepairMultiplier", 3.0, "Resource cost multiplier when the service station repairs a worn part WITHOUT a linked PrecisionAssembler (consumes the repair recipe's non-part ingredients times this factor). The assembler-backed path stays at 1x").getDouble(); + arConfig.wearTankLeakChanceMax = config.get(ROCKET, "wearTankLeakChanceMax", 0.5, "Chance (0..1) that a fully-worn fuel tank carrying fuel/oxidizer leaks at launch. Scaled by the tank's wear stage. A leak both bleeds fuel and adds to the launch failure (explosion) probability").getDouble(); + arConfig.wearTankLeakFuelLoss = config.get(ROCKET, "wearTankLeakFuelLoss", 0.25, "Fraction of a fuel type's loaded fuel lost when a worn tank of that type leaks at launch").getDouble(); + arConfig.wearSeatBlockStageFraction = config.get(ROCKET, "wearSeatBlockStageFraction", 0.7, "Wear fraction (0..1 of max stage) at or above which a worn seat blocks a CREWED launch. Uncrewed/automated rockets ignore seat wear").getDouble(); arConfig.partsWearSystem = config.get(ROCKET, "partsWearSystem", true, "Enable rocket part wear and exploding chance.").getBoolean(); arConfig.increaseWearIntensityProb = config.get(ROCKET, "increaseWearIntensityProb", 0.025, "Chance for each part to gain wear on launch.").getDouble(); diff --git a/src/main/java/zmaster587/advancedRocketry/api/StatsRocket.java b/src/main/java/zmaster587/advancedRocketry/api/StatsRocket.java index 83beafcd4..c66c1d2ea 100644 --- a/src/main/java/zmaster587/advancedRocketry/api/StatsRocket.java +++ b/src/main/java/zmaster587/advancedRocketry/api/StatsRocket.java @@ -190,8 +190,43 @@ public void setDrillingPower(float power) { } public float getAcceleration(float gravitationalMultiplier) { - float N = getThrust() - (getWeight() * ((ARConfiguration.getCurrentConfig().gravityAffectsFuel) ? gravitationalMultiplier : 1)); - return N/getWeight() /20f; + float weight = getWeight(); + if (weight <= 0) { + return 0; + } + float N = getThrust() - (weight * ((ARConfiguration.getCurrentConfig().gravityAffectsFuel) ? gravitationalMultiplier : 1)); + return N / weight / 20f; + } + + /** Acceleration with empty tanks (dry weight only) — the upper bound reached as fuel burns off. */ + public float getDryAcceleration(float gravitationalMultiplier) { + float weight = getWeight_NoFuel(); + if (weight <= 0) { + return 0; + } + float N = getThrust() - (weight * ((ARConfiguration.getCurrentConfig().gravityAffectsFuel) ? gravitationalMultiplier : 1)); + return N / weight / 20f; + } + + /** Thrust-to-weight ratio against the current wet weight (dry + fuel). 0 if weightless. */ + public float getThrustToWeightRatio() { + float weight = getWeight(); + if (weight <= 0) { + return 0; + } + return getThrust() / weight; + } + + /** True if the rocket clears the configured minimum thrust-to-weight ratio to launch. + * When the advanced weight system is disabled the weight-based launch gate is off + * entirely (classic behaviour — no TWR check), so this returns true regardless of + * thrust or weight. This is the single source of truth for weight-based launch + * gating; callers must not re-derive the TWR check independently. */ + public boolean canLaunch() { + if (!ARConfiguration.getCurrentConfig().advancedWeightSystem) { + return true; + } + return getThrustToWeightRatio() >= ARConfiguration.getCurrentConfig().minLaunchTWR; } public List> getEngineLocations() { diff --git a/src/main/java/zmaster587/advancedRocketry/api/capability/CapabilityWear.java b/src/main/java/zmaster587/advancedRocketry/api/capability/CapabilityWear.java new file mode 100644 index 000000000..faa54b39e --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/api/capability/CapabilityWear.java @@ -0,0 +1,74 @@ +package zmaster587.advancedRocketry.api.capability; + +import net.minecraft.nbt.NBTBase; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.EnumFacing; +import net.minecraftforge.common.capabilities.Capability; +import net.minecraftforge.common.capabilities.CapabilityInject; +import net.minecraftforge.common.capabilities.CapabilityManager; + +import javax.annotation.Nullable; + +/** + * Capability holding the {@link IPartWear} wear state of a rocket part. + * Registered the same way as {@link CapabilitySpaceArmor}. The hosting tile + * (today always {@link zmaster587.advancedRocketry.tile.TileBrokenPart}) + * persists the stage in its own NBT, so the capability {@code IStorage} is a + * no-op. + */ +public class CapabilityWear { + + @CapabilityInject(IPartWear.class) + public static Capability PART_WEAR = null; + + public CapabilityWear() { + } + + /** Convenience: the wear capability on a tile entity, or null if absent. */ + @Nullable + public static IPartWear get(@Nullable TileEntity te) { + if (te == null || PART_WEAR == null) { + return null; + } + return te.getCapability(PART_WEAR, null); + } + + public static void register() { + CapabilityManager.INSTANCE.register(IPartWear.class, new Capability.IStorage() { + @Override + public void readNBT(Capability capability, IPartWear instance, EnumFacing side, NBTBase nbt) { + } + + @Override + public NBTBase writeNBT(Capability capability, IPartWear instance, EnumFacing side) { + return null; + } + }, DefaultPartWear::new); + } + + /** Trivial standalone implementation for foreign hosts that want a backing store. */ + public static class DefaultPartWear implements IPartWear { + private int stage; + private int maxStage; + + @Override + public int getStage() { + return stage; + } + + @Override + public int getMaxStage() { + return maxStage; + } + + @Override + public void setStage(int stage) { + this.stage = stage; + } + + @Override + public boolean transition() { + return false; + } + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/api/capability/IPartWear.java b/src/main/java/zmaster587/advancedRocketry/api/capability/IPartWear.java new file mode 100644 index 000000000..f03901460 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/api/capability/IPartWear.java @@ -0,0 +1,29 @@ +package zmaster587.advancedRocketry.api.capability; + +/** + * Wear state of a rocket part. Exposed as a Forge capability + * ({@link CapabilityWear#PART_WEAR}) so wear can ride on a dedicated + * {@link zmaster587.advancedRocketry.tile.TileBrokenPart} or, in the future, + * on a block's own TileEntity without a second tile. + * + *

Stage convention: {@code 0} = pristine, {@code getMaxStage()} = fully + * worn / broken. Consequence formulas (thrust loss, leak/explosion chance) + * live in the consumers, not here — this is pure state.

+ */ +public interface IPartWear { + + /** Current wear stage (0 = pristine ... maxStage = broken). */ + int getStage(); + + /** Maximum wear stage (the broken state). */ + int getMaxStage(); + + /** Set the current wear stage (used by repair to reset to 0). */ + void setStage(int stage); + + /** + * Advance wear by one probabilistic step (called once per flight on + * landing). Returns true if the part changed stage or is already broken. + */ + boolean transition(); +} diff --git a/src/main/java/zmaster587/advancedRocketry/block/BlockFuelTank.java b/src/main/java/zmaster587/advancedRocketry/block/BlockFuelTank.java index 1a0b5ae46..44ac26c77 100644 --- a/src/main/java/zmaster587/advancedRocketry/block/BlockFuelTank.java +++ b/src/main/java/zmaster587/advancedRocketry/block/BlockFuelTank.java @@ -17,6 +17,7 @@ import zmaster587.libVulpes.block.BlockFullyRotatable; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import java.util.LinkedList; import java.util.List; import java.util.Locale; @@ -198,6 +199,20 @@ public int getMaxFill(World world, BlockPos pos, IBlockState state) { return 1000; } + @Override + public boolean hasTileEntity(IBlockState state) { + return true; + } + + @Nullable + @Override + public net.minecraft.tileentity.TileEntity createTileEntity(World worldIn, IBlockState state) { + // Wear lives only in the tile (no meta overload, no breaking render): + // a worn tank may leak/explode on launch; replacing the block resets wear. + return new zmaster587.advancedRocketry.tile.TileWearable( + 10, (float) zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig().increaseWearIntensityProb); + } + public enum TankStates implements IStringSerializable { TOP, BOTTOM, diff --git a/src/main/java/zmaster587/advancedRocketry/block/BlockSeat.java b/src/main/java/zmaster587/advancedRocketry/block/BlockSeat.java index 71d3d565c..4092e5edb 100644 --- a/src/main/java/zmaster587/advancedRocketry/block/BlockSeat.java +++ b/src/main/java/zmaster587/advancedRocketry/block/BlockSeat.java @@ -33,6 +33,19 @@ public BlockSeat(Material mat) { super(mat); } + @Override + public boolean hasTileEntity(IBlockState state) { + return true; + } + + @Nullable + @Override + public net.minecraft.tileentity.TileEntity createTileEntity(World worldIn, IBlockState state) { + // Seats wear slowly; a worn seat blocks a crewed launch (see EntityRocket). + return new zmaster587.advancedRocketry.tile.TileWearable( + 10, 0.25f * (float) zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig().increaseWearIntensityProb); + } + @Override public boolean isOpaqueCube(IBlockState state) { return false; diff --git a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java index 9b85f977f..dbb95945e 100644 --- a/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java +++ b/src/main/java/zmaster587/advancedRocketry/command/test/TestProbeCommand.java @@ -160,6 +160,12 @@ public void execute(MinecraftServer server, ICommandSender sender, String[] args case "item": handleItem(server, sender, tail(args)); break; + case "weight": + handleWeight(sender, tail(args)); + break; + case "wear": + handleWear(server, sender, tail(args)); + break; case "enchant": handleEnchant(server, sender, tail(args)); break; @@ -501,6 +507,54 @@ private void handleWeather(MinecraftServer server, ICommandSender sender, String send(sender, "{\"ok\":true,\"dim\":" + dim + ",\"mode\":\"" + mode + "\",\"ticks\":" + ticks + "}"); return; } + // weather set-marker — set the planet's + // XML-style weather markers at runtime and refresh usesCustomWorldInfo(). + // A non-default marker (e.g. rain=-1 = forced-clear) makes the custom + // weather cycle eligible to run, which is what we toggle the config against. + if (args.length >= 4 && "set-marker".equalsIgnoreCase(args[0])) { + int dim = parseIntOr(args[1], Integer.MIN_VALUE); + int rainMarker = parseIntOr(args[2], 0); + int thunderMarker = parseIntOr(args[3], 0); + zmaster587.advancedRocketry.dimension.DimensionProperties props = + zmaster587.advancedRocketry.dimension.DimensionManager.getInstance() + .getDimensionProperties(dim); + if (props == null) { + send(sender, "{\"error\":\"no dimension properties\",\"dim\":" + dim + "}"); + return; + } + props.setRainMarker(rainMarker); + props.setThunderMarker(thunderMarker); + props.updateCustomWorldInfo(); + send(sender, "{\"ok\":true,\"dim\":" + dim + + ",\"rainMarker\":" + props.getRainMarker() + + ",\"thunderMarker\":" + props.getThunderMarker() + + ",\"usesCustomWorldInfo\":" + props.usesCustomWorldInfo() + "}"); + return; + } + // weather tick-provider [n] — call WorldProvider.updateWeather() + // directly n times (default 1), bypassing the natural per-tick schedule. + // This is the production weather-cycle entry point; driving it lets a test + // observe whether the custom planet cycle runs (config on) or delegates to + // vanilla (config off) without waiting on real ticks. + if (args.length >= 2 && "tick-provider".equalsIgnoreCase(args[0])) { + int dim = parseIntOr(args[1], Integer.MIN_VALUE); + int n = args.length >= 3 ? parseIntOr(args[2], 1) : 1; + 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; + } + for (int i = 0; i < n; i++) { + world.provider.updateWeather(); + } + send(sender, "{\"ok\":true,\"dim\":" + dim + ",\"ticks\":" + n + + ",\"providerClass\":\"" + world.provider.getClass().getName() + "\"}"); + return; + } send(sender, "{\"error\":\"unknown weather subcommand\"}"); } @@ -852,6 +906,7 @@ private void handleRocket(MinecraftServer server, ICommandSender sender, String[ info.put("fuel", fuel); info.put("thrust", rocket.stats.getThrust()); info.put("weight_no_fuel", rocket.stats.getWeight_NoFuel()); + info.put("breakingProb", rocket.storage.getBreakingProbability()); // TASK-37/TASK-38 — expose stats fields that aggregate per-block // contributions during scanRocket. drillingPower sums every // IMiningDrill.getMiningSpeed(); thrust above already reflects @@ -4532,7 +4587,18 @@ private void handleMachineTickUntil(MinecraftServer server, ICommandSender sende "allowTerraformNonAR", "terraformRequiresFluid", "oxygenVentSize", - "atmosphereHandleBitMask")); + "atmosphereHandleBitMask", + // Disableability-contract tests (TASK-46): toggle each opt-in + // mechanic and its tuning knobs from the test JVM. + "advancedWeightSystem", + "minLaunchTWR", + "partsWearSystem", + "increaseWearIntensityProb", + "enableCustomPlanetWeather", + // perDimWorldInfo master switch (gates weather + time + wrapper): + // PerDimWorldInfoMasterToggleTest flips it to pin both off (vanilla + // WorldInfo) and weather-off-but-master-on (per-dim time survives). + "perDimWorldInfo")); private void handleConfig(ICommandSender sender, String[] args) { if (args.length == 0) { @@ -9403,6 +9469,220 @@ private void handleItem(MinecraftServer server, ICommandSender sender, String[] send(sender, jsonMap(info)); } + /** + * {@code /artest weight ...} — probes the {@link zmaster587.advancedRocketry.util.WeightEngine}. + * Verbs: + * reset — restore default tables + scales (test isolation) + * item [count] — resolved weight of an ItemStack + * fluid — resolved weight of a FluidStack-equivalent + * set — register an individual override + * set-regex — register a regex rule + * material-scale — set ARConfiguration.weightMaterialScale + * fuel-scale — set ARConfiguration.fuelMassScale + */ + private void handleWeight(ICommandSender sender, String[] args) { + zmaster587.advancedRocketry.util.WeightEngine we = zmaster587.advancedRocketry.util.WeightEngine.INSTANCE; + if (args.length == 0) { + send(sender, "{\"error\":\"unknown weight subcommand — try reset|item|fluid|set|set-regex|material-scale|fuel-scale\"}"); + return; + } + Map info = new LinkedHashMap<>(); + String verb = args[0].toLowerCase(); + switch (verb) { + case "reset": + we.resetTables(); + zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig().weightMaterialScale = 1.0; + zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig().fuelMassScale = 1.0; + info.put("reset", true); + info.put("materialCount", we.materialCount()); + break; + case "item": { + String id = args[1]; + int count = args.length >= 3 ? Integer.parseInt(args[2]) : 1; + net.minecraft.item.Item item = ForgeRegistries.ITEMS.getValue(new ResourceLocation(id)); + info.put("id", id); + info.put("registered", item != null); + if (item != null) { + net.minecraft.item.ItemStack stack = new net.minecraft.item.ItemStack(item, count); + info.put("count", count); + info.put("weight", we.getWeight(stack)); + } + break; + } + case "fluid": { + String name = args[1]; + float amount = Float.parseFloat(args[2]); + net.minecraftforge.fluids.Fluid f = net.minecraftforge.fluids.FluidRegistry.getFluid(name); + info.put("fluid", name); + info.put("registered", f != null); + if (f != null) { + info.put("amount", amount); + info.put("weight", we.getWeight(f, amount)); + } + break; + } + case "set": + we.setIndividual(args[1], Double.parseDouble(args[2])); + info.put("set", args[1]); + info.put("value", Double.parseDouble(args[2])); + break; + case "set-regex": + we.setRegex(args[1], Double.parseDouble(args[2])); + info.put("regex", args[1]); + info.put("value", Double.parseDouble(args[2])); + break; + case "material-scale": + zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig().weightMaterialScale = Double.parseDouble(args[1]); + we.clearResolveCache(); + info.put("materialScale", Double.parseDouble(args[1])); + break; + case "fuel-scale": + zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig().fuelMassScale = Double.parseDouble(args[1]); + info.put("fuelScale", Double.parseDouble(args[1])); + break; + default: + send(sender, "{\"error\":\"unknown weight subcommand\",\"sub\":\"" + verb + "\"}"); + return; + } + info.put("ok", true); + send(sender, jsonMap(info)); + } + + /** + * {@code /artest wear ...} — probes the part-wear capability on world blocks + * (motors / fuel tanks / seats hosting a TileWearable): + * get — registered + current/max wear stage + * set — force the wear stage at a position + */ + private void handleWear(MinecraftServer server, ICommandSender sender, String[] args) { + if (args.length == 0) { + send(sender, "{\"error\":\"usage: wear get|set|station-load|rocket-status ...\"}"); + return; + } + String verb = args[0].toLowerCase(); + + // wear rocket-status — worn tanks + worn-seat + // predicate of an assembled rocket (the data the launch gate reads). + if ("rocket-status".equals(verb)) { + EntityRocket rocket = findRocket(server, Integer.parseInt(args[1])); + double frac = args.length >= 3 ? Double.parseDouble(args[2]) : 0.7; + Map info = new LinkedHashMap<>(); + if (rocket == null) { + info.put("found", false); + send(sender, jsonMap(info)); + return; + } + info.put("found", true); + info.put("wornTankCount", rocket.storage.getWornTanks().size()); + info.put("hasCriticallyWornSeat", rocket.storage.hasCriticallyWornSeat(frac)); + info.put("breakingProb", rocket.storage.getBreakingProbability()); + info.put("ok", true); + send(sender, jsonMap(info)); + return; + } + + // wear damage-parts [iterations] — drive StorageChunk.damageParts() + // directly (the same accrual entry point production calls on landing) N times, + // then report the resulting breaking probability. Lets a test observe whether + // wear ACCRUES (partsWearSystem on) or stays put (system off) deterministically, + // without depending on a free-flight landing tick. + if ("damage-parts".equals(verb)) { + EntityRocket rocket = findRocket(server, Integer.parseInt(args[1])); + int iterations = args.length >= 3 ? Integer.parseInt(args[2]) : 1; + Map info = new LinkedHashMap<>(); + if (rocket == null || rocket.storage == null) { + info.put("found", false); + send(sender, jsonMap(info)); + return; + } + double before = rocket.storage.getBreakingProbability(); + for (int i = 0; i < iterations; i++) { + rocket.storage.damageParts(); + } + info.put("found", true); + info.put("iterations", iterations); + info.put("breakingProbBefore", before); + info.put("breakingProb", rocket.storage.getBreakingProbability()); + info.put("ok", true); + send(sender, jsonMap(info)); + return; + } + + // wear station-load + if ("station-load".equals(verb)) { + int dim = Integer.parseInt(args[1]); + net.minecraft.world.WorldServer world = server.getWorld(dim); + BlockPos pos = new BlockPos(Integer.parseInt(args[2]), Integer.parseInt(args[3]), Integer.parseInt(args[4])); + TileEntity te = world.getTileEntity(pos); + Map info = new LinkedHashMap<>(); + if (!(te instanceof zmaster587.advancedRocketry.tile.infrastructure.TileRocketServiceStation)) { + info.put("error", "no service station at pos"); + send(sender, jsonMap(info)); + return; + } + int slot = Integer.parseInt(args[5]); + String spec = args[6]; + int count = Integer.parseInt(args[7]); + net.minecraft.item.ItemStack stack; + if (spec.startsWith("ore:")) { + java.util.List ores = + net.minecraftforge.oredict.OreDictionary.getOres(spec.substring(4)); + if (ores.isEmpty()) { + info.put("error", "ore dict empty: " + spec); + send(sender, jsonMap(info)); + return; + } + stack = ores.get(0).copy(); + } else { + net.minecraft.item.Item item = ForgeRegistries.ITEMS.getValue(new ResourceLocation(spec)); + if (item == null) { + info.put("error", "item not found: " + spec); + send(sender, jsonMap(info)); + return; + } + stack = new net.minecraft.item.ItemStack(item); + } + stack.setCount(count); + ((zmaster587.advancedRocketry.tile.infrastructure.TileRocketServiceStation) te) + .getRepairInventory().setStackInSlot(slot, stack); + info.put("loaded", stack.getItem().getRegistryName().toString()); + info.put("count", count); + info.put("ok", true); + send(sender, jsonMap(info)); + return; + } + + // wear get|set [stage] + if (args.length < 5) { + send(sender, "{\"error\":\"usage: wear get|set [stage]\"}"); + return; + } + int dim = Integer.parseInt(args[1]); + net.minecraft.world.WorldServer world = server.getWorld(dim); + BlockPos pos = new BlockPos(Integer.parseInt(args[2]), Integer.parseInt(args[3]), Integer.parseInt(args[4])); + zmaster587.advancedRocketry.api.capability.IPartWear wear = + zmaster587.advancedRocketry.api.capability.CapabilityWear.get(world.getTileEntity(pos)); + + Map info = new LinkedHashMap<>(); + info.put("pos", new int[]{pos.getX(), pos.getY(), pos.getZ()}); + info.put("registered", wear != null); + if (wear == null) { + send(sender, jsonMap(info)); + return; + } + if ("set".equals(verb)) { + if (args.length < 6) { + send(sender, "{\"error\":\"usage: wear set \"}"); + return; + } + wear.setStage(Integer.parseInt(args[5])); + } + info.put("stage", wear.getStage()); + info.put("maxStage", wear.getMaxStage()); + info.put("ok", true); + send(sender, jsonMap(info)); + } + /** * {@code /artest enchant check } — reports whether an * enchantment is registered. Used to verify the spacebreathing enchant lands diff --git a/src/main/java/zmaster587/advancedRocketry/entity/EntityRocket.java b/src/main/java/zmaster587/advancedRocketry/entity/EntityRocket.java index f02b21daf..20d7d414b 100644 --- a/src/main/java/zmaster587/advancedRocketry/entity/EntityRocket.java +++ b/src/main/java/zmaster587/advancedRocketry/entity/EntityRocket.java @@ -563,6 +563,18 @@ private static String packReason(String key, Object... args) { return sb.toString(); } + /** Send a translated informational message to the rocket's passengers (no abort). */ + private void messagePilot(String key, Object... args) { + if (world.isRemote) { + return; + } + for (Entity e : this.getPassengers()) { + if (e instanceof EntityPlayerMP) { + ((EntityPlayerMP) e).sendMessage(new net.minecraft.util.text.TextComponentTranslation(key, args)); + } + } + } + private void setError(String key, Object... args) { this.errorStr = key; this.lastErrorTime = this.world.getTotalWorldTime(); @@ -2061,9 +2073,46 @@ public void launch() { } } - if (ARConfiguration.getCurrentConfig().partsWearSystem && storage.shouldBreak()) { - this.explode(); - return; + if (ARConfiguration.getCurrentConfig().partsWearSystem) { + ARConfiguration cfg = ARConfiguration.getCurrentConfig(); + + // A worn seat is unsafe: refuse a CREWED launch (automated rockets fly). + if (!this.getPassengers().isEmpty() && storage.hasCriticallyWornSeat(cfg.wearSeatBlockStageFraction)) { + setError("error.rocket.seatWorn"); + return; + } + + // Failure probability = motor wear + leak-ignition risk of worn tanks + // that actually carry fuel/oxidizer. Computed without side effects so + // the block decision below does not strand a half-leaked rocket. + float failProb = storage.getBreakingProbability(); + for (StorageChunk.WornTank tank : storage.getWornTanks()) { + if (getFuelAmount(tank.type) > 0) { + failProb += (float) cfg.wearTankLeakChanceMax * tank.wornFraction; + } + } + failProb = Math.min(1f, failProb); + + if (failProb >= cfg.wearWarnProbability) { + messagePilot("warning.rocket.worn", (int) (failProb * 100)); + if (cfg.wearCriticalBlocksLaunch) { + setError("error.rocket.tooWorn", (int) (failProb * 100)); + return; + } + } + + if (failProb > 0 && world.rand.nextFloat() < failProb) { + this.explode(); + return; + } + + // Launch proceeds, but worn tanks bleed some of their fuel. + for (StorageChunk.WornTank tank : storage.getWornTanks()) { + int amt = getFuelAmount(tank.type); + if (amt > 0 && world.rand.nextFloat() < cfg.wearTankLeakChanceMax * tank.wornFraction) { + setFuelAmount(tank.type, (int) (amt * (1 - cfg.wearTankLeakFuelLoss))); + } + } } if (ARConfiguration.getCurrentConfig().experimentalSpaceFlight && storage.getGuidanceComputer() != null && storage.getGuidanceComputer().isEmpty()) { @@ -2153,7 +2202,7 @@ public void launch() { } - if (this.stats.getWeight() >= this.stats.getThrust()) { + if (!this.stats.canLaunch()) { setError("error.rocket.tooHeavy"); return; // hard stop; no silent fall-through } @@ -2720,15 +2769,13 @@ public List getModules(int ID, EntityPlayer player) { modules.add(new ModuleImage(173, 168, new IconResource(98, 168, 78, 3, CommonResources.genericBackground))); } - // Broken parts - // TODO Add check for the service monitor - + // Worn parts damage view — gated on a service monitor in the rocket. if (storage.hasServiceMonitor()) { List serviceMonitorList = new ArrayList<>(); int ii = 0; - for (TileBrokenPart part : storage.getBrokenBlocks()) { - serviceMonitorList.add(new ModuleBrokenPart(1 + (ii % 5) * 18, 1 + (ii / 5) * 18, part.getDrop())); + for (ItemStack worn : storage.getWornPartDisplayStacks()) { + serviceMonitorList.add(new ModuleBrokenPart(1 + (ii % 5) * 18, 1 + (ii / 5) * 18, worn)); ii++; } diff --git a/src/main/java/zmaster587/advancedRocketry/mixin/ARMixinPlugin.java b/src/main/java/zmaster587/advancedRocketry/mixin/ARMixinPlugin.java new file mode 100644 index 000000000..68a5d4d98 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/mixin/ARMixinPlugin.java @@ -0,0 +1,112 @@ +package zmaster587.advancedRocketry.mixin; + +import net.minecraftforge.common.config.Configuration; +import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin; +import org.spongepowered.asm.mixin.extensibility.IMixinInfo; + +import java.io.File; +import java.util.List; +import java.util.Set; + +/** + * Mixin config plugin for {@code mixins.advancedrocketry.json}. + * + *

Its only job today: gate the three per-dimension WorldInfo mixins + * ({@link MixinWorldServerMulti} wrapper install, {@code MixinWorldServer} + * per-dim time / sleep, {@link MixinPlayerList} weather sync) on the + * {@code perDimWorldInfo} MASTER config flag, so that with the per-dimension + * WorldInfo subsystem turned off those mixins are never woven into their target + * classes at all — not merely no-ops at runtime. The other mixins (gravity, + * atmosphere block-place, rocket inventory access) are unrelated and always + * apply.

+ * + *

Timing. {@code shouldApplyMixin} is evaluated lazily, when each + * target class first loads ({@code WorldServerMulti} at dimension creation, + * {@code PlayerList} at server start) — late enough that the config file + * exists. We still read the {@code .cfg} directly here rather than going + * through {@link zmaster587.advancedRocketry.api.ARConfiguration}, because that + * singleton is populated in mod pre-init, which runs AFTER the coremod phase + * that constructs this plugin. Reading the file ourselves removes any + * dependence on mod-lifecycle ordering.

+ * + *

Fail-open. If the config can't be read for any reason (missing + * file on first launch, parse error), we default to {@code true} — i.e. the + * WorldInfo mixins apply, exactly as they did before this plugin existed. A + * disabled-by-accident subsystem would be a worse surprise than the + * pre-existing always-on behaviour.

+ */ +public class ARMixinPlugin implements IMixinConfigPlugin { + + /** Fully-qualified names of the per-dimension WorldInfo mixins gated by the + * {@code perDimWorldInfo} master flag: wrapper install, per-dim time/sleep, + * and weather sync. */ + private static final String MIXIN_WORLD_SERVER_MULTI = + "zmaster587.advancedRocketry.mixin.MixinWorldServerMulti"; + private static final String MIXIN_PLAYER_LIST = + "zmaster587.advancedRocketry.mixin.MixinPlayerList"; + private static final String MIXIN_WORLD_SERVER = + "zmaster587.advancedRocketry.mixin.MixinWorldServer"; + + private boolean perDimWorldInfo = true; + + @Override + public void onLoad(String mixinPackage) { + try { + File cfgFile = new File("config/advRocketry/advancedRocketry.cfg"); + if (cfgFile.isFile()) { + Configuration cfg = new Configuration(cfgFile); + cfg.load(); + perDimWorldInfo = cfg + .get("Planet", "perDimWorldInfo", true) + .getBoolean(true); + } + } catch (Throwable t) { + // Fail-open: behave exactly as before the plugin (mixins on). + perDimWorldInfo = true; + } + } + + @Override + public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { + return shouldApply(perDimWorldInfo, mixinClassName); + } + + /** + * Pure decision function (no I/O, no state) so it can be unit-tested + * directly: the three per-dimension WorldInfo mixins (wrapper install, + * per-dim time/sleep, weather sync) apply iff {@code perDimWorldInfo} is + * enabled; every other mixin always applies. + */ + public static boolean shouldApply(boolean perDimWorldInfoEnabled, String mixinClassName) { + if (MIXIN_WORLD_SERVER_MULTI.equals(mixinClassName) + || MIXIN_PLAYER_LIST.equals(mixinClassName) + || MIXIN_WORLD_SERVER.equals(mixinClassName)) { + return perDimWorldInfoEnabled; + } + return true; + } + + @Override + public String getRefMapperConfig() { + return null; + } + + @Override + public void acceptTargets(Set myTargets, Set otherTargets) { + } + + @Override + public List getMixins() { + return null; + } + + @Override + public void preApply(String targetClassName, org.objectweb.asm.tree.ClassNode targetClass, + String mixinClassName, IMixinInfo mixinInfo) { + } + + @Override + public void postApply(String targetClassName, org.objectweb.asm.tree.ClassNode targetClass, + String mixinClassName, IMixinInfo mixinInfo) { + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServer.java b/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServer.java index f5ca2e093..78a27d32d 100644 --- a/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServer.java +++ b/src/main/java/zmaster587/advancedRocketry/mixin/MixinWorldServer.java @@ -34,7 +34,13 @@ public abstract class MixinWorldServer { target = "Lnet/minecraft/world/WorldServer;setWorldTime(J)V", ordinal = 0)) private void ar$roundSleepWakeToRotationalPeriod(WorldServer self, long vanillaRounded) { - if (self.provider instanceof IPlanetaryProvider) { + // Runtime belt-and-suspenders for the perDimWorldInfo master switch: + // ARMixinPlugin already skips weaving this mixin when the master is off, + // but if it is woven we still defer to vanilla rounding unless per-dim + // WorldInfo is active (so the planet's per-dim clock is what we round). + zmaster587.advancedRocketry.api.ARConfiguration cfg = + zmaster587.advancedRocketry.api.ARConfiguration.getCurrentConfig(); + if (cfg != null && cfg.perDimWorldInfo && self.provider instanceof IPlanetaryProvider) { int rotationalPeriod = ((IPlanetaryProvider) self.provider).getRotationalPeriod(null); self.setWorldTime(ARDimensionWorldInfo.computeSleepWakeTime(self.getWorldTime(), rotationalPeriod)); } else { diff --git a/src/main/java/zmaster587/advancedRocketry/tile/TileBrokenPart.java b/src/main/java/zmaster587/advancedRocketry/tile/TileBrokenPart.java index 8922ab5fb..1a281ea78 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/TileBrokenPart.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/TileBrokenPart.java @@ -1,72 +1,31 @@ package zmaster587.advancedRocketry.tile; import net.minecraft.item.ItemStack; -import net.minecraft.nbt.NBTTagCompound; import zmaster587.advancedRocketry.util.IBrokenPartBlock; -import javax.annotation.Nonnull; import java.util.Random; -public class TileBrokenPart extends TileEntitySyncable { - - private int stage; - private int maxStage; - private float transitionProb; - private float[] probs; - private final Random rand; +/** + * Wear host for rocket motors: a {@link TileWearable} that also renders the + * breaking overlay (motors render INVISIBLE and rely on this TESR) and drops a + * staged item so a worn motor keeps its wear when picked up and replaced. + */ +public class TileBrokenPart extends TileWearable { public TileBrokenPart() { - this(0, 0); + super(); } public TileBrokenPart(int stage, int maxStage, float transitionProb, Random rand) { - this.stage = stage; - this.maxStage = maxStage; - this.rand = rand; - this.initProb(transitionProb); + super(stage, maxStage, transitionProb, rand); } public TileBrokenPart(int maxStage, float transitionProb, Random rand) { - this(0, maxStage, transitionProb, rand); + super(maxStage, transitionProb, rand); } public TileBrokenPart(int maxStage, float transitionProb) { - this(maxStage, transitionProb, new Random()); - } - - public void setStage(int stage) { - this.stage = stage; - this.markDirty(); - } - - public int getStage() { - return this.stage; - } - - private void initProb(float transitionProb) { - this.transitionProb = transitionProb; - this.probs = new float[maxStage]; - - for (int i = 0; i < maxStage; i++) { - this.probs[i] = transitionProb / (float) Math.sqrt(2 * i + 1); - } - } - - public boolean transition() { - if (stage == maxStage) { - return true; - } - for (int i = maxStage - 1; i >= 0; i--) { - if (stage == i) { - return false; - } - if (rand.nextFloat() < (stage + 1) * this.probs[i]) { - stage = i; - this.markDirty(); - return true; - } - } - return false; + super(maxStage, transitionProb); } @Override @@ -77,23 +36,4 @@ public boolean canRenderBreaking() { public ItemStack getDrop() { return ((IBrokenPartBlock) this.getBlockType()).getDropItem(world.getBlockState(pos), world, this); } - - @Nonnull - @Override - public NBTTagCompound writeToNBT(final NBTTagCompound compound) { - compound.setInteger("stage", stage); - compound.setInteger("maxStage", maxStage); - compound.setFloat("transitionProb", transitionProb); - return super.writeToNBT(compound); - } - - @Override - public void readFromNBT(@Nonnull final NBTTagCompound compound) { - super.readFromNBT(compound); - stage = compound.getInteger("stage"); - maxStage = compound.getInteger("maxStage"); - transitionProb = compound.getFloat("transitionProb"); - - this.initProb(transitionProb); - } } diff --git a/src/main/java/zmaster587/advancedRocketry/tile/TileRocketAssemblingMachine.java b/src/main/java/zmaster587/advancedRocketry/tile/TileRocketAssemblingMachine.java index 719945150..15653b1ec 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/TileRocketAssemblingMachine.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/TileRocketAssemblingMachine.java @@ -222,17 +222,34 @@ public int getThrust() { } public float getNeededThrust() { - return getWeight(); + // With the weight system off there is no TWR launch gate (see + // StatsRocket.canLaunch), so there is no thrust requirement to display. + if (!ARConfiguration.getCurrentConfig().advancedWeightSystem) { + return 0; + } + return getWeight() * (float) ARConfiguration.getCurrentConfig().minLaunchTWR; + } + + public float getThrustToWeightRatio() { + return stats.getThrustToWeightRatio(); } public boolean hasEnoughFuel(@Nonnull FuelType fuelType) { - //return getAcceleration(getGravityMultiplier()) > 0 ? 2 * stats.getBaseFuelRate(fuelType) * MathHelper.sqrt((2 * (ARConfiguration.getCurrentConfig().orbit - this.getPos().getY())) / getAcceleration(getGravityMultiplier())) : 0; - float a = getAcceleration(getGravityMultiplier()); + if (stats.getBaseFuelRate(fuelType) <= 0) { + return false; + } + float g = getGravityMultiplier(); + // Acceleration grows as fuel burns off (wet -> dry), so integrate over the burn using the + // average of the full-tank and empty-tank accelerations rather than the (often near-zero) + // full-tank value alone. + float aAvg = (getAcceleration(g) + stats.getDryAcceleration(g)) / 2f; + if (aAvg <= 0) { + return false; + } float fueltime = (float) stats.getFuelCapacity(fuelType) / stats.getBaseFuelRate(fuelType); - float s_can = a/2f*fueltime*fueltime; + float s_can = aAvg / 2f * fueltime * fueltime; float target_s = 1 * ARConfiguration.getCurrentConfig().orbit - this.getPos().getY(); // for way back *2 return s_can > target_s; - } public float getGravityMultiplier() { @@ -939,7 +956,7 @@ protected void updateText() { thrustText.setText(isScanning() ? (LibVulpes.proxy.getLocalizedString("msg.rocketbuilder.thrust") + ": ???") : String.format("%s: %dkN", LibVulpes.proxy.getLocalizedString("msg.rocketbuilder.thrust"), getThrust())); weightText.setText(isScanning() ? (LibVulpes.proxy.getLocalizedString("msg.rocketbuilder.weight") + ": ???") : String.format("%s: %.2fkN", LibVulpes.proxy.getLocalizedString("msg.rocketbuilder.weight"), (getWeight() * getGravityMultiplier()))); fuelText.setText(isScanning() ? (LibVulpes.proxy.getLocalizedString("msg.rocketbuilder.fuel") + ": ???") : String.format("%s: %dmb/s", LibVulpes.proxy.getLocalizedString("msg.rocketbuilder.fuel"), 20* getRocketStats().getFuelRate((stats.getFuelCapacity(FuelType.LIQUID_MONOPROPELLANT) > 0) ? FuelType.LIQUID_MONOPROPELLANT : (stats.getFuelCapacity(FuelType.NUCLEAR_WORKING_FLUID) > 0) ? FuelType.NUCLEAR_WORKING_FLUID : FuelType.LIQUID_BIPROPELLANT))); - accelerationText.setText(isScanning() ? (LibVulpes.proxy.getLocalizedString("msg.rocketbuilder.acc") + ": ???") : String.format("%s: %.2fm/s\u00b2", LibVulpes.proxy.getLocalizedString("msg.rocketbuilder.acc"), getAcceleration(getGravityMultiplier()) * 20f)); + accelerationText.setText(isScanning() ? (LibVulpes.proxy.getLocalizedString("msg.rocketbuilder.acc") + ": ???") : String.format("%s: %.2fm/s\u00b2 (TWR %.2f)", LibVulpes.proxy.getLocalizedString("msg.rocketbuilder.acc"), getAcceleration(getGravityMultiplier()) * 20f, getThrustToWeightRatio())); if (!world.isRemote) { if (getRocketPadBounds(world, pos) == null) setStatus(ErrorCodes.INCOMPLETESTRCUTURE.ordinal()); diff --git a/src/main/java/zmaster587/advancedRocketry/tile/TileWearable.java b/src/main/java/zmaster587/advancedRocketry/tile/TileWearable.java new file mode 100644 index 000000000..62b598d07 --- /dev/null +++ b/src/main/java/zmaster587/advancedRocketry/tile/TileWearable.java @@ -0,0 +1,126 @@ +package zmaster587.advancedRocketry.tile; + +import net.minecraft.nbt.NBTTagCompound; +import net.minecraft.util.EnumFacing; +import net.minecraftforge.common.capabilities.Capability; +import zmaster587.advancedRocketry.api.capability.CapabilityWear; +import zmaster587.advancedRocketry.api.capability.IPartWear; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Random; + +/** + * Generic wear-bearing tile: holds a wear {@code stage} (0 = pristine ... + * {@code maxStage} = broken) and exposes it via {@link CapabilityWear}. + * + *

This is the host for wear on blocks that have no special render + * (fuel tanks, seats). {@link TileBrokenPart} extends it to add the motor + * breaking-render and a staged item drop.

+ */ +public class TileWearable extends TileEntitySyncable implements IPartWear { + + protected int stage; + protected int maxStage; + protected float transitionProb; + protected float[] probs; + protected final Random rand; + + public TileWearable() { + this(0, 0); + } + + public TileWearable(int stage, int maxStage, float transitionProb, Random rand) { + this.stage = stage; + this.maxStage = maxStage; + this.rand = rand; + this.initProb(transitionProb); + } + + public TileWearable(int maxStage, float transitionProb, Random rand) { + this(0, maxStage, transitionProb, rand); + } + + public TileWearable(int maxStage, float transitionProb) { + this(maxStage, transitionProb, new Random()); + } + + @Override + public void setStage(int stage) { + this.stage = stage; + this.markDirty(); + } + + @Override + public int getStage() { + return this.stage; + } + + @Override + public int getMaxStage() { + return this.maxStage; + } + + protected void initProb(float transitionProb) { + this.transitionProb = transitionProb; + this.probs = new float[maxStage]; + + for (int i = 0; i < maxStage; i++) { + this.probs[i] = transitionProb / (float) Math.sqrt(2 * i + 1); + } + } + + @Override + public boolean transition() { + if (stage == maxStage) { + return true; + } + for (int i = maxStage - 1; i >= 0; i--) { + if (stage == i) { + return false; + } + if (rand.nextFloat() < (stage + 1) * this.probs[i]) { + stage = i; + this.markDirty(); + return true; + } + } + return false; + } + + @Override + public boolean hasCapability(@Nonnull Capability capability, @Nullable EnumFacing facing) { + if (capability == CapabilityWear.PART_WEAR) { + return true; + } + return super.hasCapability(capability, facing); + } + + @Nullable + @Override + public T getCapability(@Nonnull Capability capability, @Nullable EnumFacing facing) { + if (capability == CapabilityWear.PART_WEAR) { + return CapabilityWear.PART_WEAR.cast(this); + } + return super.getCapability(capability, facing); + } + + @Nonnull + @Override + public NBTTagCompound writeToNBT(final NBTTagCompound compound) { + compound.setInteger("stage", stage); + compound.setInteger("maxStage", maxStage); + compound.setFloat("transitionProb", transitionProb); + return super.writeToNBT(compound); + } + + @Override + public void readFromNBT(@Nonnull final NBTTagCompound compound) { + super.readFromNBT(compound); + stage = compound.getInteger("stage"); + maxStage = compound.getInteger("maxStage"); + transitionProb = compound.getFloat("transitionProb"); + + this.initProb(transitionProb); + } +} diff --git a/src/main/java/zmaster587/advancedRocketry/tile/infrastructure/TileRocketServiceStation.java b/src/main/java/zmaster587/advancedRocketry/tile/infrastructure/TileRocketServiceStation.java index af31a044c..1dc831132 100644 --- a/src/main/java/zmaster587/advancedRocketry/tile/infrastructure/TileRocketServiceStation.java +++ b/src/main/java/zmaster587/advancedRocketry/tile/infrastructure/TileRocketServiceStation.java @@ -23,12 +23,18 @@ import zmaster587.advancedRocketry.block.BlockSeat; import zmaster587.advancedRocketry.entity.EntityRocket; import zmaster587.advancedRocketry.inventory.TextureResources; +import zmaster587.advancedRocketry.api.ARConfiguration; +import zmaster587.advancedRocketry.api.capability.CapabilityWear; +import zmaster587.advancedRocketry.api.capability.IPartWear; import zmaster587.advancedRocketry.tile.TileBrokenPart; import zmaster587.advancedRocketry.tile.multiblock.machine.TilePrecisionAssembler; import zmaster587.advancedRocketry.util.IBrokenPartBlock; import zmaster587.advancedRocketry.util.InventoryUtil; import zmaster587.advancedRocketry.util.StorageChunk; import zmaster587.advancedRocketry.util.nbt.NBTHelper; +import zmaster587.libVulpes.interfaces.IRecipe; +import zmaster587.libVulpes.recipe.RecipesMachine; +import zmaster587.libVulpes.util.EmbeddedInventory; import zmaster587.libVulpes.LibVulpes; import zmaster587.libVulpes.block.BlockTile; import zmaster587.libVulpes.interfaces.ILinkableTile; @@ -69,18 +75,24 @@ public class TileRocketServiceStation extends TileEntityRFConsumer implements IM List partsToRepair = new LinkedList<>(); List statesToRepair = new LinkedList<>(); + // Input slots for the standalone (assembler-less) repair path. + private static final int REPAIR_SLOTS = 6; + private final EmbeddedInventory repairInventory = new EmbeddedInventory(REPAIR_SLOTS); + public TileRocketServiceStation() { super(10000); - destroyProbText = new ModuleText(90, 30, LibVulpes.proxy.getLocalizedString("msg.serviceStation.destroyProbNA"), 0x2b2b2b, true); - wornMotorsText = new ModuleText(40, 30 + 30, LibVulpes.proxy.getLocalizedString("msg.serviceStation.wornMotorsText"), 0x2b2b2b, true); - wornSeatsText = new ModuleText(90, 30 + 30, LibVulpes.proxy.getLocalizedString("msg.serviceStation.wornSeatsText"), 0x2b2b2b, true); - wornTanksText = new ModuleText(140, 30 + 30, LibVulpes.proxy.getLocalizedString("msg.serviceStation.wornTanksText"), 0x2b2b2b, true); - destroyProgressText = new ModuleText(90, 120, LibVulpes.proxy.getLocalizedString("msg.serviceStation.serviceProgressNA"), 0x2b2b2b, true); + // Compact layout: everything sits above the player inventory (y >= 89 + // in a MODULAR gui), so the repair-material slots stay reachable. + destroyProbText = new ModuleText(8, 46, LibVulpes.proxy.getLocalizedString("msg.serviceStation.destroyProbNA"), 0x2b2b2b, true); + wornMotorsText = new ModuleText(8, 56, LibVulpes.proxy.getLocalizedString("msg.serviceStation.wornMotorsText"), 0x2b2b2b, true); + wornSeatsText = new ModuleText(60, 56, LibVulpes.proxy.getLocalizedString("msg.serviceStation.wornSeatsText"), 0x2b2b2b, true); + wornTanksText = new ModuleText(112, 56, LibVulpes.proxy.getLocalizedString("msg.serviceStation.wornTanksText"), 0x2b2b2b, true); + destroyProgressText = new ModuleText(8, 76, LibVulpes.proxy.getLocalizedString("msg.serviceStation.serviceProgressNA"), 0x2b2b2b, true); - wornMotorsCount = new ModuleText(40, 30 + 30 + 10, "0", 0x2b2b2b, true); - wornSeatsCount = new ModuleText(90, 30 + 30 + 10, "0", 0x2b2b2b, true); - wornTanksCount = new ModuleText(140, 30 + 30 + 10, "0", 0x2b2b2b, true); + wornMotorsCount = new ModuleText(8, 66, "0", 0x2b2b2b, true); + wornSeatsCount = new ModuleText(60, 66, "0", 0x2b2b2b, true); + wornTanksCount = new ModuleText(112, 66, "0", 0x2b2b2b, true); } @Override @@ -228,8 +240,13 @@ private void consumePartToRepair(int assemblerIndex) { private void giveWorkToAssemblers() { boolean dirty = false; for (int i = 0; i < assemblers.size(); i++) { - if (assemblers.get(i).isInvalid()) { - // it is invalid, so we should not operate with it + if (assemblers.get(i) == null || assemblers.get(i).isInvalid()) { + // Assembler vanished mid-repair: re-queue the in-flight part so it + // is not silently lost, then drop the dead assembler slot. + if (partsProcessing[i] != null) { + partsToRepair.add(0, partsProcessing[i]); + statesToRepair.add(0, statesProcessing[i]); + } assemblers.set(i, null); partsProcessing[i] = null; statesProcessing[i] = null; @@ -281,7 +298,13 @@ public void performFunction() { } } - giveWorkToAssemblers(); + if (hasValidAssembler()) { + giveWorkToAssemblers(); + } else { + // No assembler nearby → repair one part from the station's own + // input slots at the configured resource penalty. + tryStandaloneRepair(); + } } } if (!getEquivalentPower()) { @@ -289,6 +312,136 @@ public void performFunction() { } } + /** The standalone-repair material input inventory (test/automation access). */ + public net.minecraftforge.items.IItemHandlerModifiable getRepairInventory() { + return repairInventory; + } + + @Override + public boolean hasCapability(@Nonnull net.minecraftforge.common.capabilities.Capability capability, + net.minecraft.util.EnumFacing facing) { + if (capability == net.minecraftforge.items.CapabilityItemHandler.ITEM_HANDLER_CAPABILITY) { + return true; + } + return super.hasCapability(capability, facing); + } + + @Override + public T getCapability(@Nonnull net.minecraftforge.common.capabilities.Capability capability, + net.minecraft.util.EnumFacing facing) { + // Expose the repair-material inventory so hoppers/pipes can feed it + // (works regardless of the GUI slot layout). + if (capability == net.minecraftforge.items.CapabilityItemHandler.ITEM_HANDLER_CAPABILITY) { + return net.minecraftforge.items.CapabilityItemHandler.ITEM_HANDLER_CAPABILITY.cast(repairInventory); + } + return super.getCapability(capability, facing); + } + + private boolean hasValidAssembler() { + for (TilePrecisionAssembler a : assemblers) { + if (a != null && !a.isInvalid()) { + return true; + } + } + return false; + } + + /** + * Repair one worn part using the station's own input inventory, consuming the + * part's PrecisionAssembler repair-recipe non-part ingredients times + * {@code serviceStationStandaloneRepairMultiplier}. No-op (leaves the part + * worn) if there is no repair recipe or the materials are missing. + */ + private boolean tryStandaloneRepair() { + if (partsToRepair.isEmpty()) { + return false; + } + TileBrokenPart part = partsToRepair.get(0); + IBlockState state = statesToRepair.get(0); + if (!(part.getBlockType() instanceof IBrokenPartBlock)) { + partsToRepair.remove(0); + statesToRepair.remove(0); + return false; + } + ItemStack worn = ((IBrokenPartBlock) part.getBlockType()).getDropItem(state, world, part); + IRecipe recipe = findRepairRecipe(worn); + if (recipe == null) { + // Not standalone-repairable (no recipe) — skip so the queue advances. + partsToRepair.remove(0); + statesToRepair.remove(0); + return false; + } + + double mult = ARConfiguration.getCurrentConfig().serviceStationStandaloneRepairMultiplier; + if (!consumeStandaloneMaterials(recipe, worn, mult, true)) { + return false; // not enough materials yet; keep the part queued + } + consumeStandaloneMaterials(recipe, worn, mult, false); + + part.setStage(0); + StorageChunk storage = ((EntityRocket) linkedRocket).storage; + storage.setBlockState(part.getPos(), state); + partsToRepair.remove(0); + statesToRepair.remove(0); + syncRocket(); + return true; + } + + private IRecipe findRepairRecipe(ItemStack worn) { + for (IRecipe recipe : RecipesMachine.getInstance().getRecipes(TilePrecisionAssembler.class)) { + for (List slot : recipe.getIngredients()) { + for (ItemStack variant : slot) { + if (ItemStack.areItemsEqual(variant, worn)) { + return recipe; + } + } + } + } + return null; + } + + /** + * Either check (simulate=true) or consume (simulate=false) the recipe's + * non-part ingredients ×mult from the station inventory. The part slot (the + * worn item itself) is skipped — only materials are charged. + */ + private boolean consumeStandaloneMaterials(IRecipe recipe, ItemStack worn, double mult, boolean simulate) { + for (List slot : recipe.getIngredients()) { + if (slot.isEmpty()) { + continue; + } + boolean isPartSlot = slot.stream().anyMatch(s -> ItemStack.areItemsEqual(s, worn)); + if (isPartSlot) { + continue; + } + int needed = (int) Math.ceil(slot.get(0).getCount() * mult); + if (needed <= 0) { + continue; + } + int remaining = needed; + for (int i = 0; i < repairInventory.getSlots() && remaining > 0; i++) { + ItemStack inSlot = repairInventory.getStackInSlot(i); + if (inSlot.isEmpty()) { + continue; + } + boolean matches = slot.stream().anyMatch( + v -> net.minecraftforge.oredict.OreDictionary.itemMatches(v, inSlot, false)); + if (!matches) { + continue; + } + int take = Math.min(remaining, inSlot.getCount()); + if (!simulate) { + repairInventory.extractItem(i, take, false); + } + remaining -= take; + } + if (remaining > 0) { + return false; // cannot satisfy this material + } + } + return true; + } + @Override public boolean canPerformFunction() { if (world.isRemote || world.getWorldTime() % 20 != 0) { @@ -370,6 +523,10 @@ public void readFromNBT(NBTTagCompound nbt) { super.readFromNBT(nbt); was_powered = nbt.getBoolean("was_powered"); initialPartToRepairCount = nbt.getInteger("initialPartToRepairCount"); + // Backward compatible: old saves lack these keys → empty inventory. + if (nbt.hasKey("repairInv")) { + repairInventory.readFromNBT(nbt.getCompoundTag("repairInv")); + } assemblerPoses = NBTHelper.readCollection("assemblerPoses", nbt, ArrayList::new, NBTHelper::readBlockPos); partsProcessing = NBTHelper.readCollection("partsProcessing", nbt, ArrayList::new, NBTHelper::readTileEntity).toArray(new TileBrokenPart[0]); @@ -382,6 +539,10 @@ public NBTTagCompound writeToNBT(NBTTagCompound nbt) { nbt.setBoolean("was_powered", was_powered); nbt.setInteger("initialPartToRepairCount", initialPartToRepairCount); + NBTTagCompound invTag = new NBTTagCompound(); + repairInventory.writeToNBT(invTag); + nbt.setTag("repairInv", invTag); + NBTHelper.writeCollection("assemblerPoses", nbt, this.assemblers, te -> NBTHelper.writeBlockPos(te.getPos())); NBTHelper.writeCollection("partsProcessing", nbt, Arrays.asList(this.partsProcessing), NBTHelper::writeTileEntity); NBTHelper.writeCollection("statesProcessing", nbt, Arrays.asList(this.statesProcessing), NBTHelper::writeState); @@ -404,8 +565,8 @@ public void readDataFromNetwork(ByteBuf in, byte packetId, public List getModules(int ID, EntityPlayer player) { LinkedList modules = new LinkedList<>(); - modules.add(new ModulePower(10, 20, this.energy)); - modules.add(new ModuleButton(63 - 52 / 2, 100, 0, LibVulpes.proxy.getLocalizedString("msg.serviceStation.assemblerScan"), + modules.add(new ModulePower(150, 8, this.energy)); + modules.add(new ModuleButton(8, 6, 0, LibVulpes.proxy.getLocalizedString("msg.serviceStation.assemblerScan"), this, zmaster587.libVulpes.inventory.TextureResources.buttonBuild, 104, 16)); updateText(); @@ -419,7 +580,11 @@ public List getModules(int ID, EntityPlayer player) { modules.add(wornSeatsCount); modules.add(wornTanksCount); - modules.add(new ModuleProgress(32, 133, 0, TextureResources.progressToMission, this)); + modules.add(new ModuleProgress(120, 26, 0, TextureResources.progressToMission, this)); + + // Input slots for the standalone repair path (materials when no assembler). + // Kept above y=89 so they don't collide with the player inventory. + modules.add(new ModuleSlotArray(8, 26, repairInventory, 0, REPAIR_SLOTS)); if (!world.isRemote) { PacketHandler.sendToPlayer(new PacketMachine(this, (byte) 1), player); @@ -437,20 +602,23 @@ private void updateText() { } EntityRocket rocket = (EntityRocket) linkedRocket; destroyProbText.setText(LibVulpes.proxy.getLocalizedString("msg.serviceStation.destroyProb") + ": " + rocket.storage.getBreakingProbability()); - List brokenParts = rocket.storage.getBrokenBlocks(); - long motorsCount = brokenParts - .stream() - .filter(te -> te.getStage() > 0 && (te.getBlockType() instanceof BlockRocketMotor - || te.getBlockType() instanceof BlockBipropellantRocketMotor)) - .count(); - long seatsCount = brokenParts - .stream() - .filter(te -> te.getStage() > 0 && te.getBlockType() instanceof BlockSeat) - .count(); - long tanksCount = brokenParts - .stream() - .filter(te -> te.getStage() > 0 && te.getBlockType() instanceof IFuelTank) - .count(); + + // Count worn parts via the wear capability so tanks/seats (which are + // TileWearable, not TileBrokenPart) are reflected, not just motors. + long motorsCount = 0, seatsCount = 0, tanksCount = 0; + for (TileEntity te : rocket.storage.getTileEntityList()) { + IPartWear wear = CapabilityWear.get(te); + if (wear == null || wear.getStage() <= 0) { + continue; + } + if (te.getBlockType() instanceof BlockRocketMotor || te.getBlockType() instanceof BlockBipropellantRocketMotor) { + motorsCount++; + } else if (te.getBlockType() instanceof BlockSeat) { + seatsCount++; + } else if (te.getBlockType() instanceof IFuelTank) { + tanksCount++; + } + } this.wornMotorsCount.setText(String.valueOf(motorsCount)); this.wornSeatsCount.setText(String.valueOf(seatsCount)); diff --git a/src/main/java/zmaster587/advancedRocketry/util/StorageChunk.java b/src/main/java/zmaster587/advancedRocketry/util/StorageChunk.java index d62ccd968..978b35f73 100644 --- a/src/main/java/zmaster587/advancedRocketry/util/StorageChunk.java +++ b/src/main/java/zmaster587/advancedRocketry/util/StorageChunk.java @@ -35,10 +35,13 @@ import zmaster587.advancedRocketry.AdvancedRocketry; import zmaster587.advancedRocketry.api.*; import zmaster587.advancedRocketry.api.fuel.FuelRegistry; +import zmaster587.advancedRocketry.api.fuel.FuelRegistry.FuelType; import zmaster587.advancedRocketry.api.satellite.SatelliteBase; import zmaster587.advancedRocketry.api.stations.IStorageChunk; import zmaster587.advancedRocketry.block.*; import zmaster587.advancedRocketry.item.ItemPackedStructure; +import zmaster587.advancedRocketry.api.capability.CapabilityWear; +import zmaster587.advancedRocketry.api.capability.IPartWear; import zmaster587.advancedRocketry.tile.TileBrokenPart; import zmaster587.advancedRocketry.tile.TileGuidanceComputer; import zmaster587.advancedRocketry.tile.hatch.TileSatelliteHatch; @@ -217,15 +220,19 @@ public void recalculateStats(StatsRocket stats) { } if (eligible) { + // Worn motors produce less thrust (partsWearSystem): a + // motor at max wear keeps (1 - wearThrustPenaltyMax) of + // its rated thrust. Feeds TWR → may fail the launch gate. + float wear = wearThrustFactor(currBlockPos); if (block instanceof BlockNuclearRocketMotor) { nuclearWorkingFluidUseMax += ((IRocketEngine) block).getFuelConsumptionRate(world, xCurr, yCurr, zCurr); - thrustNuclearNozzleLimit += ((IRocketEngine) block).getThrust(world, currBlockPos); + thrustNuclearNozzleLimit += (int) (((IRocketEngine) block).getThrust(world, currBlockPos) * wear); } else if (block instanceof BlockBipropellantRocketMotor) { bipropellantfuelUse += ((IRocketEngine) block).getFuelConsumptionRate(world, xCurr, yCurr, zCurr); - thrustBipropellant += ((IRocketEngine) block).getThrust(world, currBlockPos); + thrustBipropellant += (int) (((IRocketEngine) block).getThrust(world, currBlockPos) * wear); } else if (block instanceof BlockRocketMotor) { monopropellantfuelUse += ((IRocketEngine) block).getFuelConsumptionRate(world, xCurr, yCurr, zCurr); - thrustMonopropellant += ((IRocketEngine) block).getThrust(world, currBlockPos); + thrustMonopropellant += (int) (((IRocketEngine) block).getThrust(world, currBlockPos) * wear); } stats.addEngineLocation(xCurr - (float)this.sizeX/2 + 0.5f, yCurr+0.5f, zCurr - (float)this.sizeZ/2 + 0.5f); } @@ -787,9 +794,16 @@ public void pasteInWorld(World world, int xCoord, int yCoord, int zCoord) { } public void damageParts() { + // Single gate for wear ACCRUAL. When the parts-wear system is disabled no + // part ever advances a wear stage, so a worn save loaded with the system + // off neither grows nor (combined with the gated consequences) bites. + if (!ARConfiguration.getCurrentConfig().partsWearSystem) { + return; + } for (TileEntity tile : tileEntities) { - if (tile instanceof TileBrokenPart) { - ((TileBrokenPart) tile).transition(); + IPartWear wear = CapabilityWear.get(tile); + if (wear != null) { + wear.transition(); } } } @@ -867,12 +881,37 @@ public TileGuidanceComputer getGuidanceComputer() { return null; } + /** + * Thrust multiplier for a motor at the given position based on its wear + * stage: 1.0 when pristine, (1 - wearThrustPenaltyMax) when fully worn. + * Returns 1.0 when the wear system is off or the block has no wear state. + */ + private float wearThrustFactor(BlockPos pos) { + if (!ARConfiguration.getCurrentConfig().partsWearSystem) { + return 1f; + } + double maxPenalty = ARConfiguration.getCurrentConfig().wearThrustPenaltyMax; + if (maxPenalty <= 0) { + return 1f; + } + IPartWear wear = CapabilityWear.get(world.getTileEntity(pos)); + if (wear != null) { + int max = wear.getMaxStage(); + if (max <= 0) { + return 1f; + } + float frac = (float) wear.getStage() / max; // 0 = pristine, 1 = fully worn + return (float) Math.max(0.0, 1.0 - maxPenalty * frac); + } + return 1f; + } + public float getBreakingProbability() { float prob = 0; for (TileEntity te : tileEntities) { - if (te instanceof TileBrokenPart) { - TileBrokenPart brokenPart = (TileBrokenPart) te; + IPartWear wear = CapabilityWear.get(te); + if (wear != null) { float additionalProb = 0; if (te.getBlockType() instanceof BlockNuclearRocketMotor) { @@ -880,7 +919,7 @@ public float getBreakingProbability() { } else if (te.getBlockType() instanceof BlockRocketMotor || te.getBlockType() instanceof BlockBipropellantRocketMotor) { additionalProb = 0.2F; } - prob += additionalProb * brokenPart.getStage() / 10; + prob += additionalProb * wear.getStage() / 10; if (prob >= 1) { return Math.min(1, prob); } @@ -890,6 +929,79 @@ public float getBreakingProbability() { return prob; } + /** A worn fuel tank: which fuel type it holds and how worn it is (0..1). */ + public static class WornTank { + public final FuelType type; + public final float wornFraction; + + public WornTank(FuelType type, float wornFraction) { + this.type = type; + this.wornFraction = wornFraction; + } + } + + @Nullable + private static FuelType tankFuelType(Block b) { + // Subclasses first — Oxidizer/Bipropellant/Nuclear all extend BlockFuelTank. + if (b instanceof BlockOxidizerFuelTank) return FuelType.LIQUID_OXIDIZER; + if (b instanceof BlockBipropellantFuelTank) return FuelType.LIQUID_BIPROPELLANT; + if (b instanceof BlockNuclearFuelTank) return FuelType.NUCLEAR_WORKING_FLUID; + if (b instanceof BlockFuelTank) return FuelType.LIQUID_MONOPROPELLANT; + return null; + } + + /** Worn fuel tanks (stage > 0) with their fuel type and wear fraction. */ + public List getWornTanks() { + List res = new ArrayList<>(); + for (TileEntity te : tileEntities) { + IPartWear wear = CapabilityWear.get(te); + if (wear == null || wear.getMaxStage() <= 0 || wear.getStage() <= 0) { + continue; + } + FuelType ft = tankFuelType(te.getBlockType()); + if (ft != null) { + res.add(new WornTank(ft, (float) wear.getStage() / wear.getMaxStage())); + } + } + return res; + } + + /** True if any seat is worn at/above the given fraction of its max stage. */ + public boolean hasCriticallyWornSeat(double stageFraction) { + for (TileEntity te : tileEntities) { + IPartWear wear = CapabilityWear.get(te); + if (wear == null || wear.getMaxStage() <= 0) { + continue; + } + if (te.getBlockType() instanceof BlockSeat + && wear.getStage() >= Math.ceil(wear.getMaxStage() * stageFraction)) { + return true; + } + } + return false; + } + + /** + * Display stacks for every worn part (stage > 0) for the rocket GUI damage + * view: motors show their staged drop (with wear overlay), tanks/seats show + * their block icon. + */ + public List getWornPartDisplayStacks() { + List res = new ArrayList<>(); + for (TileEntity te : tileEntities) { + IPartWear wear = CapabilityWear.get(te); + if (wear == null || wear.getStage() <= 0) { + continue; + } + if (te instanceof TileBrokenPart) { + res.add(((TileBrokenPart) te).getDrop()); + } else if (te.getBlockType() != null) { + res.add(new ItemStack(te.getBlockType())); + } + } + return res; + } + public List getBrokenBlocks() { List res = new ArrayList<>(); diff --git a/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java b/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java index 3fe5f4751..ca8981ec0 100644 --- a/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java +++ b/src/main/java/zmaster587/advancedRocketry/util/WeightEngine.java @@ -3,7 +3,9 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonObject; +import com.google.gson.reflect.TypeToken; import net.minecraft.block.Block; +import net.minecraft.block.material.Material; import net.minecraft.item.ItemBlock; import net.minecraft.item.ItemStack; import net.minecraft.tileentity.TileEntity; @@ -26,66 +28,136 @@ import java.io.FileReader; import java.io.FileWriter; import java.io.Reader; +import java.lang.reflect.Type; import java.util.Collection; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +/** + * Resolves the weight (in kN, the unit the rocket maths uses) of any block, item or fluid. + * + * Resolution chain for a stack (first hit wins, per single item, before multiplying by count): + * 1. {@code individual} — explicit per-registry-name override from weights.json + * 2. {@code byRegex} — first matching regex over the registry name + * 3. AR component specifics (motor / tank / pressure tank / guidance / loader) + * 4. {@code materials} — by the block's {@link Material}, scaled by weightMaterialScale + * 5. {@code fallback} — global default, scaled by weightMaterialScale + * + * Only steps 4-5 are scaled by {@code weightMaterialScale}; explicit overrides and AR + * component values are intentional absolutes and are left untouched. + */ public enum WeightEngine { INSTANCE("config/advRocketry/weights.json"); + // AR component defaults (kN) — heavy, purpose-built parts that should not fall back to material. + private static final double TANK_WEIGHT = 0.2; + private static final double MOTOR_WEIGHT = 2; + private static final double GUIDANCE_COMPUTER_WEIGHT = 1.8; + private static final double PRESSURE_TANK_WEIGHT = 5; + private static final double SATELLITE_HATCH_WEIGHT = 5; + private final String file; - private Map weights; + + // Persisted, player-editable tables. + private Map individual = new HashMap<>(); + private Map byRegex = new LinkedHashMap<>(); + private Map fluids = new HashMap<>(); + private Map materials = new HashMap<>(); + private double fallback = 0.1; + private double fluidFallback = 0.001; + + // Transient runtime caches (not persisted; cleared on load()). + private final Map resolvedItemCache = new HashMap<>(); + private final Map compiledRegex = new HashMap<>(); WeightEngine(String file) { this.file = file; load(); } + private static double scale() { + return ARConfiguration.getCurrentConfig().weightMaterialScale; + } + public float getWeight(ItemStack stack) { - if (stack.isEmpty() || stack.getItem().getRegistryName()==null) { + if (stack.isEmpty() || stack.getItem().getRegistryName() == null) { return 0; } - double weight = weights.getOrDefault(stack.getItem().getRegistryName().toString(), -1.0) * stack.getCount(); - if (weight >= 0) { - return (float) weight; + String key = stack.getItem().getRegistryName().toString(); + return resolveUnitWeight(key, stack) * stack.getCount(); + } + + /** Weight of a single item (count == 1), memoised by registry name. */ + private float resolveUnitWeight(String key, ItemStack stack) { + Float cached = resolvedItemCache.get(key); + if (cached != null) { + return cached; } - double tankWeight = 0.2; - double motorWeight = 2; - double guidanceComputerWeight = 1.8; + float weight; + Double override = individual.get(key); + if (override != null) { + weight = override.floatValue(); + } else { + Double regex = matchRegex(key); + if (regex != null) { + weight = regex.floatValue(); + } else { + weight = componentOrMaterialWeight(key, stack); + } + } - double pressureTankWeight = 5; - double satelliteHatchWeight = 5; + resolvedItemCache.put(key, weight); + return weight; + } - // TODO Rewrite!!!! + private float componentOrMaterialWeight(String key, ItemStack stack) { if (stack.getItem() instanceof ItemBlock) { Block block = ((ItemBlock) stack.getItem()).getBlock(); - if (block instanceof BlockFuelTank){ - weights.put(stack.getItem().getRegistryName().toString(), (double) tankWeight); - return (float) tankWeight; + if (block instanceof BlockFuelTank) { + return (float) TANK_WEIGHT; } - if (block instanceof BlockRocketMotor || block instanceof BlockBipropellantRocketMotor){ - weights.put(stack.getItem().getRegistryName().toString(), (double) motorWeight); - return (float) motorWeight; + if (block instanceof BlockRocketMotor || block instanceof BlockBipropellantRocketMotor) { + return (float) MOTOR_WEIGHT; } - if (block instanceof BlockPressurizedFluidTank){ - weights.put(stack.getItem().getRegistryName().toString(), (double) pressureTankWeight); - return (float) pressureTankWeight; + if (block instanceof BlockPressurizedFluidTank) { + return (float) PRESSURE_TANK_WEIGHT; } - if (stack.getItem().getRegistryName().toString().equals("advancedrocketry:guidancecomputer")){ - weights.put(stack.getItem().getRegistryName().toString(), (double) guidanceComputerWeight); - return (float) guidanceComputerWeight; + if (key.equals("advancedrocketry:guidancecomputer")) { + return (float) GUIDANCE_COMPUTER_WEIGHT; } - if (stack.getItem().getRegistryName().toString().equals("advancedrocketry:loader")){ - weights.put(stack.getItem().getRegistryName().toString(), (double) satelliteHatchWeight); - return (float) satelliteHatchWeight; + if (key.equals("advancedrocketry:loader")) { + return (float) SATELLITE_HATCH_WEIGHT; + } + + Double materialWeight = materials.get(materialName(block.getDefaultState().getMaterial())); + if (materialWeight != null) { + return (float) (materialWeight * scale()); } } + return (float) (fallback * scale()); + } - weights.put(stack.getItem().getRegistryName().toString(), 0.1); - return 0.1F; - // TODO Make weight selection by regular expressions + private Double matchRegex(String key) { + for (Map.Entry e : byRegex.entrySet()) { + Pattern p = compiledRegex.get(e.getKey()); + if (p == null) { + try { + p = Pattern.compile(e.getKey()); + } catch (PatternSyntaxException ex) { + continue; + } + compiledRegex.put(e.getKey(), p); + } + if (p.matcher(key).matches()) { + return e.getValue(); + } + } + return null; } public float getWeight(Collection stacks) { @@ -101,20 +173,12 @@ public float getWeight(FluidStack stack) { } public float getWeight(Fluid fluid, float amount) { - double weight = weights.getOrDefault(fluid.getUnlocalizedName(), -1.0) * amount; - if (weight >= 0) { - return (float) weight; - } - - weight = 0.001 * amount; - - weights.put(fluid.getUnlocalizedName(), 0.001); - return (float) weight; + double perMb = fluids.getOrDefault(fluid.getName(), fluidFallback); + return (float) (perMb * amount * ARConfiguration.getCurrentConfig().fuelMassScale); } public float getTEWeight(TileEntity te) { - - if(!ARConfiguration.getCurrentConfig().advancedWeightSystemInventories) return 0; + if (!ARConfiguration.getCurrentConfig().advancedWeightSystemInventories) return 0; float weight = 0; @@ -129,7 +193,6 @@ public float getTEWeight(TileEntity te) { } } - IFluidHandler fluidHandler = te.getCapability(CapabilityFluidHandler.FLUID_HANDLER_CAPABILITY, null); if (fluidHandler != null) { for (IFluidTankProperties info : fluidHandler.getTankProperties()) { @@ -157,30 +220,194 @@ public float getWeight(World world, Collection poses) { } public void load() { + resolvedItemCache.clear(); + compiledRegex.clear(); File f = new File(file); if (!f.exists()) { - weights = new HashMap<>(); + seedDefaults(); + save(); return; } try (Reader r = new FileReader(file)) { - Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); - JsonObject root = GSON.fromJson(r, JsonObject.class); - weights = GSON.fromJson(root.getAsJsonObject("individual"), HashMap.class); + Gson gson = new GsonBuilder().disableHtmlEscaping().create(); + JsonObject root = gson.fromJson(r, JsonObject.class); + Type mapType = new TypeToken>() {}.getType(); + Type linkedType = new TypeToken>() {}.getType(); + + individual = readMap(gson, root, "individual", mapType); + byRegex = readMap(gson, root, "byRegex", linkedType); + fluids = readMap(gson, root, "fluids", mapType); + materials = readMap(gson, root, "materials", mapType); + if (materials.isEmpty()) { + materials = defaultMaterials(); + } + if (root.has("fallback")) { + fallback = root.get("fallback").getAsDouble(); + } + if (root.has("fluidFallback")) { + fluidFallback = root.get("fluidFallback").getAsDouble(); + } } catch (Exception e) { e.printStackTrace(); - weights = new HashMap<>(); // use empty map - System.out.println("The weight config was wrong, could not be read, was broken, not there or something else! An empty weight config will be used"); + seedDefaults(); + System.out.println("The weight config was wrong, could not be read, was broken, not there or something else! Defaults will be used"); } } + private static > T readMap(Gson gson, JsonObject root, String name, Type type) { + if (root.has(name) && root.get(name).isJsonObject()) { + T parsed = gson.fromJson(root.getAsJsonObject(name), type); + if (parsed != null) { + return parsed; + } + } + return gson.fromJson("{}", type); + } + + private void seedDefaults() { + individual = new HashMap<>(); + byRegex = new LinkedHashMap<>(); + fluids = new HashMap<>(); + materials = defaultMaterials(); + fallback = 0.1; + fluidFallback = 0.001; + } + + // ---- Runtime / test mutation hooks -------------------------------------- + + /** Reset every table to the built-in defaults and drop all caches. */ + public void resetTables() { + seedDefaults(); + resolvedItemCache.clear(); + compiledRegex.clear(); + } + + /** Drop the memoised per-item resolutions (call after changing scale config). */ + public void clearResolveCache() { + resolvedItemCache.clear(); + } + + /** Register an explicit per-registry-name weight (highest precedence). */ + public void setIndividual(String registryName, double weight) { + individual.put(registryName, weight); + resolvedItemCache.clear(); + } + + /** Register a regex rule matched against the registry name (below individual). */ + public void setRegex(String pattern, double weight) { + byRegex.put(pattern, weight); + compiledRegex.clear(); + resolvedItemCache.clear(); + } + + /** Test accessor: raw individual-override value, or null if none. */ + public Double rawIndividual(String registryName) { + return individual.get(registryName); + } + + /** Test accessor: number of material entries currently loaded. */ + public int materialCount() { + return materials.size(); + } + public void save() { + File parent = new File(file).getParentFile(); + if (parent != null) { + parent.mkdirs(); + } try (FileWriter w = new FileWriter(file)) { - Gson GSON = new GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create(); + Gson gson = new GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create(); JsonObject json = new JsonObject(); - json.add("individual", GSON.toJsonTree(weights)); - w.write(GSON.toJson(json)); + json.add("individual", gson.toJsonTree(individual)); + json.add("byRegex", gson.toJsonTree(byRegex)); + json.add("fluids", gson.toJsonTree(fluids)); + json.add("materials", gson.toJsonTree(materials)); + json.addProperty("fallback", fallback); + json.addProperty("fluidFallback", fluidFallback); + w.write(gson.toJson(json)); } catch (Exception e) { e.printStackTrace(); } } + + // ---- Material table ----------------------------------------------------- + + private static Map defaultMaterials() { + Map m = new LinkedHashMap<>(); + m.put("AIR", 0.0); + m.put("CLOTH", 0.05); + m.put("CARPET", 0.05); + m.put("WEB", 0.02); + m.put("PLANTS", 0.02); + m.put("VINE", 0.02); + m.put("LEAVES", 0.02); + m.put("CACTUS", 0.05); + m.put("GOURD", 0.1); + m.put("SNOW", 0.05); + m.put("CRAFTED_SNOW", 0.1); + m.put("SAND", 0.2); + m.put("GROUND", 0.2); + m.put("GRASS", 0.2); + m.put("CLAY", 0.25); + m.put("WOOD", 0.15); + m.put("GLASS", 0.1); + m.put("ICE", 0.15); + m.put("PACKED_ICE", 0.2); + m.put("CORAL", 0.2); + m.put("CAKE", 0.05); + m.put("CIRCUITS", 0.3); + m.put("REDSTONE_LIGHT", 0.3); + m.put("TNT", 0.3); + m.put("ROCK", 0.4); + m.put("IRON", 1.0); + m.put("ANVIL", 1.5); + return m; + } + + private static final Map MATERIAL_NAMES = buildMaterialNames(); + + private static Map buildMaterialNames() { + Map m = new HashMap<>(); + m.put(Material.AIR, "AIR"); + m.put(Material.GRASS, "GRASS"); + m.put(Material.GROUND, "GROUND"); + m.put(Material.WOOD, "WOOD"); + m.put(Material.ROCK, "ROCK"); + m.put(Material.IRON, "IRON"); + m.put(Material.ANVIL, "ANVIL"); + m.put(Material.WATER, "WATER"); + m.put(Material.LAVA, "LAVA"); + m.put(Material.LEAVES, "LEAVES"); + m.put(Material.PLANTS, "PLANTS"); + m.put(Material.VINE, "VINE"); + m.put(Material.SPONGE, "SPONGE"); + m.put(Material.CLOTH, "CLOTH"); + m.put(Material.FIRE, "FIRE"); + m.put(Material.SAND, "SAND"); + m.put(Material.CIRCUITS, "CIRCUITS"); + m.put(Material.CARPET, "CARPET"); + m.put(Material.GLASS, "GLASS"); + m.put(Material.REDSTONE_LIGHT, "REDSTONE_LIGHT"); + m.put(Material.TNT, "TNT"); + m.put(Material.CORAL, "CORAL"); + m.put(Material.ICE, "ICE"); + m.put(Material.PACKED_ICE, "PACKED_ICE"); + m.put(Material.SNOW, "SNOW"); + m.put(Material.CRAFTED_SNOW, "CRAFTED_SNOW"); + m.put(Material.CACTUS, "CACTUS"); + m.put(Material.CLAY, "CLAY"); + m.put(Material.GOURD, "GOURD"); + m.put(Material.DRAGON_EGG, "DRAGON_EGG"); + m.put(Material.PORTAL, "PORTAL"); + m.put(Material.CAKE, "CAKE"); + m.put(Material.WEB, "WEB"); + m.put(Material.PISTON, "PISTON"); + m.put(Material.BARRIER, "BARRIER"); + m.put(Material.STRUCTURE_VOID, "STRUCTURE_VOID"); + return m; + } + + private static String materialName(Material material) { + return MATERIAL_NAMES.getOrDefault(material, "UNKNOWN"); + } } diff --git a/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java b/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java index 915e00407..57c5cb05b 100644 --- a/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java +++ b/src/main/java/zmaster587/advancedRocketry/world/provider/WorldProviderPlanet.java @@ -116,7 +116,14 @@ public void calculateInitialWeather() { @Override public void updateWeather() { DimensionProperties props = getDimensionProperties(); - if (!props.usesCustomWorldInfo()) { + // Gate the custom weather cycle on the config flag too: with custom planet + // weather disabled we fall straight back to vanilla, even for planets whose + // XML carries non-default rain/thunder markers. Without this, the custom + // cycle keeps running against an UN-wrapped (shared overworld) WorldInfo and + // silently overwrites the overworld's weather — see PlanetWeatherManager. + if (!ARConfiguration.getCurrentConfig().perDimWorldInfo + || !ARConfiguration.getCurrentConfig().enableCustomPlanetWeather + || !props.usesCustomWorldInfo()) { super.updateWeather(); return; } diff --git a/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java b/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java index 23cceccf3..c66d1934b 100644 --- a/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java +++ b/src/main/java/zmaster587/advancedRocketry/world/weather/PlanetWeatherManager.java @@ -120,11 +120,14 @@ public static void markDirty(WorldServer world) { */ public static boolean shouldWrap(WorldServer world) { ARConfiguration cfg = ARConfiguration.getCurrentConfig(); - // 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; + // Gated by the perDimWorldInfo MASTER switch only (NOT by + // enableCustomPlanetWeather): while the master is on, AR planets are + // wrapped regardless of the weather sub-toggle so per-dimension time / + // working beds (issue #66) apply; whether the wrapper *manages weather* + // is decided separately by isWeatherManaged(). With the master off, no + // wrapper is installed at all (and ARMixinPlugin skips weaving the + // WorldInfo mixins) — fully vanilla shared-overworld WorldInfo. + if (cfg == null || !cfg.perDimWorldInfo) return false; if (world == null || world.isRemote) return false; if (world.provider == null) return false; int dim = world.provider.getDimension(); @@ -156,7 +159,7 @@ public static boolean shouldWrap(WorldServer world) { */ public static boolean isWeatherManaged(WorldServer world) { ARConfiguration cfg = ARConfiguration.getCurrentConfig(); - if (cfg == null) return false; + if (cfg == null || !cfg.perDimWorldInfo) return false; return cfg.enableCustomPlanetWeather || cfg.forcePlanetWeatherWorldInfoWrapper; } diff --git a/src/main/resources/assets/advancedrocketry/lang/en_US.lang b/src/main/resources/assets/advancedrocketry/lang/en_US.lang index 69d734209..de2b13b0c 100644 --- a/src/main/resources/assets/advancedrocketry/lang/en_US.lang +++ b/src/main/resources/assets/advancedrocketry/lang/en_US.lang @@ -240,6 +240,9 @@ mission.gascollection.name=Gas Collection error.rocket.notEnoughMissionFuel=Not enough fuel! error.rocket.tooHeavy=Rocket is too heavy to launch (insufficient thrust). +error.rocket.tooWorn=Rocket is too worn to launch safely (%s%% chance of failure). Service it first. +error.rocket.seatWorn=A seat is too worn for a crewed launch. Repair or replace it, or launch uncrewed. +warning.rocket.worn=§eWarning: worn rocket parts — %s%% chance of failure on launch. error.rocket.cannotGetThere=Selected destination cannot be reached. (Are you trying to land on a Gas Giant?) error.rocket.destinationNotExist=Selected space station does not exist. error.rocket.partsWornOut=Critical parts are worn out — launch aborted. @@ -911,9 +914,9 @@ tooltip.advancedrocketry.guidancecomputer.alt.2=when deploying Satellites or Spa # Service Monitor tooltip.advancedrocketry.servicemonitor=§cPart of Rocket -tooltip.advancedrocketry.servicemonitor.shift.1=Enables damage view -tooltip.advancedrocketry.servicemonitor.shift.2=in rocket GUI -tooltip.advancedrocketry.servicemonitor.alt.1=WIP +tooltip.advancedrocketry.servicemonitor.shift.1=Shows worn parts and failure +tooltip.advancedrocketry.servicemonitor.shift.2=chance in the rocket GUI +tooltip.advancedrocketry.servicemonitor.alt.1=Place one inside a rocket tooltip.advancedrocketry.servicemonitor.alt.2= # Docking Pad (landingPad) @@ -1029,8 +1032,8 @@ tooltip.advancedrocketry.fuelingstation.alt.2=§fRockets landing here will auto- # Service Station tooltip.advancedrocketry.servicestation=§cInfrastructure -tooltip.advancedrocketry.servicestation.shift.1=Repair rocket -tooltip.advancedrocketry.servicestation.shift.2=§o(WIP) +tooltip.advancedrocketry.servicestation.shift.1=Link to a rocket to repair +tooltip.advancedrocketry.servicestation.shift.2=worn parts (needs power) ## // Infrastructure diff --git a/src/main/resources/mixins.advancedrocketry.json b/src/main/resources/mixins.advancedrocketry.json index f9c4ed730..2ebf6a5f3 100644 --- a/src/main/resources/mixins.advancedrocketry.json +++ b/src/main/resources/mixins.advancedrocketry.json @@ -2,6 +2,7 @@ "required": true, "minVersion": "0.8", "package": "zmaster587.advancedRocketry.mixin", + "plugin": "zmaster587.advancedRocketry.mixin.ARMixinPlugin", "compatibilityLevel": "JAVA_8", "refmap": "mixins.advancedrocketry.refmap.json", "mixins": [ diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/FuelingStationFuelsAdjacentRocketTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/FuelingStationFuelsAdjacentRocketTest.java index a86ebd942..04188faa0 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/FuelingStationFuelsAdjacentRocketTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/FuelingStationFuelsAdjacentRocketTest.java @@ -54,6 +54,24 @@ public class FuelingStationFuelsAdjacentRocketTest extends AbstractHeadlessServe @Test public void stationDrainsTankAndRocketFuelRisesAfterLinkAndTick() throws Exception { + // ─── 0. Pre-clear terrain above the pad ──────────────────────── + // Natural overworld terrain (trees/hills) poking into the scan's + // bbCache volume confuses scanRocket's component detection, making + // fuel-tank counts depend on the biome at (RX,RZ) — the rocket then + // assembles with cap=0 and this test flakes under the parallel + // full-suite run (passes in isolation). Warm the chunks first so + // cross-chunk populate() (trees/leaves) has landed, THEN clear it — + // same mitigation as RocketAssemblySmokeTest#buildAndAssemble. + int cx1 = (RX - 2) >> 4, cz1 = (RZ - 2) >> 4; + int cx2 = (RX + 7) >> 4, cz2 = (RZ + 7) >> 4; + String warmup = join(client().execute( + "artest chunk warmup 0 " + cx1 + " " + cz1 + " " + cx2 + " " + cz2)); + assertTrue("chunk warmup failed: " + warmup, warmup.contains("\"ok\":true")); + String fillAir = join(client().execute( + "artest fill 0 " + (RX - 2) + " " + (RY + 1) + " " + (RZ - 2) + + " " + (RX + 7) + " " + (RY + 10) + " " + (RZ + 7) + " minecraft:air")); + assertTrue("pre-clear failed: " + fillAir, fillAir.contains("\"ok\":true")); + // ─── 1. Build + assemble rocket fixture ──────────────────────── String fixture = join(client().execute( "artest fixture rocket 0 " + RX + " " + RY + " " + RZ)); diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/NonARDimensionIsolationTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/NonARDimensionIsolationTest.java index b3a6a082e..735c0686e 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/server/NonARDimensionIsolationTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/server/NonARDimensionIsolationTest.java @@ -1,6 +1,7 @@ package zmaster587.advancedRocketry.test.server; import com.github.stannismod.forge.testing.junit.AbstractHeadlessServerTest; +import org.junit.Ignore; import org.junit.Test; import static org.junit.Assert.assertFalse; @@ -26,6 +27,14 @@ */ public class NonARDimensionIsolationTest extends AbstractHeadlessServerTest { + @Ignore("TASK-52: hangs at suite scale (~44th testServer class) — the `dim info " + + "-1/1` probes force a Nether/End load on the long-lived shared " + + "AbstractHeadlessServerTest server, which deadlocks after ~43 prior " + + "classes; passes 2/2 in isolation. Not a wrap-policy regression: the " + + "wrapper-isolation half of this contract (Nether/End NOT " + + "ARDimensionWorldInfo) is still pinned green by " + + "overworldAndVanillaDimsAreNotWrapped below. Only the isARPlanet " + + "classification check is parked here until the harness hang is fixed.") @Test public void netherAndEndAreNotARPlanets() throws Exception { String nether = String.join("\n", client().execute("artest dim info -1")); diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/PerDimWorldInfoMasterToggleTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/PerDimWorldInfoMasterToggleTest.java new file mode 100644 index 000000000..f2d4eb37b --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/PerDimWorldInfoMasterToggleTest.java @@ -0,0 +1,142 @@ +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.Pattern; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Disableability contract for the {@code perDimWorldInfo} MASTER switch + * (the single gate over AR's per-dimension WorldInfo subsystem: per-planet + * weather + per-planet time/sleep + wrapper install). + * + *

Two observable contracts, both pinned by lazily loading a planet under a + * specific config and reading the probe's named {@code worldInfoClass} field + * (per the artest-probe-authoring SOP). The flag is flipped at runtime BEFORE + * the fixture dim is ever loaded — wrapping is decided at dim load and is sticky + * for the dim's lifetime, so the load order is what makes each case + * deterministic.

+ * + *
    + *
  • OFF → vanilla. With {@code perDimWorldInfo=false}, a freshly + * loaded planet must keep the vanilla shared-overworld WorldInfo — NO + * {@code ARDimensionWorldInfo} wrapper. Fails if the master gate in + * {@code PlanetWeatherManager.shouldWrap} is reverted.
  • + *
  • Weather sub-toggle OFF, master ON → wrapper survives (the leak fix). + * With {@code perDimWorldInfo=true} but {@code enableCustomPlanetWeather=false}, + * the wrapper — which owns per-dimension TIME, not just weather — must STILL + * install. Fails if {@code shouldWrap}/{@code isWeatherManaged} are + * re-gated on the weather flag (the bug where turning weather off also + * killed per-dim time).
  • + *
+ */ +public class PerDimWorldInfoMasterToggleTest { + + private static final int FIXTURE_DIM = 9311; + private static final Pattern WRAPPED = + Pattern.compile("\"worldInfoClass\":\"[^\"]*ARDimensionWorldInfo\""); + + private Path workDir; + private RealDedicatedServerHarness harness; + + @Before + public void writePlanetFixture() 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-perdim-master-"); + 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)); + } + + @After + public void stopHarness() throws Exception { + if (harness != null) harness.close(); + } + + private String cmd(String c) throws Exception { + return String.join("\n", harness.client().execute(c)); + } + + private void assertDimRegistered() throws Exception { + String dimList = cmd("artest dim list"); + assertTrue("fixture dim not registered: " + dimList, + dimList.contains(String.valueOf(FIXTURE_DIM))); + } + + @Test + public void masterOffLeavesPlanetOnVanillaWorldInfo() throws Exception { + harness = RealDedicatedServerHarness.startWith(workDir, /*cleanupOnClose=*/true); + assertDimRegistered(); + + // Master OFF before the dim is EVER loaded → shouldWrap runtime-gates it + // out, so the first load keeps the vanilla DerivedWorldInfo. + assertTrue(cmd("artest config set perDimWorldInfo false").contains("\"ok\":true")); + + String info = cmd("artest weather get " + FIXTURE_DIM); // first load + assertFalse("with perDimWorldInfo OFF a freshly-loaded planet must NOT be " + + "wrapped (vanilla shared-overworld WorldInfo) — got " + info, + WRAPPED.matcher(info).find()); + } + + @Test + public void weatherOffButMasterOnKeepsTheWrapperForPerDimTime() throws Exception { + harness = RealDedicatedServerHarness.startWith(workDir, /*cleanupOnClose=*/true); + assertDimRegistered(); + + // Master ON (boot default, set explicitly for clarity) but the weather + // SUB-toggle OFF — the leak-fix contract: the wrapper that owns per-dim + // TIME must still install even though custom weather is disabled. + assertTrue(cmd("artest config set perDimWorldInfo true").contains("\"ok\":true")); + assertTrue(cmd("artest config set enableCustomPlanetWeather false").contains("\"ok\":true")); + + String info = cmd("artest weather get " + FIXTURE_DIM); // first load + assertTrue("perDimWorldInfo ON + weather OFF must STILL wrap the planet " + + "(per-dim time rides the wrapper) — got " + info, + WRAPPED.matcher(info).find()); + + // Tie the contract to TIME explicitly: the per-dim clock probe sees the + // wrapper with weather off (proves the time mechanism was not collateral + // damage of disabling weather). + String time = cmd("artest dim time " + FIXTURE_DIM); + assertTrue("dim-time probe must report the per-dim wrapper with weather " + + "OFF — got " + time, WRAPPED.matcher(time).find()); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/ServiceStationUnlinkedPerformFunctionTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/ServiceStationUnlinkedPerformFunctionTest.java new file mode 100644 index 000000000..04e52c9d0 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/ServiceStationUnlinkedPerformFunctionTest.java @@ -0,0 +1,69 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import static org.junit.Assert.assertTrue; +import static zmaster587.advancedRocketry.test.server.WorldCommandFixtures.exec; + +/** + * Regression guard for the standalone-repair null-deref invariant (PR #23 + * review note #5). + * + *

{@code TileRocketServiceStation.tryStandaloneRepair()} dereferences + * {@code ((EntityRocket) linkedRocket).storage} with no null/type check. That + * is safe by construction: {@code tryStandaloneRepair} is only ever + * reached from {@code performFunction()}'s {@code if (linkedRocket instanceof + * EntityRocket)} branch, and {@code unlinkRocket()} additionally clears + * {@code partsToRepair} — so the standalone path can never run with a null (or + * non-rocket) {@code linkedRocket}.

+ * + *

This pins that invariant directly: driving {@code performFunction} on a + * powered but UNLINKED service station must be a safe no-op — it must not reach + * the standalone-repair path and must not throw. The {@code service-perform- + * function} probe wraps the call in try/catch and reports {@code "performFunction + * threw"} on any {@link RuntimeException}, so a regression (the {@code + * instanceof} guard removed, or {@code tryStandaloneRepair} hoisted out of it) + * surfaces here as a failed {@code "ok":true} assertion rather than a silent + * NPE in production.

+ */ +public class ServiceStationUnlinkedPerformFunctionTest extends AbstractSharedServerTest { + + // Isolated lane, clear of the other service-station fixtures. + private static final int X = 16400; + private static final int Y = 70; + private static final int Z = 15900; + + @Test + public void performFunctionOnUnlinkedPoweredStationIsSafeNoOp() throws Exception { + int cx = X >> 4, cz = Z >> 4; + exec("artest chunk warmup 0 " + cx + " " + cz + " " + cx + " " + cz); + exec("artest fill 0 " + X + " " + Y + " " + Z + " " + X + " " + (Y + 1) + " " + + Z + " minecraft:air"); + + String place = exec("artest place 0 " + X + " " + Y + " " + Z + + " advancedrocketry:serviceStation"); + assertTrue("service station place failed: " + place, + place.contains("\"placed\":true")); + + // Power it (performFunction's getEquivalentPower gate) but DO NOT link a + // rocket — linkedRocket stays null. + exec("artest place 0 " + X + " " + (Y + 1) + " " + Z + " minecraft:redstone_block"); + + // Sanity: truly unlinked, empty repair queue. + String pre = exec("artest infra service-state 0 " + X + " " + Y + " " + Z); + assertTrue("station must be unlinked: " + pre, pre.contains("\"linkedRocketId\":-1")); + assertTrue("repair queue must be empty: " + pre, pre.contains("\"partsToRepairCount\":0")); + + // The concern: performFunction must NOT reach tryStandaloneRepair's + // ((EntityRocket) linkedRocket).storage with a null linkedRocket. + String pf = exec("artest infra service-perform-function 0 " + X + " " + Y + " " + Z); + assertTrue("performFunction on an unlinked powered station must be a safe " + + "no-op (no NPE/CCE reaching the standalone-repair path): " + pf, + pf.contains("\"ok\":true")); + + // State still sane after the no-op. + String post = exec("artest infra service-state 0 " + X + " " + Y + " " + Z); + assertTrue("repair queue still empty after no-op performFunction: " + post, + post.contains("\"partsToRepairCount\":0")); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/WearAccrualDisableTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/WearAccrualDisableTest.java new file mode 100644 index 000000000..2b669793b --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/WearAccrualDisableTest.java @@ -0,0 +1,92 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Disableability contract for the parts-wear system (TASK-46 fix 2). + * + *

Wear ACCRUAL goes through {@code StorageChunk.damageParts()}. With + * {@code partsWearSystem} off, no part may advance a wear stage, so a rocket + * driven through that entry point any number of times keeps a zero breaking + * probability; with the system on, its motors wear and the breaking probability + * rises. This pins the player-facing promise that turning the wear system off in + * the config stops parts wearing at all (the consequences — thrust loss, tank + * leak, seat block — are already gated and covered by {@code WearSystemTest}).

+ */ +public class WearAccrualDisableTest extends AbstractSharedServerTest { + + private static final Pattern BUILDER_POS = + Pattern.compile("\"builderPos\":\\[(-?\\d+),(-?\\d+),(-?\\d+)]"); + private static final Pattern ROCKET_LIST_ID = Pattern.compile("\"id\":(-?\\d+)"); + private static final Pattern BREAKING_PROB = + Pattern.compile("\"breakingProb\":(-?\\d+(?:\\.\\d+)?)"); + + private String cmd(String c) throws Exception { + return String.join("\n", client().execute(c)); + } + + private void preClear(int baseX, int baseY, int baseZ) throws Exception { + int cx1 = (baseX - 2) >> 4, cz1 = (baseZ - 2) >> 4; + int cx2 = (baseX + 7) >> 4, cz2 = (baseZ + 7) >> 4; + client().execute("artest chunk warmup 0 " + cx1 + " " + cz1 + " " + cx2 + " " + cz2); + client().execute("artest fill 0 " + (baseX - 2) + " " + (baseY + 1) + " " + (baseZ - 2) + + " " + (baseX + 7) + " " + (baseY + 10) + " " + (baseZ + 7) + " minecraft:air"); + } + + private int buildAndAssemble(int baseX, int baseY, int baseZ) throws Exception { + preClear(baseX, baseY, baseZ); + String fixture = cmd("artest fixture rocket 0 " + baseX + " " + baseY + " " + baseZ + " simple"); + assertTrue("fixture build failed: " + fixture, fixture.contains("\"ok\":true")); + Matcher bp = BUILDER_POS.matcher(fixture); + assertTrue("no builderPos: " + fixture, bp.find()); + String assemble = cmd("artest rocket assemble 0 " + + bp.group(1) + " " + bp.group(2) + " " + bp.group(3)); + assertTrue("assemble failed: " + assemble, assemble.contains("\"ok\":true")); + String list = cmd("artest rocket list 0"); + Matcher m = ROCKET_LIST_ID.matcher(list); + int id = -1; + while (m.find()) id = Integer.parseInt(m.group(1)); + assertTrue("no rocket id after assemble: " + list, id >= 0); + return id; + } + + private double damagePartsAndReadProb(int rocketId, int iterations) throws Exception { + String r = cmd("artest wear damage-parts " + rocketId + " " + iterations); + assertTrue("damage-parts must find the rocket: " + r, r.contains("\"found\":true")); + Matcher m = BREAKING_PROB.matcher(r); + assertTrue("no breakingProb in damage-parts response: " + r, m.find()); + return Double.parseDouble(m.group(1)); + } + + @Test + public void wearAccruesOnlyWhenSystemEnabled() throws Exception { + try { + // Make motors wear deterministically fast so the "on" case is not flaky. + assertTrue(cmd("artest config set increaseWearIntensityProb 1.0").contains("\"ok\":true")); + + // --- system ON: a worn motor raises the breaking probability --- + assertTrue(cmd("artest config set partsWearSystem true").contains("\"ok\":true")); + int onRocket = buildAndAssemble(3200, 64, 3200); + double probOn = damagePartsAndReadProb(onRocket, 200); + assertTrue("with the wear system ON, driving damageParts must accrue wear " + + "(breaking probability > 0), got " + probOn, probOn > 0); + + // --- system OFF: identical driving accrues nothing --- + assertTrue(cmd("artest config set partsWearSystem false").contains("\"ok\":true")); + int offRocket = buildAndAssemble(3260, 64, 3200); + double probOff = damagePartsAndReadProb(offRocket, 200); + assertEquals("with the wear system OFF, damageParts must not advance any wear " + + "stage (breaking probability stays 0)", 0.0, probOff, 1e-9); + } finally { + // Restore shared-harness defaults for any later test in this JVM. + client().execute("artest config set partsWearSystem true"); + client().execute("artest config set increaseWearIntensityProb 0.025"); + } + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/WearSystemTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/WearSystemTest.java new file mode 100644 index 000000000..7adda7799 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/WearSystemTest.java @@ -0,0 +1,210 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Foundation coverage for the TASK-45 parts-wear rework (phases 0–0c): + * + *
    + *
  • motors, fuel tanks and seats host the wear capability in the world;
  • + *
  • {@code wear get/set} round-trips a stage;
  • + *
  • worn motors produce less thrust after assembly (graduated consequence).
  • + *
+ * + *

The launch-time consequences (tank leak / explosion / seat-block) need a + * pilot or stochastic launch and are covered later; this pins the data model + * and the thrust contract that feeds TWR.

+ */ +public class WearSystemTest extends AbstractSharedServerTest { + + private static final Pattern BUILDER_POS = Pattern.compile("\"builderPos\":\\[(-?\\d+),(-?\\d+),(-?\\d+)]"); + private static final Pattern ROCKET_LIST_ID = Pattern.compile("\"id\":(-?\\d+)"); + + private void preClear(int baseX, int baseY, int baseZ) throws Exception { + int cx1 = (baseX - 2) >> 4, cz1 = (baseZ - 2) >> 4; + int cx2 = (baseX + 7) >> 4, cz2 = (baseZ + 7) >> 4; + client().execute("artest chunk warmup 0 " + cx1 + " " + cz1 + " " + cx2 + " " + cz2); + client().execute("artest fill 0 " + (baseX - 2) + " " + (baseY + 1) + " " + (baseZ - 2) + + " " + (baseX + 7) + " " + (baseY + 10) + " " + (baseZ + 7) + " minecraft:air"); + } + + private int[] buildFixture(int baseX, int baseY, int baseZ) throws Exception { + preClear(baseX, baseY, baseZ); + String fixture = String.join("\n", client().execute( + "artest fixture rocket 0 " + baseX + " " + baseY + " " + baseZ + " simple")); + assertTrue("fixture build failed: " + fixture, fixture.contains("\"ok\":true")); + Matcher bp = BUILDER_POS.matcher(fixture); + assertTrue("no builderPos: " + fixture, bp.find()); + return new int[]{Integer.parseInt(bp.group(1)), Integer.parseInt(bp.group(2)), Integer.parseInt(bp.group(3))}; + } + + private int assembleAndGetId(int[] builderPos) throws Exception { + String assemble = String.join("\n", client().execute( + "artest rocket assemble 0 " + builderPos[0] + " " + builderPos[1] + " " + builderPos[2])); + assertTrue("assemble failed: " + assemble, assemble.contains("\"ok\":true")); + String list = String.join("\n", client().execute("artest rocket list 0")); + Matcher m = ROCKET_LIST_ID.matcher(list); + int id = -1; + while (m.find()) id = Integer.parseInt(m.group(1)); + assertTrue("no rocket id after assemble: " + list, id >= 0); + return id; + } + + private int thrustOf(int entityId) throws Exception { + String info = String.join("\n", client().execute("artest rocket info " + entityId)); + Matcher m = Pattern.compile("\"thrust\":(-?\\d+)").matcher(info); + assertTrue("no thrust in info: " + info, m.find()); + return Integer.parseInt(m.group(1)); + } + + @Test + public void motorTankSeatHostWearCapability() throws Exception { + int bx = 2900, by = 64, bz = 2900; + buildFixture(bx, by, bz); + int rocketX = bx + 3, rocketY = by + 1, rocketZ = bz + 3; + + // Engine, fuel tank, seat positions (see fixture builder). + String engine = String.join("\n", client().execute( + "artest wear get 0 " + (rocketX - 1) + " " + rocketY + " " + rocketZ)); + assertTrue("motor must host wear cap: " + engine, engine.contains("\"registered\":true")); + + String tank = String.join("\n", client().execute( + "artest wear get 0 " + rocketX + " " + (rocketY + 1) + " " + rocketZ)); + assertTrue("fuel tank must host wear cap: " + tank, tank.contains("\"registered\":true")); + + String seat = String.join("\n", client().execute( + "artest wear get 0 " + rocketX + " " + (rocketY + 4) + " " + rocketZ)); + assertTrue("seat must host wear cap: " + seat, seat.contains("\"registered\":true")); + } + + @Test + public void wearStageRoundTripsThroughCapability() throws Exception { + int bx = 2960, by = 64, bz = 2900; + buildFixture(bx, by, bz); + int ex = bx + 3 - 1, ey = by + 1, ez = bz + 3; + + String set = String.join("\n", client().execute("artest wear set 0 " + ex + " " + ey + " " + ez + " 7")); + assertTrue("wear set failed: " + set, set.contains("\"ok\":true")); + + String get = String.join("\n", client().execute("artest wear get 0 " + ex + " " + ey + " " + ez)); + Matcher m = Pattern.compile("\"stage\":(\\d+)").matcher(get); + assertTrue("no stage in get: " + get, m.find()); + assertEquals("wear stage must persist", 7, Integer.parseInt(m.group(1))); + } + + private double breakingProbOf(int entityId) throws Exception { + String info = String.join("\n", client().execute("artest rocket info " + entityId)); + Matcher m = Pattern.compile("\"breakingProb\":(-?\\d+(?:\\.\\d+)?)").matcher(info); + assertTrue("no breakingProb in info: " + info, m.find()); + return Double.parseDouble(m.group(1)); + } + + @Test + public void wornMotorRaisesBreakingProbability() throws Exception { + // Pristine rocket: zero failure probability. + int ax = 2900, ay = 64, az = 3020; + int pristine = assembleAndGetId(buildFixture(ax, ay, az)); + assertEquals("pristine rocket must have zero breaking probability", + 0.0, breakingProbOf(pristine), 1e-6); + + // Max out one engine's wear before assembly → breaking probability rises. + int bx = 2960, by = 64, bz = 3020; + int[] builder = buildFixture(bx, by, bz); + int rocketX = bx + 3, rocketY = by + 1, rocketZ = bz + 3; + client().execute("artest wear set 0 " + (rocketX - 1) + " " + rocketY + " " + rocketZ + " 10"); + int worn = assembleAndGetId(builder); + assertTrue("a fully-worn motor must raise the breaking probability", + breakingProbOf(worn) > 0); + } + + @Test + public void standaloneRepairResetsMotorWear() throws Exception { + int bx = 2900, by = 64, bz = 3080; + int[] builder = buildFixture(bx, by, bz); + int rocketId = assembleAndGetId(builder); + // Wear one motor to stage 5 (no PrecisionAssembler nearby → standalone path). + String inject = String.join("\n", client().execute("artest infra inject-broken-part " + rocketId + " 5")); + assertTrue("inject-broken-part failed: " + inject, inject.contains("\"ok\":true")); + assertTrue("worn motor must give a non-zero breaking probability", + breakingProbOf(rocketId) > 0); + + // Service station off to the side, with its own clear pocket + redstone power. + int sx = bx - 4, sy = by + 1, sz = bz; + client().execute("artest fill 0 " + (sx - 1) + " " + sy + " " + (sz - 1) + + " " + (sx + 1) + " " + (sy + 2) + " " + (sz + 1) + " minecraft:air"); + String place = String.join("\n", client().execute( + "artest place 0 " + sx + " " + sy + " " + sz + " advancedrocketry:serviceStation")); + assertTrue("service station place failed: " + place, place.contains("\"placed\":true")); + // Redstone power — performFunction requires getEquivalentPower=true. + client().execute("artest place 0 " + sx + " " + (sy + 1) + " " + sz + " minecraft:redstone_block"); + + String link = String.join("\n", client().execute( + "artest infra link 0 " + sx + " " + sy + " " + sz + " " + rocketId)); + assertTrue("link failed: " + link, link.contains("\"ok\":true")); + + // Load the stage-5 repair recipe's non-part materials (ingot + plate), + // each well above the x3 standalone multiplier. + String load0 = String.join("\n", client().execute( + "artest wear station-load 0 " + sx + " " + sy + " " + sz + " 0 ore:ingotTitaniumIridium 16")); + assertTrue("station-load ingot failed: " + load0, load0.contains("\"ok\":true")); + String load1 = String.join("\n", client().execute( + "artest wear station-load 0 " + sx + " " + sy + " " + sz + " 1 ore:plateTitaniumAluminide 16")); + assertTrue("station-load plate failed: " + load1, load1.contains("\"ok\":true")); + + // Drive performFunction directly (no assembler → standalone repair branch). + client().execute("artest infra service-perform-function 0 " + sx + " " + sy + " " + sz); + client().execute("artest infra service-perform-function 0 " + sx + " " + sy + " " + sz); + + assertEquals("standalone repair must reset the worn motor (breaking prob back to 0)", + 0.0, breakingProbOf(rocketId), 1e-6); + } + + @Test + public void wornTankAndSeatSurfaceForLaunchGate() throws Exception { + int bx = 2960, by = 64, bz = 3080; + int[] builder = buildFixture(bx, by, bz); + int rocketX = bx + 3, rocketY = by + 1, rocketZ = bz + 3; + client().execute("artest wear set 0 " + rocketX + " " + (rocketY + 1) + " " + rocketZ + " 8"); // a fuel tank + client().execute("artest wear set 0 " + rocketX + " " + (rocketY + 4) + " " + rocketZ + " 10"); // the seat + int rocketId = assembleAndGetId(builder); + + String status = String.join("\n", client().execute("artest wear rocket-status " + rocketId + " 0.7")); + assertTrue("rocket-status must find the rocket: " + status, status.contains("\"found\":true")); + + Matcher tanks = Pattern.compile("\"wornTankCount\":(\\d+)").matcher(status); + assertTrue("no wornTankCount: " + status, tanks.find()); + assertTrue("a worn fuel tank must be surfaced for the launch gate: " + status, + Integer.parseInt(tanks.group(1)) >= 1); + assertTrue("a critically-worn seat must be detected: " + status, + status.contains("\"hasCriticallyWornSeat\":true")); + } + + @Test + public void wornMotorsProduceLessThrust() throws Exception { + // Pristine reference rocket. + int[] pristineBuilder = {0, 0, 0}; + int ax = 2900, ay = 64, az = 2960; + pristineBuilder = buildFixture(ax, ay, az); + int pristineThrust = thrustOf(assembleAndGetId(pristineBuilder)); + assertTrue("pristine thrust must be positive", pristineThrust > 0); + + // Worn rocket: max out both engine wear stages before assembly. + int bx = 2960, by = 64, bz = 2960; + int[] wornBuilder = buildFixture(bx, by, bz); + int rocketX = bx + 3, rocketY = by + 1, rocketZ = bz + 3; + client().execute("artest wear set 0 " + (rocketX - 1) + " " + rocketY + " " + rocketZ + " 10"); + client().execute("artest wear set 0 " + (rocketX + 1) + " " + rocketY + " " + rocketZ + " 10"); + int wornThrust = thrustOf(assembleAndGetId(wornBuilder)); + + assertTrue("worn rocket must still have some thrust: " + wornThrust, wornThrust > 0); + assertTrue("fully-worn motors must produce less thrust than pristine (" + + wornThrust + " vs " + pristineThrust + ")", + wornThrust < pristineThrust); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/WeatherCycleDisableTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/WeatherCycleDisableTest.java new file mode 100644 index 000000000..5189b9ca0 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/WeatherCycleDisableTest.java @@ -0,0 +1,144 @@ +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.Pattern; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Disableability contract for the custom planet weather CYCLE (TASK-46 fix 3). + * + *

{@code WorldProviderPlanet.updateWeather()} overrides the vanilla weather + * cycle for planets whose XML carries non-default rain/thunder markers. The bug: + * that override keyed only off the markers, so it kept forcing weather even with + * {@code enableCustomPlanetWeather} off — overwriting the (un-wrapped) shared + * overworld weather. The fix gates the override on the config flag too.

+ * + *

Contract pinned here, deterministically, by driving {@code updateWeather()} + * directly via a probe: with a forced-clear marker (rain = -1) set on a planet + * that we've just made rain, one weather tick suppresses the rain when the flag + * is ON (custom cycle runs) but leaves it raining when the flag is OFF (vanilla + * delegation). The marker stays set across both cases; only the config flips.

+ */ +public class WeatherCycleDisableTest { + + private static final int FIXTURE_DIM = 9301; + + private Path workDir; + private RealDedicatedServerHarness harness; + + @Before + public void writePlanetFixture() 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-weather-disable-"); + 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)); + } + + @After + public void stopHarness() throws Exception { + if (harness != null) harness.close(); + } + + private String cmd(String c) throws Exception { + return String.join("\n", harness.client().execute(c)); + } + + private boolean isRaining(int dim) throws Exception { + return cmd("artest weather get " + dim).contains("\"isRaining\":true"); + } + + @Test + public void customWeatherCycleRunsOnlyWhenConfigEnabled() throws Exception { + harness = RealDedicatedServerHarness.startWith(workDir, /*cleanupOnClose=*/true); + + String dimList = cmd("artest dim list"); + assertTrue("fixture dim not registered: " + dimList, + dimList.contains(String.valueOf(FIXTURE_DIM))); + + // Load the planet while custom weather is still ENABLED (boot default) so it + // wraps with its own ARDimensionWorldInfo. Wrapping is sticky for the dim's + // lifetime, so the later config-off sub-case operates on the same wrapped, + // overworld-isolated WorldInfo — isolating the updateWeather() gate from the + // separate (already-tested) wrapping gate. + assertTrue(cmd("artest config set enableCustomPlanetWeather true").contains("\"ok\":true")); + String wrapped = cmd("artest weather get " + FIXTURE_DIM); + // Anchor on the probe's named worldInfoClass field, not a bare substring + // of the whole response (see artest-probe-authoring SOP). + assertTrue("planet must be wrapped while custom weather is on: " + wrapped, + Pattern.compile("\"worldInfoClass\":\"[^\"]*ARDimensionWorldInfo\"") + .matcher(wrapped).find()); + + // Forced-clear marker (rain=-1, thunder=-1): the custom cycle, when it runs, + // drives this planet to clear regardless of what we set. + String marker = cmd("artest weather set-marker " + FIXTURE_DIM + " -1 -1"); + assertTrue("set-marker failed: " + marker, marker.contains("\"usesCustomWorldInfo\":true")); + + // --- config ON: the forced-clear cycle runs and suppresses the rain --- + // (No intermediate "is raining" assert — with the cycle active the natural + // server tick clears it before we could observe it; the post-tick state is + // the deterministic contract.) + assertTrue("weather set rain failed", + cmd("artest weather set " + FIXTURE_DIM + " rain 12000").contains("\"ok\":true")); + assertTrue("tick-provider failed", + cmd("artest weather tick-provider " + FIXTURE_DIM + " 3").contains("\"ok\":true")); + String onAfterTick = cmd("artest weather get " + FIXTURE_DIM); + assertFalse("with custom planet weather ON, the forced-clear cycle must suppress the " + + "rain — got " + onAfterTick, onAfterTick.contains("\"isRaining\":true")); + + // --- config OFF (the fix): updateWeather delegates to vanilla; the custom + // forced-clear cycle does NOT run, so rain we set takes and survives ticks. + // This fails if the fix is reverted (the marker cycle would clear it). + assertTrue(cmd("artest config set enableCustomPlanetWeather false").contains("\"ok\":true")); + assertTrue("weather set rain failed", + cmd("artest weather set " + FIXTURE_DIM + " rain 12000").contains("\"ok\":true")); + String offAfterSet = cmd("artest weather get " + FIXTURE_DIM); + assertTrue("with custom planet weather OFF, set rain must take (no custom cycle to " + + "suppress it) — got " + offAfterSet, offAfterSet.contains("\"isRaining\":true")); + assertTrue("tick-provider failed", + cmd("artest weather tick-provider " + FIXTURE_DIM + " 3").contains("\"ok\":true")); + String offAfterTick = cmd("artest weather get " + FIXTURE_DIM); + assertTrue("with custom planet weather OFF, the rain must survive weather ticks " + + "(vanilla delegation, marker ignored) — got " + offAfterTick, + offAfterTick.contains("\"isRaining\":true")); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/server/WeightSystemTest.java b/src/test/java/zmaster587/advancedRocketry/test/server/WeightSystemTest.java new file mode 100644 index 000000000..2ce611279 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/server/WeightSystemTest.java @@ -0,0 +1,128 @@ +package zmaster587.advancedRocketry.test.server; + +import org.junit.Test; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Contract coverage for {@link zmaster587.advancedRocketry.util.WeightEngine} + * exercised against real (registered) blocks and fluids in a booted server. + * + *

These tests pin the contracts of the weight resolution chain, not + * the exact kN constants in the default material table:

+ * + *
    + *
  • heavier materials resolve to a larger weight than lighter ones;
  • + *
  • stack count multiplies the per-item weight;
  • + *
  • resolution precedence: individual override > regex > material;
  • + *
  • {@code weightMaterialScale} scales material-derived weights;
  • + *
  • fluid weight uses the fallback per-mB rate and {@code fuelMassScale}.
  • + *
+ * + *

Every method calls {@code /artest weight reset} first so the shared + * WeightEngine singleton + the two scale config keys start from defaults + * (see {@link AbstractSharedServerTest} state-leak contract).

+ */ +public class WeightSystemTest extends AbstractSharedServerTest { + + private static final Pattern WEIGHT = Pattern.compile("\"weight\":(-?\\d+(?:\\.\\d+)?)"); + + private void reset() throws Exception { + String r = String.join("\n", client().execute("artest weight reset")); + assertTrue("weight reset failed: " + r, r.contains("\"ok\":true")); + } + + private double itemWeight(String id, int count) throws Exception { + String r = String.join("\n", client().execute("artest weight item " + id + " " + count)); + assertTrue("item " + id + " not registered: " + r, r.contains("\"registered\":true")); + Matcher m = WEIGHT.matcher(r); + assertTrue("no weight field for " + id + ": " + r, m.find()); + return Double.parseDouble(m.group(1)); + } + + private double fluidWeight(String name, int amount) throws Exception { + String r = String.join("\n", client().execute("artest weight fluid " + name + " " + amount)); + assertTrue("fluid " + name + " not registered: " + r, r.contains("\"registered\":true")); + Matcher m = WEIGHT.matcher(r); + assertTrue("no weight field for fluid " + name + ": " + r, m.find()); + return Double.parseDouble(m.group(1)); + } + + @Test + public void heavierMaterialsWeighMore() throws Exception { + reset(); + double iron = itemWeight("minecraft:iron_block", 1); // Material.IRON + double stone = itemWeight("minecraft:stone", 1); // Material.ROCK + double glass = itemWeight("minecraft:glass", 1); // Material.GLASS + double wool = itemWeight("minecraft:wool", 1); // Material.CLOTH + + assertTrue("all material weights must be positive", iron > 0 && stone > 0 && glass > 0 && wool > 0); + assertTrue("iron must be heavier than stone (" + iron + " vs " + stone + ")", iron > stone); + assertTrue("stone must be heavier than glass (" + stone + " vs " + glass + ")", stone > glass); + assertTrue("glass must be at least as heavy as wool (" + glass + " vs " + wool + ")", glass >= wool); + } + + @Test + public void stackCountMultipliesWeight() throws Exception { + reset(); + double one = itemWeight("minecraft:iron_block", 1); + double four = itemWeight("minecraft:iron_block", 4); + assertEquals("weight must scale linearly with stack count", 4 * one, four, 1e-4); + } + + @Test + public void individualOverrideBeatsMaterial() throws Exception { + reset(); + double material = itemWeight("minecraft:stone", 1); + assertTrue("baseline material weight must differ from the override sentinel", material != 99.0); + + String set = String.join("\n", client().execute("artest weight set minecraft:stone 99.0")); + assertTrue("weight set failed: " + set, set.contains("\"ok\":true")); + + assertEquals("explicit individual override must win over the material table", + 99.0, itemWeight("minecraft:stone", 1), 1e-4); + } + + @Test + public void regexBeatsMaterialButIndividualBeatsRegex() throws Exception { + reset(); + String reg = String.join("\n", client().execute("artest weight set-regex minecraft:gla.* 3.0")); + assertTrue("set-regex failed: " + reg, reg.contains("\"ok\":true")); + assertEquals("regex rule must win over the material table", + 3.0, itemWeight("minecraft:glass", 1), 1e-4); + + String set = String.join("\n", client().execute("artest weight set minecraft:glass 50.0")); + assertTrue("weight set failed: " + set, set.contains("\"ok\":true")); + assertEquals("individual override must win over a matching regex rule", + 50.0, itemWeight("minecraft:glass", 1), 1e-4); + } + + @Test + public void materialScaleScalesMaterialWeights() throws Exception { + reset(); + double base = itemWeight("minecraft:stone", 1); + + String sc = String.join("\n", client().execute("artest weight material-scale 2.0")); + assertTrue("material-scale failed: " + sc, sc.contains("\"ok\":true")); + + assertEquals("material weight must scale by weightMaterialScale", + 2 * base, itemWeight("minecraft:stone", 1), 1e-4); + } + + @Test + public void fluidWeightUsesFallbackAndFuelScale() throws Exception { + reset(); + double base = fluidWeight("water", 1000); + assertTrue("fluid weight must be positive: " + base, base > 0); + + String sc = String.join("\n", client().execute("artest weight fuel-scale 2.0")); + assertTrue("fuel-scale failed: " + sc, sc.contains("\"ok\":true")); + + assertEquals("fluid weight must scale by fuelMassScale", + 2 * base, fluidWeight("water", 1000), 1e-4); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/ARMixinPluginTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/ARMixinPluginTest.java new file mode 100644 index 000000000..b712c9c56 --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/ARMixinPluginTest.java @@ -0,0 +1,70 @@ +package zmaster587.advancedRocketry.test.unit; + +import org.junit.Test; +import zmaster587.advancedRocketry.mixin.ARMixinPlugin; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Disableability contract for the per-dimension WorldInfo MIXINS. + * + *

{@link ARMixinPlugin#shouldApply} is the pure decision the mixin runtime + * consults per target class: the three WorldInfo mixins — {@code + * MixinWorldServerMulti} (wrapper install), {@code MixinWorldServer} (per-dim + * time / sleep) and {@code MixinPlayerList} (weather sync) — are woven in iff + * the {@code perDimWorldInfo} master flag is enabled; every other AR mixin + * always applies. This pins the promise that "perDimWorldInfo off in the config" + * means those mixins aren't even woven (not merely no-ops at runtime), AND that + * the per-dim TIME mixin rides the SAME master flag — so turning the weather + * sub-toggle off can never accidentally un-weave per-dim time.

+ * + *

Why no end-to-end weave test. Whether a mixin is actually woven is + * decided once, at target-class load, from the config snapshot taken when the + * coremod constructs the plugin — before any test can intervene, and frozen for + * the JVM's life. A single test JVM can't load the same target class twice + * under two different configs to observe weave-vs-no-weave. The runtime + * effect of the wrapper is already covered by {@code WeatherBaselineTest} + * (wrapping on) and {@code WeatherCycleDisableTest} (cycle off), so the residual + * value of an end-to-end weave assertion is low. This unit test pins the gating + * decision itself, which is the part this fix introduced.

+ */ +public class ARMixinPluginTest { + + private static final String WORLD_SERVER_MULTI = + "zmaster587.advancedRocketry.mixin.MixinWorldServerMulti"; + private static final String PLAYER_LIST = + "zmaster587.advancedRocketry.mixin.MixinPlayerList"; + private static final String WORLD_SERVER = + "zmaster587.advancedRocketry.mixin.MixinWorldServer"; + private static final String GRAVITY = + "zmaster587.advancedRocketry.mixin.MixinEntityGravity"; + private static final String BLOCK_PLACE = + "zmaster587.advancedRocketry.mixin.MixinWorldSetBlockState"; + + @Test + public void worldInfoMixinsApplyWhenPerDimWorldInfoEnabled() { + assertTrue(ARMixinPlugin.shouldApply(true, WORLD_SERVER_MULTI)); + assertTrue(ARMixinPlugin.shouldApply(true, PLAYER_LIST)); + assertTrue(ARMixinPlugin.shouldApply(true, WORLD_SERVER)); + } + + @Test + public void worldInfoMixinsSkippedWhenPerDimWorldInfoDisabled() { + assertFalse(ARMixinPlugin.shouldApply(false, WORLD_SERVER_MULTI)); + assertFalse(ARMixinPlugin.shouldApply(false, PLAYER_LIST)); + // The per-dim TIME mixin is gated by the SAME master flag, so disabling + // the subsystem un-weaves it too — no weather/time leak between flags. + assertFalse(ARMixinPlugin.shouldApply(false, WORLD_SERVER)); + } + + @Test + public void nonWorldInfoMixinsAlwaysApplyRegardlessOfFlag() { + // Gravity / atmosphere block-place are unrelated to the WorldInfo + // subsystem: they must weave whether perDimWorldInfo is on or off. + assertTrue(ARMixinPlugin.shouldApply(true, GRAVITY)); + assertTrue(ARMixinPlugin.shouldApply(false, GRAVITY)); + assertTrue(ARMixinPlugin.shouldApply(true, BLOCK_PLACE)); + assertTrue(ARMixinPlugin.shouldApply(false, BLOCK_PLACE)); + } +} diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/StatsRocketTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/StatsRocketTest.java index 569f165b5..ddd56d49e 100644 --- a/src/test/java/zmaster587/advancedRocketry/test/unit/StatsRocketTest.java +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/StatsRocketTest.java @@ -1,6 +1,7 @@ package zmaster587.advancedRocketry.test.unit; import net.minecraft.nbt.NBTTagCompound; +import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; import zmaster587.advancedRocketry.api.ARConfiguration; @@ -21,16 +22,28 @@ */ public class StatsRocketTest { + private static double prevThrustMultiplier; + private static boolean prevRequireFuel; + @BeforeClass public static void primeConfig() { // Ensure the multiplier-applying getters are observable. ARConfiguration's // public field defaults to 0 (no @ConfigProperty default) which would make // getThrust() always return 0, which is correct in production but masks our // per-field assertions. + prevThrustMultiplier = ARConfiguration.getCurrentConfig().rocketThrustMultiplier; + prevRequireFuel = ARConfiguration.getCurrentConfig().rocketRequireFuel; ARConfiguration.getCurrentConfig().rocketThrustMultiplier = 1.0; ARConfiguration.getCurrentConfig().rocketRequireFuel = true; } + @AfterClass + public static void restoreConfig() { + // The config singleton is shared with every other unit test in this JVM. + ARConfiguration.getCurrentConfig().rocketThrustMultiplier = prevThrustMultiplier; + ARConfiguration.getCurrentConfig().rocketRequireFuel = prevRequireFuel; + } + private static StatsRocket sample() { StatsRocket stats = new StatsRocket(); stats.setThrust(12345); @@ -294,6 +307,132 @@ public void rocketStatsBackwardCompatibleWithOldNbt() { assertEquals(0, restored.getNumPassengerSeats()); // passenger list still empty } + @Test + public void accelerationOnWeightlessRocketIsZeroNotInfinite() { + // getAcceleration divides by weight; a zero-weight rocket must not yield + // NaN/Infinity (which would propagate into motion and the assembler GUI). + StatsRocket stats = new StatsRocket(); + stats.setThrust(100); + stats.setWeight(0f); + + float a = stats.getAcceleration(1f); + assertEquals(0f, a, 0f); + assertEquals(0f, stats.getThrustToWeightRatio(), 0f); + + // With the weight system ENABLED a weightless rocket (TWR 0) is refused. + // (The TWR launch gate only applies when advancedWeightSystem is on.) + boolean prevWeightSys = ARConfiguration.getCurrentConfig().advancedWeightSystem; + try { + ARConfiguration.getCurrentConfig().advancedWeightSystem = true; + assertFalse(stats.canLaunch()); + } finally { + ARConfiguration.getCurrentConfig().advancedWeightSystem = prevWeightSys; + } + } + + @Test + public void thrustToWeightRatioIsThrustOverWeight() { + boolean prevGravity = ARConfiguration.getCurrentConfig().gravityAffectsFuel; + boolean prevWeightSys = ARConfiguration.getCurrentConfig().advancedWeightSystem; + try { + ARConfiguration.getCurrentConfig().advancedWeightSystem = false; // getWeight() == dry weight + StatsRocket stats = new StatsRocket(); + stats.setThrust(200); + stats.setWeight(100f); + assertEquals(2.0f, stats.getThrustToWeightRatio(), 1e-6); + } finally { + ARConfiguration.getCurrentConfig().gravityAffectsFuel = prevGravity; + ARConfiguration.getCurrentConfig().advancedWeightSystem = prevWeightSys; + } + } + + @Test + public void canLaunchRespectsMinLaunchTWR() { + double prevTWR = ARConfiguration.getCurrentConfig().minLaunchTWR; + boolean prevWeightSys = ARConfiguration.getCurrentConfig().advancedWeightSystem; + try { + // The TWR gate only exists when the weight system is enabled. With a + // zero (unregistered "null") fuel fluid, getWeight() is the dry weight, + // so the ratios below are deterministic even with the system on. + ARConfiguration.getCurrentConfig().advancedWeightSystem = true; + ARConfiguration.getCurrentConfig().minLaunchTWR = 1.5; + + StatsRocket stats = new StatsRocket(); + stats.setWeight(100f); + + stats.setThrust(160); // TWR 1.6 >= 1.5 + assertTrue("TWR above the threshold must allow launch", stats.canLaunch()); + + stats.setThrust(140); // TWR 1.4 < 1.5 + assertFalse("TWR below the threshold must block launch", stats.canLaunch()); + + stats.setThrust(150); // TWR exactly 1.5 — boundary is inclusive + assertTrue("TWR exactly at the threshold must allow launch", stats.canLaunch()); + } finally { + ARConfiguration.getCurrentConfig().minLaunchTWR = prevTWR; + ARConfiguration.getCurrentConfig().advancedWeightSystem = prevWeightSys; + } + } + + @Test + public void canLaunchIgnoresTwrGateWhenWeightSystemDisabled() { + // Disableability contract: with advancedWeightSystem off, the weight-based + // launch gate is OFF entirely. A rocket that the gate would reject when the + // system is on (TWR below minLaunchTWR — here even TWR 0 from a heavy, low- + // thrust rocket) must launch freely. This is the player-facing promise that + // "turning the weight system off in the config disables it completely". + double prevTWR = ARConfiguration.getCurrentConfig().minLaunchTWR; + boolean prevWeightSys = ARConfiguration.getCurrentConfig().advancedWeightSystem; + try { + ARConfiguration.getCurrentConfig().minLaunchTWR = 1.5; + + StatsRocket underweight = new StatsRocket(); + underweight.setWeight(100f); + underweight.setThrust(10); // TWR 0.1 — far below the 1.5 gate + + ARConfiguration.getCurrentConfig().advancedWeightSystem = true; + assertFalse("sanity: the gate rejects this rocket while the system is on", + underweight.canLaunch()); + + ARConfiguration.getCurrentConfig().advancedWeightSystem = false; + assertTrue("with the weight system disabled the TWR gate must not block launch", + underweight.canLaunch()); + } finally { + ARConfiguration.getCurrentConfig().minLaunchTWR = prevTWR; + ARConfiguration.getCurrentConfig().advancedWeightSystem = prevWeightSys; + } + } + + @Test + public void dryAccelerationUsesEmptyTankWeight() { + boolean prevGravity = ARConfiguration.getCurrentConfig().gravityAffectsFuel; + boolean prevWeightSys = ARConfiguration.getCurrentConfig().advancedWeightSystem; + try { + ARConfiguration.getCurrentConfig().advancedWeightSystem = false; + ARConfiguration.getCurrentConfig().gravityAffectsFuel = false; + + StatsRocket stats = new StatsRocket(); + stats.setWeight(100f); // dry weight + + // Contract: the sign follows the net force (thrust vs dry weight), + // and more thrust accelerates harder. The exact scaling constant is + // an implementation detail (see testing-principles SOP). + stats.setThrust(100); // thrust == counter-gravity weight → no net force + assertEquals(0f, stats.getDryAcceleration(1f), 1e-6); + + stats.setThrust(300); + float a300 = stats.getDryAcceleration(1f); + assertTrue("thrust above dry weight must give positive dry acceleration", a300 > 0); + + stats.setThrust(600); + assertTrue("more thrust must accelerate the dry rocket harder", + stats.getDryAcceleration(1f) > a300); + } finally { + ARConfiguration.getCurrentConfig().gravityAffectsFuel = prevGravity; + ARConfiguration.getCurrentConfig().advancedWeightSystem = prevWeightSys; + } + } + @Test public void copyProducesIndependentInstance() { StatsRocket original = new StatsRocket(); diff --git a/src/test/java/zmaster587/advancedRocketry/test/unit/WeightEngineUnitTest.java b/src/test/java/zmaster587/advancedRocketry/test/unit/WeightEngineUnitTest.java new file mode 100644 index 000000000..f9c90551b --- /dev/null +++ b/src/test/java/zmaster587/advancedRocketry/test/unit/WeightEngineUnitTest.java @@ -0,0 +1,92 @@ +package zmaster587.advancedRocketry.test.unit; + +import net.minecraft.util.ResourceLocation; +import net.minecraftforge.fluids.Fluid; +import org.junit.Test; +import zmaster587.advancedRocketry.api.ARConfiguration; +import zmaster587.advancedRocketry.util.WeightEngine; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * MC-free unit coverage for {@link WeightEngine}: the parts that don't need a + * block/item registry — fluid weight arithmetic, the JSON table round-trip, and + * default seeding. Block/material resolution (which needs real ItemStacks) is + * covered by the server-tier {@code WeightSystemTest}. + */ +public class WeightEngineUnitTest { + + private static Fluid testFluid() { + ResourceLocation tex = new ResourceLocation("advancedrocketry", "blocks/unit_fluid"); + return new Fluid("ar_unit_fluid", tex, tex); + } + + @Test + public void fluidWeightIsPositiveAndLinearInAmount() { + WeightEngine we = WeightEngine.INSTANCE; + we.resetTables(); + double prevScale = ARConfiguration.getCurrentConfig().fuelMassScale; + try { + ARConfiguration.getCurrentConfig().fuelMassScale = 1.0; + // An unknown fluid still weighs something (the fallback per-mB rate) + // and the weight is linear in the amount. The exact kN/mB constant is + // an implementation default (see testing-principles SOP). + float base = we.getWeight(testFluid(), 1000f); + assertTrue("fallback fluid weight must be positive: " + base, base > 0); + assertEquals("fluid weight must be linear in the amount", + 2 * base, we.getWeight(testFluid(), 2000f), 1e-4); + } finally { + ARConfiguration.getCurrentConfig().fuelMassScale = prevScale; + } + } + + @Test + public void fuelMassScaleMultipliesFluidWeight() { + WeightEngine we = WeightEngine.INSTANCE; + we.resetTables(); + double prevScale = ARConfiguration.getCurrentConfig().fuelMassScale; + try { + ARConfiguration.getCurrentConfig().fuelMassScale = 1.0; + float base = we.getWeight(testFluid(), 1000f); + + ARConfiguration.getCurrentConfig().fuelMassScale = 2.5; + assertEquals("fluid weight must scale by fuelMassScale", + 2.5f * base, we.getWeight(testFluid(), 1000f), 1e-4); + } finally { + ARConfiguration.getCurrentConfig().fuelMassScale = prevScale; + } + } + + @Test + public void seedDefaultsPopulatesMaterialTable() { + WeightEngine we = WeightEngine.INSTANCE; + we.resetTables(); + assertTrue("default material table must be populated", we.materialCount() > 10); + } + + @Test + public void individualOverrideSurvivesSaveLoadRoundTrip() { + WeightEngine we = WeightEngine.INSTANCE; + try { + we.resetTables(); + assertNull("clean slate must not know the test key", we.rawIndividual("ar:roundtrip_probe")); + + we.setIndividual("ar:roundtrip_probe", 42.0); + we.save(); + + // Wipe in-memory state, then reload from the file just written. + we.resetTables(); + assertNull("resetTables must drop the in-memory override", we.rawIndividual("ar:roundtrip_probe")); + + we.load(); + assertEquals("override must persist across save/load", + Double.valueOf(42.0), we.rawIndividual("ar:roundtrip_probe")); + } finally { + // Leave no residue in the on-disk config for other tests. + we.resetTables(); + we.save(); + } + } +}