Compare commits

..
Author SHA1 Message Date
dnorberg 4a59fb5d02 Complete 26.3 parity acceptance and dual-version release workflow
Build and smoke / changes (push) Waiting to run
2026-11-14 12:17:49 -05:00
dnorberg 41daff1e61 Release 26.3.0.1 with live Reborn views and random page policies
Build / build (push) Canceled after 0s
2026-11-11 17:15:46 -05:00
74 changed files with 3214 additions and 493 deletions

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
name: Build and smoke
on: [push, pull_request]
permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.targets.outputs.matrix }}
any: ${{ steps.targets.outputs.any }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check release workflow safeguards
run: python3 scripts/test_release_workflow.py -v
- id: targets
env:
BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
run: |
base="$BASE"
if [ -z "$base" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
base=$(git rev-parse HEAD^)
fi
matrix=$(python3 scripts/changed_targets.py --base "$base")
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
if [ "$matrix" = '{"include":[]}' ]; then
echo "any=false" >> "$GITHUB_OUTPUT"
else
echo "any=true" >> "$GITHUB_OUTPUT"
fi
build:
needs: changes
if: needs.changes.outputs.any == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 1
matrix: ${{ fromJSON(needs.changes.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-java@v4
with:
java-version: ${{ matrix.java }}
distribution: temurin
- uses: gradle/actions/setup-gradle@v4
- name: Build Minecraft target
run: ./gradlew ${{ matrix.gradle_args }} --console=plain
- name: Disposable dedicated-server smoke
run: python3 scripts/smoke_server.py --target ${{ matrix.target }}
- name: Keep smoke logs
if: always()
uses: actions/upload-artifact@v3
with:
name: smoke-${{ matrix.target }}
path: build/verification/${{ matrix.target }}/*.log
if-no-files-found: ignore
+54 -15
View File
@@ -1,25 +1,64 @@
name: Build
name: Build and smoke
on: [push, pull_request]
permissions:
contents: read
jobs:
build:
changes:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.targets.outputs.matrix }}
any: ${{ steps.targets.outputs.any }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check release workflow safeguards
run: python3 scripts/test_release_workflow.py -v
- id: targets
env:
BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
run: |
base="$BASE"
if [ -z "$base" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then
base=$(git rev-parse HEAD^)
fi
matrix=$(python3 scripts/changed_targets.py --base "$base")
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
if [ "$matrix" = '{"include":[]}' ]; then
echo "any=false" >> "$GITHUB_OUTPUT"
else
echo "any=true" >> "$GITHUB_OUTPUT"
fi
build:
needs: changes
if: needs.changes.outputs.any == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 1
matrix: ${{ fromJSON(needs.changes.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Setup JDK 21
uses: actions/setup-java@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Build with Gradle
run: ./gradlew build
java-version: ${{ matrix.java }}
distribution: temurin
- uses: gradle/actions/setup-gradle@v4
- name: Build Minecraft target
run: ./gradlew ${{ matrix.gradle_args }} --console=plain
- name: Disposable dedicated-server smoke
run: python3 scripts/smoke_server.py --target ${{ matrix.target }}
- name: Keep smoke logs
if: always()
uses: actions/upload-artifact@v4
with:
name: smoke-${{ matrix.target }}
path: build/verification/${{ matrix.target }}/*.log
if-no-files-found: ignore
+24 -20
View File
@@ -10,26 +10,28 @@ also lets you author modern terrain, biomes, structures, seasons, and celestial
bodies. It is an independently developed project, not a byte-for-byte copy of
the 1.12.2 mod.
The gameplay guide below describes the **Minecraft 1.21.1 release**. A separate,
incomplete **Minecraft 26.3 beta** is available: Java 25, NeoForge 26.3.0.99,
and a first-party Age backend (no Dynamic Dimensions or Bad Packets). It now
supports authored terrain/biomes, structure and vegetation pages, independent
Age weather/lighting, seeded and colorable Overworld/Nether/End/Static skies,
independent Sun/Moon controls, multiple bodies and eclipses, seeded biome and
feature completion, and random structure selection. Nether/End noise-biome
materials now follow their native rules. Books now have Legacy-style spreads,
animated fallback panels and saved destination labels; lectern stands, open-book
drops, page collections, stations and basic progression are also ported. The latest
beta includes libraries, fallback crystal portals, live instability profiling and
hazards, ordered writing recovery, and the Link Modifier with property, seed and
operator retirement controls. JEI, TerraBlender/Biomes O' Plenty, Serene Seasons
and Ruins integrations are now available as optional additions. Live destination
renderers, remaining discovery/configuration parity and the final multiplayer
regression remain unfinished.
See the [26.3 beta guide](ports/mc263/README.md) and
[latest beta release notes](ports/mc263/RELEASE_NOTES_1.0.1.136-beta.1.md).
The gameplay guide below describes the **Minecraft 1.21.1 release**. The separate
**Minecraft 26.3 release, 26.3.0.1**, uses Java 25, NeoForge 26.3.0.99 and a
first-party Age backend; it does not need Dynamic Dimensions or Bad Packets.
It includes authored/seeded Ages, writing stations and collections, libraries,
crystal portals, celestials/eclipses, instability and Link Modifier controls.
JEI, TerraBlender/Biomes O' Plenty, Serene Seasons and Ruins remain optional.
Immersive Portals Reborn 7.0.12 adds live book previews and crystal portal views;
animated fallbacks work without it. Random-page eligibility and random-Age glyph
descriptions are included. Full 1.21.1 parity and the final expanded-catalog
two-client regression remain ongoing. Reborn's Static Sky rendering bug is tracked
in [#116](https://git.paddedcell.org/dnorberg/rivencraft/issues/116).
See the [26.3 guide](ports/mc263/README.md) and
[release notes](ports/mc263/RELEASE_NOTES_26.3.0.1.md).
Keep the builds and their saves separate, and back up worlds before updating.
Minecraft **26.3** is also supported through a separate **Java 25 / NeoForge
26.3.0.99** release with a first-party Age backend. See the
[26.3 guide](ports/mc263/README.md) and
[parity review](docs/MC263_PARITY_REVIEW.md). Keep the game-version JARs and saves
separate; 1.21.1 remains supported with its existing dependencies. The guide below
describes 1.21.1 unless stated otherwise.
## Getting started
1. Make an Ink Vial from two black dyes, a glass bottle, and a water bucket.
@@ -130,8 +132,10 @@ checks from manual evidence and known limitations.
The JAR is written to `build/libs/`. Development runs need the required mods;
see [GameTest guidance](docs/GAMETESTS.md) for focused archived suites.
Versions use `x.y.z.b`: major release, completed milestone, release slice,
then fixes/additional work within that slice.
New versions prefix the Minecraft version, followed by the milestone/release
slice and fixes/additional-work iteration. For example, `26.3.0.1` produces
`rivencraft-26.3.0.1.jar`. Existing 1.21.1 artifacts keep their published legacy
numbers until that line's next code release. See the [release workflow](docs/DUAL_VERSION_RELEASE.md).
Rivencraft is licensed under LGPL-3.0-or-later. Legacy-derived material is
credited in [NOTICE](NOTICE) and [asset provenance](docs/ASSET_PROVENANCE.md).
+104
View File
@@ -0,0 +1,104 @@
# Dual-version release workflow
Maintain Minecraft 1.21.1 and 26.3 independently. The 1.21.1 line retains Dynamic
Dimensions AR Edition and Bad Packets. The 26.3 line uses its first-party backend.
Do not widen either artifact's Minecraft dependency range or import saves between
the tracks.
## Version identity
From 26.3.0.1 onward, prefix the Minecraft version and append the milestone/release
slice and iteration: Minecraft.release-slice.iteration. Example:
`26.3.0.1` -> `rivencraft-26.3.0.1.jar`, Gitea tag `V26.3.0.1`.
An explicitly selected prerelease uses a suffix such as `-beta.1`.
The existing 1.21.1 release keeps its published version until its next code update;
then use the corresponding `1.21.1.<slice>.<iteration>` convention.
The user authorized regular release status for 26.3.0.1. Release-channel status
does not close unfinished parity or multiplayer issues automatically.
## Build and publish
For 26.3, update `ports/mc263/gradle.properties`, then run
`./gradlew -p ports/mc263 build --console=plain` with Java 25 available.
Use the exact built/player-validated artifact; naming/metadata-only changes do
not change generation or save behavior.
Write release notes, audit the guide/wiki/project description, commit and push
the changes, then create and push the matching annotated tag. Preview the
`scripts/publish_mod_sites.py release` command with the JAR, changelog, version,
display name, Minecraft version, Java version and channel; add `--publish` to
upload to Gitea, Modrinth and CurseForge. Tokens stay in the system keyring.
Verify published metadata and downloaded artifact hashes. CurseForge approval
may remain pending after a successful upload.
The publisher accepts legacy Minecraft-prefixed archive filenames and beta tags
for already numbered historical uploads; new releases use the identity above.
## Validation gates and issue audit
Run appropriate builds and targeted server checks for changed behavior. The user
performs visual/gameplay validation. Keep temporary test fixtures out of shipped
JARs; use new worlds and remove them regardless of pass/fail. Stop test processes.
If generation stalls, hand the test to the user.
Before closing a port umbrella issue, compare its acceptance criteria and comments
to actual evidence. The expanded-catalog matrix and manual restart/return handoff completed in #100.
See [the final parity review](MC263_PARITY_REVIEW.md) for evidence and limits.
Shared issues need both versions resolved before closure.
For each release, document tested optional versions and no-optional fallbacks,
match client/server JARs, preserve the independent 1.21.1 support line, update wiki
and platform descriptions, and keep known bugs linked in the release notes.
## Changed-target validation policy
Only test a Minecraft target when its code/resources changed. Select targets with
`python3 scripts/changed_targets.py --base <commit>`. Shared assets can select
both; temporary servers must run sequentially. Earlier gameplay sign-off remains
valid for unchanged 1.21.1 code.
Repeatable local checks:
- Java 21 / 1.21.1: `./gradlew build --console=plain`.
- Java 25 / 26.3: `./gradlew -p ports/mc263 build --console=plain`.
- Smoke one changed target: `python3 scripts/smoke_server.py --target mc1211`
or `--target mc263`.
- Two-client 26.3 acceptance: `python3 scripts/regression_multiplayer_263.py`.
Fixture sources remain outside the shipping JAR. If a test stalls, stop it,
preserve logs, delete temporary worlds and hand the remaining check to the user.
Do not automatically retry.
GitHub and Gitea workflow definitions select changed targets and use
`max-parallel: 1`. Hosted execution is not yet verified. Repository runner
inventory alone does not establish availability of instance-wide runners.
The publisher checks embedded version/Minecraft metadata, Java selection,
filename identity, absence of test fixtures and required 1.21.1 dependencies.
Apply relevant fixes to both supported lines, with separate implementation and
validation per target. Keep version-specific fixes on the affected line. Advancing
the newer Minecraft/NeoForge target requires a compatibility audit and must not
silently replace 1.21.1 support or widen an existing JAR dependency range.
## Current acceptance evidence (2026-11-14)
26.3.0.3 builds and the user passed book-window mouse-look, right-click return
and re-entry. Beta branding is removed. The release collects these changes,
the workflow tools and the final parity review.
The expanded-catalog dedicated matrix ran with two real clients. Both received
runtime-created Ages and authored environment data, traveled using normal books,
and passed stale-ack rejection/recovery, occupied-unload refusal, empty-Age
reload/reseed and retirement checks. After full server restart both reconnected in
the surviving Age with its saved seed and presentation, without the retired Age.
The run stalled at the final post-restart return Linking Book step (phase 22).
It was stopped, temporary worlds deleted, and the last check handed off rather
than retried. Singleplayer preview sign-off does not replace that dedicated check.
The player completed the manual dedicated-server restart/return check on
2026-11-14 and explicitly accepted #100. This was one player-operated client,
complementing the automated two-client evidence; it is not described as a new
automated two-client pass. The test server was stopped, logs retained under
build/verification/issue100-manual, and its temporary world removed.
See [the final parity review](MC263_PARITY_REVIEW.md). Renderer bug #116 remains
separately tracked. #103/#104 and parent #96 close after release verification.
+79
View File
@@ -0,0 +1,79 @@
# 26.3 port parity and acceptance review
Reviewed 2026-11-14 for 26.3.0.3 against the independently maintained 1.21.1
implementation. This closes the port feature checklist, not every open bug or
future enhancement. The dated development entries in MINECRAFT_26_3_PORT.md
retain their original evidence and are superseded by later slices where stated.
## Feature comparison
| Area | 1.21.1 implementation | 26.3 counterpart / acceptance |
| --- | --- | --- |
| Page language | Ordered writing, registry-backed pages, modifiers, recovery and corrupted pages | PortAuthoredWriting, PortWritingRecovery, PortMaterialCompletion, PortRegistryPages and the shared symbol resources retain source writing, material pairing and diagnostics. Port slices received player sign-off. |
| Ink Mixer | Paper/ink, additive modifiers, vial/bottle exchange and output reset | InkMixerMenu/Screen, InkModifierIngredients and InkVialExchange port the interaction and assets. One ink and paper per panel; additives reset on removal. The separate 1.21.1 correction remains a shared follow-up. |
| Page storage | Notebook, folders, portfolios, sealed notebooks | NotebookMenu/Screen and the corresponding item components port sparse storage, selection/moving, held-slot protection and bulk import. Legacy glyph/page assets reused. |
| Desk / Binder | Tab catalog, ink/paper copy transactions, collection input, named books and OP diagnostics | WritingDeskMenu/Screen, WritingDeskCatalog and PortBookBinderMenu/Screen with authoritative networking and existing textures/models. Input, output pickup and naming fixes passed in game. |
| Link Modifier | Name, properties, seed and retirement | LinkModifierMenu/Screen and explicit edit payloads provide six property controls, title/seed edits and guarded retirement. Player-approved before this audit. |
| Terrain and biomes | Standard/amplified/flat/cave/void/sky-island profiles and biome distributions | PortAgeTerrain, PortBiomePlan, native registry biome pools and first-party generators. Authored and random plans persist their own seeds. Version-specific terrain/biome follow-ups remain separate. |
| Decorations and structures | Material features, vegetation, lakes, carvers, vanilla structures, libraries, crystals and fissures | Port terrain/vegetation/lake/carver/structure plans and generation passes implement these families, with modern structures and registry-backed materials. Family-specific terrain restrictions are retained; this audit does not claim every possible combination can generate. |
| Time, sky and celestials | Authored/random bodies, number/color modifiers, time modes, eclipses and ambient lighting | PortDayCycle, PortAgeColors, PortCelestialDiscs and sky/light mixins; colors, multiple bodies, sizes, positions, frozen time and eclipse rendering received player checks. Static Sky with Reborn has known bug #116. |
| Weather and seasons | Per-Age weather and optional Serene policies | PortWeatherMode, PortSeasonPolicies and Serene adapters preserve independent Age policy. Serene is optional; Ecliptic integration remains deferred on both lines. |
| Instability | Writing and live material scores, provider plans, effects and operator controls | PortInstabilityData/Plan/Runtime, block profiling, status/decay/hazard/meteor/environment providers and operator commands. Scores remain OP-only. Accelerated ticks use bounded loaded-world budgets; machine parity work remains tracked separately. |
| Travel | Handheld books, stands, receptacles, portals, footing/platform, modifiers and return links | Shared port destination/travel services, PortalTravelQueue and CrystalPortalNetwork keep book properties authoritative. Return links default disabled. Unsafe or unsynchronized travel leaves the player at source. |
| Presentation | Legacy books/pages/models/sounds, dropped open books and preview renderers | Existing assets reused through 26.3 extracted render states and modern input. Live Reborn book/portal views and look-only exploration are implemented; right-click return/re-entry passed on 26.3.0.3. |
| Discovery / guides | Libraries, loot, crafting, advancements, creative pages and JEI | Libraries/stands, sealed notebook discovery, progression and JEI guides ported. Random eligibility controls apply to new random choices/loot while existing authored pages remain usable. |
| Backend lifecycle | Dynamic Dimensions AR plus catalog/travel behavior | Rivencraft-owned backend with atomic creation, seed persistence, catalog acknowledgement, unload/reseed/retire. #99/#101 and now #100 accepted. |
## Deliberate platform differences
- Java 21 / NeoForge 21.1.264 and Dynamic Dimensions AR + Bad Packets stay on
1.21.1. The tested 26.3 target uses Java 25 / NeoForge 26.3.0.99 and a first-party
Age backend. No optional integration is bundled or made mandatory.
- 26.3 starts fresh saves. Importing 1.21.1 dimension/save internals is unsupported.
New chunk algorithms and random streams need not produce identical terrain.
- Create/Ponder has no ported 26.3 integration. Built-in animation is always
available; Reborn 7.0.12 provides optional live book/crystal views and look-only
exploration. Seamless has the dimension bridge, not this Reborn rendering path.
- Optional libraries, registry contents and vanilla generation APIs differ across
game versions. Changes to shared symbols/assets must be considered for both;
version-specific implementations and dependency requirements stay separate.
- Shared requested enhancements in #105–#115 and renderer bug #116 are separate
work. Port closure does not close those issues or describe their work as done.
## Regression evidence and limits
Earlier recorded 1.21.1 client/dedicated/optional-mod sign-offs remain valid:
its game code did not change during this work, so it was not retested merely
because the 26.3 target changed. Both build targets were built during the workflow
audit; only changed-target gameplay checks are required going forward.
The expanded-catalog 26.3 test used two actual clients on one dedicated server.
Runtime Age creation, catalog/environment delivery, normal-book travel,
stale-ack rejection/recovery, occupied unload refusal, empty reload/reseed and
retirement passed. On full restart both reconnected in the surviving Age with
the saved seed/presentation; retired data stayed absent. The final return-book
step stalled and was handed off without retry, per the user's instruction.
The player then created and entered an Age on the current local dedicated server
with the Dev optional stack, requested a full restart, reconnected, and returned
to the Overworld by Linking Book. Server logs confirm restored Age
rivencraft:age/random, seed -1985136355166262436, and the Overworld return.
The player explicitly accepted #100. This final handoff used one player-operated
client, not a new two-client automated pass. Logs are retained locally under
build/verification; temporary worlds and server processes were removed.
Production 26.3.0.3 builds and its book-window look/exit/re-entry received an
in-game pass. Earlier slices record no-optional startup, generation, portal
geometry/lifecycle and optional integration checks. This is cumulative evidence,
not a claim that every historical test was rerun on the final JAR.
## Release gate
[DUAL_VERSION_RELEASE.md](DUAL_VERSION_RELEASE.md) defines changed-target builds,
sequential temporary servers, stalled-test handoff, maintenance policy and upload
identity checks. Offline tooling checks cover track selection, release naming,
required dependencies, wrong-artifact/fixture rejection and server exclusion.
Hosted workflow execution remains unverified; repeatable local commands provide
the supported fallback. Release acceptance is recorded in Gitea after publication
and artifact verification. The existing published 1.21.1 release is retained;
unchanged code is not republished merely to adopt the new filename scheme.
+257 -15
View File
@@ -8,20 +8,32 @@ The user wants both Minecraft lines maintained after the port, not a one-time
26.3 release. Gitea milestone "Dual-version support: 1.21.1 and 26.3"
tracks issues #97–#103 under parent #96.
## Final port acceptance — 2026-11-14
The 26.3 port is playable on the regular release channel. The complete writing,
generation, instability, travel, discovery and presentation review is in
[MC263_PARITY_REVIEW.md](MC263_PARITY_REVIEW.md). #100 is complete: the
expanded-catalog two-client checks and the player-operated full-restart return
handoff passed. The look-only book-window overlay and its right-click exit fix
also passed on 26.3.0.3. #103/#104 closure follows the workflow/release audit.
The original introductory audit and slice entries below are historical, not a
list of currently missing features. 1.21.1 remains maintained independently.
Known issues and shared enhancements remain tracked separately.
## Published status — 2026-11-11
[1.0.1.136-beta.1](../ports/mc263/RELEASE_NOTES_1.0.1.136-beta.1.md) collects
player-validated .130–.136: Link Modifier seed editing and operator retirement,
creation rollback, and optional JEI, TerraBlender/BOP, Serene Seasons/GlitchCore
and Ruins. Both Minecraft builds pass. The latest production client log records
all these optional integrations loaded together. Issue #99's remaining creation
rollback requirement is complete. #100 still requires the current dedicated
two-client/normal-book matrix; #102 retains the live-renderer/alternative review;
#103/#104 and parent #96 remain open. Shared dual-version follow-ups remain open
where one or both implementations are pending. The versioning change requested
in #103's comment remains part of that next release-workflow task; this release
retains the version already built and player-tested. Entries below are historical
engineering records and may describe limitations addressed in later slices.
[26.3.0.1](../ports/mc263/RELEASE_NOTES_26.3.0.1.md) collects the player-validated
.137–.142 slices: random-page eligibility, random-Age glyph descriptions, optional
portal-engine dimension synchronization, and Reborn live book/crystal portal views.
It is published on the regular release channel at the user's request, using the
new Minecraft-prefixed version scheme. Minecraft 1.21.1 remains unchanged.
#102's optional-integration audit/port is complete; the Static Sky/Reborn rendering
bug is tracked separately in #116. #100 still requires the current expanded-catalog
dedicated two-client matrix. #103/#104 and parent #96 retain the remaining workflow
and parity gates. Full parity is not implied by the release channel. Shared
follow-ups stay open where one or both implementations are pending. Earlier
entries are historical records and may describe limitations resolved later.
## Version boundary
@@ -102,8 +114,8 @@ range of the 1.21.1 JAR. The official [26.3 release notes](https://www.minecraft
[NeoForge 26.3 Maven metadata](https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml),
[NeoForge Java 25 setting](https://github.com/neoforged/NeoForge/blob/26.3.x/gradle.properties),
and [NeoForge migration primers](https://docs.neoforged.net/primer/docs/)
are the version sources. NeoForge 26.3.0.99 is the newest listed stable build
at the time of this check.
are the version sources. NeoForge 26.3.0.99 is the validated target for this release; the availability
of newer NeoForge builds does not imply they have been validated.
## Dependency availability
@@ -121,7 +133,8 @@ Rivencraft. Recheck immediately before choosing versions for a port build.
| [Biomes O' Plenty](https://modrinth.com/mod/biomes-o-plenty) | Optional TerraBlender test addon | Yes | Test modded biome registration and decoration. |
| [Ruins (Structure Spawning System)](https://www.curseforge.com/minecraft/mc-mods/ruins-structure-spawning-system) | Optional authored ruins integration | Yes | Port and verify its generation hooks. |
| [Create](https://modrinth.com/mod/create) / bundled Ponder | Optional scene renderer | No advertised 26.3 version | Use Rivencraft's existing legacy-animation preview fallback for this target until a compatible release exists. |
| [Immersive Portals for (Neo)Forge](https://modrinth.com/mod/immersive-portals-neoforge) | Optional live views and portal teleport integration | No advertised 26.3 version | Use the fallback portal surface and normal Rivencraft linking. Do not claim live destination views on 26.3. |
| [Immersive Portal / Seamless Portals](https://www.curseforge.com/minecraft/mc-mods/immersive-portal/files/9218525) | Optional live views and portal integration | Latest file 9218525: displayed 10.0.0, internal 1.0.0 | Runtime-Age/native packet checks passed; live rendering still pending. Old file 8937394 has an upstream startup failure. |
| [Immersive Portals Reborn](https://www.curseforge.com/minecraft/mc-mods/immersive-portals-reborn-unofficial-fork/files/9435354) | Alternative optional portal engine | NeoForge 7.0.12 for 26.3; later 7.0.18 is Fabric | .141–.142 port live book/crystal views; player, dedicated/client and restart checks passed. Static Sky bug: #116. Use one fork per instance. |
Ecliptic Seasons publishes 26.3, but Rivencraft's Ecliptic integration was
deferred and is not a replacement for the current Serene integration.
@@ -2871,3 +2884,232 @@ filtering, both dimension gates, spawn-event veto, and a real Tiki Head placemen
retained the counts. Temporary fixtures and test worlds were removed; test servers
were stopped. Client glyph/GUI checks and natural spawning while exploring an Age
remain player validation. No commit, push, or publication; #102 remains open.
### Random Page eligibility and library discovery — 1.0.1.137-beta.1
The player passed .136 and authorized its publication. This slice adapts 1.21.1's
RandomPagePolicy and random_pages allowlist/blocklist. Short paths (sky/static)
and fully namespaced Page IDs are accepted; blocklist wins, and allowlist entries
restrict only their own categories. Biome controllers are a separate category
from individual biomes. Creative/JEI availability and manual book authoring are
not filtered.
Normal Link-Panel-only book creation takes one immutable policy snapshot and
passes it through terrain, biome distribution/Page completion, environment,
celestial kinds, material features/materials, optional formations and structures.
Seasons use the same snapshot before their saved policy is committed. Resolved
choices are already frozen in Age index/season data, so reload/reseed never reads
the current lists to reselect an old Age. Authored and partly authored books use
the existing unrestricted completion behavior, matching the 1.21.1 boundary.
Required empty categories use Standard terrain, Native distribution, Normal
lighting/weather, inherited Overworld sky, or No Seasons. Optional features or
materials with no candidates are omitted, not filled with a forbidden random
Page. Non-Native biome distributions preserve their required minimum using the
terrain's preferred/native fallback pool; necessary fallback biomes can therefore
bypass a filter. Native noise biomes come from the preset, not biome Page selection,
and remain unfiltered just as in 1.21.1. Void still has its required Void biome.
Cave weather/season safety overrides remain unchanged. Original unrestricted
terrain/distribution seed choices are unchanged; filtered ranks are recalculated
using the shared Legacy rank chooser instead of a duplicate weighted routine.
Newly placed direct library Pages apply the current policy in both LEGACY and
AGE_ONLY modes. Existing inventory contents are not rewritten. The Legacy library
pool now includes exact Ruins templates and semantic bundled pages rather than
reusing the Sealed Notebook's narrower pool. Sealed Notebook opening retains its
1.21.1 pool behavior; this setting does not retroactively edit unopened/old loot.
No policy filtering is applied to JEI or creative portfolios.
Build passed. A disposable no-optional dedicated server passed 766 creation/policy
checks over 48 seeds and six restart checks: normal book binding, manual blocked
Page authoring, immutable saved settings under a changed policy, unload/reload,
required category/biome fallbacks, optional empty pools, material eligibility,
Native distinction, season policies without requiring Serene classes, library
filtering and unchanged Creative entries. The later rank revision added default
seed-compatibility and minimum-biome checks. These are temporary fixtures, not
new permanent game tests. Worlds and fixtures removed, servers stopped, production
JAR rebuilt. Player config/GUI/world validation remains pending. No commit, push
or publication.
### Random-Age book glyph prose — 1.0.1.138-beta.1
The player passed the .137 eligibility slice. Ported 1.21.1's first-page random-Age
glyph description: the Age name and author remain ordinary text on the left,
followed by layered Legacy word glyphs and real base-25 numerals for terrain,
distribution, lighting, weather and the first celestial cycle's period/angle/phase.
Authored books keep their existing name/author front page and single-symbol middle
pages; no writing notes or instability scores are introduced. The right-side
animated link panel and its click-to-travel behavior are unchanged.
The existing symbolcomponents.png atlas and deterministic word mappings are reused
directly with tinted GUI_TEXTURED blits; no generated art or per-frame dynamic
textures are added. A common prose layout helper handles Unicode word boundaries,
numeric width and wrapping. Writing Desk tab numerals now use the same rendering
routine, retaining their existing size/position/color.
Public terrain/distribution/lighting/weather metadata is sent in the Age catalog,
with celestial data reused from its existing saved-cycle map. These are actual
loaded Age settings rather than a reroll from the book ID or current config.
Metadata excludes seeds, instability scores and providers. It does not load an
unavailable destination merely to display a book. Full catalog replacement removes
unloaded/retired entries; logout clears description/cycle caches. Older books with
no authored symbol component use the random-description path. Existing Age/save
formats remain readable. Catalog network protocol is now 11 (previously 10):
client and server must use matching builds.
Build passed; disposable no-optional dedicated-server checks passed catalog
serialization, immutable snapshots, actual saved setting capture, cache
replacement/clear, real restart, unload/reload and retirement. Pure layout checks
covered Legacy word splitting, Unicode, wrapping, base-25 width and oversized
numeric fallback. Test worlds and fixtures removed, server stopped and production
JAR rebuilt. Visual glyph layering, book/lectern appearance and actual client
catalog receipt remain player validation. No commit, push or publication.
### Renamed Immersive Portals dimension bridge — 1.0.1.139-beta.1
The player passed .138. Confirmed the user's correction: the 26.3 Immersive Portal
implementation uses the Seamless Portals name/mod ID, retaining qouteall rendering,
chunk-loading and dimension APIs. Added an optional shared dimension-ID adapter
before Rivencraft catalog publication/acknowledgement. It synchronizes integer
IDs and native type/sea-level metadata without delegating Age ownership. Failed
synchronization blocks acknowledgement/travel rather than making a successfully
committed Age appear to have failed binding. Startup, unload/reload, reseed and
retirement are covered. Live book/crystal rendering remains later work.
The installed working build passed 35 server creation/catalog checks plus 13 after
restart. Normal book binding, native metadata, API ID roundtrips, two Ages,
lifecycle changes and failed-creation nonpublication were checked. No-optional
startup passed. Real client travel remains pending; Prism files were not changed.
Correction to the initial artifact audit: old CurseForge file 8937394 fails during
its own constructor on .99 with missing DataPackRegistryEvent$NewRegistry; this is
NOT the latest release. The latest file is 9218525, displayed as 10.0.0 while its
filename/internal metadata still say 1.0.0. Its SHA-256 exactly matches the user's
installed working file:
5d2bfc2a811a6526e3f6722c521d97e5de2869a6ead1c681323c3c472e4367a0.
The compile dependency is corrected to 9218525 in .140. The earlier broad claim
that the current published build cannot start was wrong and is superseded here.
NeoForge audit on 2026-11-11: official Maven metadata lists .102 as latest in the
26.3 line. Its universal JAR still lacks the old NewRegistry class, so upgrading
cannot fix that OLD portal artifact. Current target remains validated .99; a
separate .102 build/client/server check is recommended. No release authorized.
### Immersive Portals Reborn comparison and shared bridge — 1.0.1.140-beta.1
The user suggested Reborn. Verified the NeoForge 26.3 release is 7.0.12, file
9435354 in CurseForge project 1721833. The newer 7.0.18 is Fabric. The Reborn JAR
registers immersive_portals_reborn plus its legacy core aliases and retains the
qouteall GUI rendering/portal/chunk/dimension APIs. It starts on NeoForge .99
without Cloth Config or other optional mods in the test environment.
Generalized the optional bridge to recognize either Reborn or Seamless. Both
share DimensionIntId.onServerDimensionChanged and the createFromServer packet
factory. Seamless's createPacket returns a vanilla Packet; Reborn's returns a
CustomPacketPayload. The adapter now wraps createFromServer in the vanilla
clientbound payload packet, preserving each fork's native two-/three-tag wire
format. No cross-fork packet schema is invented and neither dependency is bundled.
The compat probe recognizes both. A development run may select
-PwithImmersivePortalsReborn or -PwithSeamlessPortals; requesting both is rejected
because the forks share classes/mixins. The normal build remains optional-free.
Reborn passed 32 creation/catalog checks and 15 after restart: real book-binding
entry point, two runtime Ages, ID/type metadata, native wire roundtrip, per-player
packet preparation, unload/reload, reseed, retirement isolation and persistence.
Seamless latest file 9218525 passed the same 32 lifecycle/packet and 15 restart checks against the shared adapter.
These are server/packet checks, not evidence of renderer quality or real client
packet receipt. Reborn is a viable next client-test target; no performance
superiority is claimed. Live snapshots, chunk leases/audio isolation, and live
crystal surfaces still need porting. Player files were not modified. Temporary
worlds/fixtures removed; no commit, push or publication.
### Reborn live book previews — 1.0.1.141-beta.1
Linking and Descriptive Book front panels now use Immersive Portals Reborn's
live GUI-world renderer when Reborn is installed on both sides. This carries
forward the 1.21.1 camera orbit (11-block radius, five blocks above the focus),
destination sky/fog, chunk streaming and remote-audio isolation. Server destination
resolution uses the actual held book or a nearby loaded stand's matching book;
the client cannot request arbitrary destination coordinates.
A unique session ID prevents stale replies or close packets from replacing or
releasing another book's preview. One per-player eight-chunk-radius loader streams
the view asynchronously, renews while the book stays open, and expires if its
heartbeat is lost. Closing, logout, death/source invalidation, dimension changes,
and shutdown release the lease. Destination resolution for Descriptive Books
uses the same footing/platform rules as book travel.
The rendering integration uses 26.3 GPU texture views with the deferred GUI.
Reborn may resize its target before extraction records the texture view; clearing
is deferred to its render hook, and the normal GUI draws the image beneath the
Legacy panel cutout. The target uses the main framebuffer's depth/stencil format.
The player camera is restored after remote rendering, and remote preview-world
sounds are suppressed without muting the player's world.
Set `[book_previews] reborn_live_views = false` in the local configuration to use
animated panels. Missing Reborn or unavailable terrain also retains the animated
fallback. Seamless's dimension-ID bridge remains available, but this live GUI
implementation is activated only for Reborn. Live crystal portal surfaces and
preview exploration controls remain separate port work.
Validation: production build passed. An isolated real NeoForge 26.3.0.99
dedicated-server/client run with Reborn 7.0.12 displayed the remote live Linking
Book view; screenshot inspected for the panel cutout, upright image and actual
terrain. The client completed without a renderer exception and logout left no
preview chunk leases. The user also reported that the books displayed live views
perfectly. All temporary worlds/client directories and fixture sources were
removed, and the distributable was rebuilt without test classes.
Startup warnings in this isolated run included Reborn's four deprecated
`logoFile` metadata entries; these are upstream branding metadata warnings.
The Linux narrator also lacks `libflite`, unrelated to the book renderer.
### Reborn live crystal portals — 1.0.1.142-beta.1
Crystal portals now create optional native Reborn render-only surfaces. Each
coplanar opening is one masked mesh on each side, including irregular outlines;
the destination image does not repeat once per portal block. Mesh planes sit
inside the portal block thickness so the crystal frame remains in front. Once
native surfaces are established, LIVE_VIEW hides only the animated portal model.
The blocks and their receptacle owner pointers stay intact.
Live views share destination resolution with portal book travel. Linking Books
use their written facing, while Descriptive Books preserve the approaching
entity's facing as their travel path does. The reference camera is anchored to
standing eye height above the opening's floor, rather than to its vertical center.
This prevents taller frames from shifting the preview's arrival height. Relative
links resolve using the opening's center as the view reference; the actual
entity's departure position is still used for travel.
Native surfaces cannot teleport entities, accept remote interaction or perform
cross-portal collision. Existing Rivencraft collision/queue and book rules remain
authoritative, including catalog readiness, Disarm, momentum and arrival safety.
Reborn handles visible-destination streaming through its normal portal system.
Geometry/destination tags allow maintenance to reuse matching entities and replace
stale ones. Activation establishes views immediately; normal receptacle maintenance
restores them after restart. Book removal, frame shutdown, missing destinations
and disabling the integration remove the native surfaces. Without Reborn the
animated surface and ordinary travel continue to work. The server setting is
[book_previews] reborn_crystal_live_views, default true.
Validation on NeoForge 26.3.0.99 / Reborn 7.0.12: 68 server checks passed, covering
all three plane axes, both sides, one mesh per opening, eye-position and facing
transforms, entity reuse, fallback toggling and book removal. Five additional
restart checks passed. An isolated real client/dedicated server rendered the live
opening, then crossed into the correct Age through normal Rivencraft travel.
Screenshots before/after were inspected; the user also reported success.
An initial render test mistakenly bound its synthetic Linking Book at Y=1 inside
the flat world's solid layers. The view correctly displayed underground terrain
and the collision check refused travel. The corrected fixture bound to the actual
surface (Y=4) and passed. No production spawn-height workaround was added for an
invalid synthetic book. All temporary fixtures and test worlds were removed, and
the distributable was rebuilt without test classes.
+1 -1
View File
@@ -35,7 +35,7 @@ mod_id=rivencraft
mod_name=Rivencraft
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
# Existing 1.21.1 release number. Adopt the Minecraft-prefixed policy in README.md on its next code release.
mod_version=1.0.1.1
immersive_portals_version=6.0.7
terrablender_version=4.1.0.8
+253 -19
View File
@@ -1,23 +1,28 @@
# Minecraft 26.3 beta port workspace
# Minecraft 26.3
This is an **experimental beta port** for issues #97–#104, not gameplay parity
with the Minecraft 1.21.1 release. The 26.3 backend persists separate Ages and
their seeds. Books, lectern book stands, the Book Binder, Ink Mixer, Writing Desk and page collections
are ported, and the Binder can
author Standard, Amplified, Island, Flat, Void, or Cave terrain with supported
biome-distribution pages, selected material/vegetation/structure features, and
independent Age weather and lighting, multiple authored or seeded celestial
bodies and eclipses, biome/material completion, and random features/structures.
Crystal portals and libraries are ported. Instability scoring/provider selection,
status effects, colored decay, lightning, explosions, meteors, burning and erosion,
ordered writing recovery, live material profiling and all six Link Modifier controls are now ported.
Seed editing, operator-confirmed Age retirement and creation rollback are included.
JEI, TerraBlender/Biomes O' Plenty, Serene Seasons/GlitchCore and Ruins are optional
integrations. Live previews, remaining discovery/configuration parity and the final
multiplayer regression remain unfinished. The latest published beta is
[1.0.1.136-beta.1](RELEASE_NOTES_1.0.1.136-beta.1.md).
Use a test world and keep a backup; do not
substitute this JAR for the 1.21.1 mod or import 1.21.1 saves.
The current **Minecraft 26.3 release is 26.3.0.3**, for NeoForge 26.3.0.99 and
Java 25. It uses Rivencraft's first-party Age backend, with no Dynamic Dimensions
or Bad Packets dependency. The writing loop, authored/seeded Ages, generation
features, weather/skies/celestials, libraries, crystal portals, live instability,
and Link Modifier controls are ported. JEI, TerraBlender/Biomes O' Plenty, Serene
Seasons/GlitchCore and Ruins are optional.
Immersive Portals Reborn 7.0.12 optionally supplies rotating live book previews
and continuous crystal portal views. Built-in animations and normal travel remain
available without it. Random-page eligibility and glyph descriptions are included.
Use one portal fork per instance. The server controls crystal live views through
`book_previews.reborn_crystal_live_views`; clients can disable live book views
with `book_previews.reborn_live_views` in `rivencraft-local.toml`.
Reborn's Static Sky rendering issue remains tracked in
[#116](https://git.paddedcell.org/dnorberg/rivencraft/issues/116).
The port feature and acceptance review is recorded in
[the parity review](../../docs/MC263_PARITY_REVIEW.md). Book exploration matches
1.21.1: use the eye for mouse look inside the book panel and right-click to return;
movement is disabled. The final dedicated-server restart/return check passed.
Create/Ponder scenes are not ported to this target. Use matching client/server
builds, keep saves separate from 1.21.1 and back up before updating.
See [release notes](RELEASE_NOTES_26.3.0.3.md).
The entries below record how the port was built, including earlier alpha-only
limitations that were addressed by later slices.
@@ -2801,3 +2806,232 @@ filtering, both dimension gates, spawn-event veto, and a real Tiki Head placemen
retained the counts. Temporary fixtures and test worlds were removed; test servers
were stopped. Client glyph/GUI checks and natural spawning while exploring an Age
remain player validation. No commit, push, or publication; #102 remains open.
### Random Page eligibility and library discovery — 1.0.1.137-beta.1
The player passed .136 and authorized its publication. This slice adapts 1.21.1's
RandomPagePolicy and random_pages allowlist/blocklist. Short paths (sky/static)
and fully namespaced Page IDs are accepted; blocklist wins, and allowlist entries
restrict only their own categories. Biome controllers are a separate category
from individual biomes. Creative/JEI availability and manual book authoring are
not filtered.
Normal Link-Panel-only book creation takes one immutable policy snapshot and
passes it through terrain, biome distribution/Page completion, environment,
celestial kinds, material features/materials, optional formations and structures.
Seasons use the same snapshot before their saved policy is committed. Resolved
choices are already frozen in Age index/season data, so reload/reseed never reads
the current lists to reselect an old Age. Authored and partly authored books use
the existing unrestricted completion behavior, matching the 1.21.1 boundary.
Required empty categories use Standard terrain, Native distribution, Normal
lighting/weather, inherited Overworld sky, or No Seasons. Optional features or
materials with no candidates are omitted, not filled with a forbidden random
Page. Non-Native biome distributions preserve their required minimum using the
terrain's preferred/native fallback pool; necessary fallback biomes can therefore
bypass a filter. Native noise biomes come from the preset, not biome Page selection,
and remain unfiltered just as in 1.21.1. Void still has its required Void biome.
Cave weather/season safety overrides remain unchanged. Original unrestricted
terrain/distribution seed choices are unchanged; filtered ranks are recalculated
using the shared Legacy rank chooser instead of a duplicate weighted routine.
Newly placed direct library Pages apply the current policy in both LEGACY and
AGE_ONLY modes. Existing inventory contents are not rewritten. The Legacy library
pool now includes exact Ruins templates and semantic bundled pages rather than
reusing the Sealed Notebook's narrower pool. Sealed Notebook opening retains its
1.21.1 pool behavior; this setting does not retroactively edit unopened/old loot.
No policy filtering is applied to JEI or creative portfolios.
Build passed. A disposable no-optional dedicated server passed 766 creation/policy
checks over 48 seeds and six restart checks: normal book binding, manual blocked
Page authoring, immutable saved settings under a changed policy, unload/reload,
required category/biome fallbacks, optional empty pools, material eligibility,
Native distinction, season policies without requiring Serene classes, library
filtering and unchanged Creative entries. The later rank revision added default
seed-compatibility and minimum-biome checks. These are temporary fixtures, not
new permanent game tests. Worlds and fixtures removed, servers stopped, production
JAR rebuilt. Player config/GUI/world validation remains pending. No commit, push
or publication.
### Random-Age book glyph prose — 1.0.1.138-beta.1
The player passed the .137 eligibility slice. Ported 1.21.1's first-page random-Age
glyph description: the Age name and author remain ordinary text on the left,
followed by layered Legacy word glyphs and real base-25 numerals for terrain,
distribution, lighting, weather and the first celestial cycle's period/angle/phase.
Authored books keep their existing name/author front page and single-symbol middle
pages; no writing notes or instability scores are introduced. The right-side
animated link panel and its click-to-travel behavior are unchanged.
The existing symbolcomponents.png atlas and deterministic word mappings are reused
directly with tinted GUI_TEXTURED blits; no generated art or per-frame dynamic
textures are added. A common prose layout helper handles Unicode word boundaries,
numeric width and wrapping. Writing Desk tab numerals now use the same rendering
routine, retaining their existing size/position/color.
Public terrain/distribution/lighting/weather metadata is sent in the Age catalog,
with celestial data reused from its existing saved-cycle map. These are actual
loaded Age settings rather than a reroll from the book ID or current config.
Metadata excludes seeds, instability scores and providers. It does not load an
unavailable destination merely to display a book. Full catalog replacement removes
unloaded/retired entries; logout clears description/cycle caches. Older books with
no authored symbol component use the random-description path. Existing Age/save
formats remain readable. Catalog network protocol is now 11 (previously 10):
client and server must use matching builds.
Build passed; disposable no-optional dedicated-server checks passed catalog
serialization, immutable snapshots, actual saved setting capture, cache
replacement/clear, real restart, unload/reload and retirement. Pure layout checks
covered Legacy word splitting, Unicode, wrapping, base-25 width and oversized
numeric fallback. Test worlds and fixtures removed, server stopped and production
JAR rebuilt. Visual glyph layering, book/lectern appearance and actual client
catalog receipt remain player validation. No commit, push or publication.
### Renamed Immersive Portals dimension bridge — 1.0.1.139-beta.1
The player passed .138. Confirmed the user's correction: the 26.3 Immersive Portal
implementation uses the Seamless Portals name/mod ID, retaining qouteall rendering,
chunk-loading and dimension APIs. Added an optional shared dimension-ID adapter
before Rivencraft catalog publication/acknowledgement. It synchronizes integer
IDs and native type/sea-level metadata without delegating Age ownership. Failed
synchronization blocks acknowledgement/travel rather than making a successfully
committed Age appear to have failed binding. Startup, unload/reload, reseed and
retirement are covered. Live book/crystal rendering remains later work.
The installed working build passed 35 server creation/catalog checks plus 13 after
restart. Normal book binding, native metadata, API ID roundtrips, two Ages,
lifecycle changes and failed-creation nonpublication were checked. No-optional
startup passed. Real client travel remains pending; Prism files were not changed.
Correction to the initial artifact audit: old CurseForge file 8937394 fails during
its own constructor on .99 with missing DataPackRegistryEvent$NewRegistry; this is
NOT the latest release. The latest file is 9218525, displayed as 10.0.0 while its
filename/internal metadata still say 1.0.0. Its SHA-256 exactly matches the user's
installed working file:
5d2bfc2a811a6526e3f6722c521d97e5de2869a6ead1c681323c3c472e4367a0.
The compile dependency is corrected to 9218525 in .140. The earlier broad claim
that the current published build cannot start was wrong and is superseded here.
NeoForge audit on 2026-11-11: official Maven metadata lists .102 as latest in the
26.3 line. Its universal JAR still lacks the old NewRegistry class, so upgrading
cannot fix that OLD portal artifact. Current target remains validated .99; a
separate .102 build/client/server check is recommended. No release authorized.
### Immersive Portals Reborn comparison and shared bridge — 1.0.1.140-beta.1
The user suggested Reborn. Verified the NeoForge 26.3 release is 7.0.12, file
9435354 in CurseForge project 1721833. The newer 7.0.18 is Fabric. The Reborn JAR
registers immersive_portals_reborn plus its legacy core aliases and retains the
qouteall GUI rendering/portal/chunk/dimension APIs. It starts on NeoForge .99
without Cloth Config or other optional mods in the test environment.
Generalized the optional bridge to recognize either Reborn or Seamless. Both
share DimensionIntId.onServerDimensionChanged and the createFromServer packet
factory. Seamless's createPacket returns a vanilla Packet; Reborn's returns a
CustomPacketPayload. The adapter now wraps createFromServer in the vanilla
clientbound payload packet, preserving each fork's native two-/three-tag wire
format. No cross-fork packet schema is invented and neither dependency is bundled.
The compat probe recognizes both. A development run may select
-PwithImmersivePortalsReborn or -PwithSeamlessPortals; requesting both is rejected
because the forks share classes/mixins. The normal build remains optional-free.
Reborn passed 32 creation/catalog checks and 15 after restart: real book-binding
entry point, two runtime Ages, ID/type metadata, native wire roundtrip, per-player
packet preparation, unload/reload, reseed, retirement isolation and persistence.
Seamless latest file 9218525 passed the same 32 lifecycle/packet and 15 restart checks against the shared adapter.
These are server/packet checks, not evidence of renderer quality or real client
packet receipt. Reborn is a viable next client-test target; no performance
superiority is claimed. Live snapshots, chunk leases/audio isolation, and live
crystal surfaces still need porting. Player files were not modified. Temporary
worlds/fixtures removed; no commit, push or publication.
### Reborn live book previews — 1.0.1.141-beta.1
Linking and Descriptive Book front panels now use Immersive Portals Reborn's
live GUI-world renderer when Reborn is installed on both sides. This carries
forward the 1.21.1 camera orbit (11-block radius, five blocks above the focus),
destination sky/fog, chunk streaming and remote-audio isolation. Server destination
resolution uses the actual held book or a nearby loaded stand's matching book;
the client cannot request arbitrary destination coordinates.
A unique session ID prevents stale replies or close packets from replacing or
releasing another book's preview. One per-player eight-chunk-radius loader streams
the view asynchronously, renews while the book stays open, and expires if its
heartbeat is lost. Closing, logout, death/source invalidation, dimension changes,
and shutdown release the lease. Destination resolution for Descriptive Books
uses the same footing/platform rules as book travel.
The rendering integration uses 26.3 GPU texture views with the deferred GUI.
Reborn may resize its target before extraction records the texture view; clearing
is deferred to its render hook, and the normal GUI draws the image beneath the
Legacy panel cutout. The target uses the main framebuffer's depth/stencil format.
The player camera is restored after remote rendering, and remote preview-world
sounds are suppressed without muting the player's world.
Set `[book_previews] reborn_live_views = false` in the local configuration to use
animated panels. Missing Reborn or unavailable terrain also retains the animated
fallback. Seamless's dimension-ID bridge remains available, but this live GUI
implementation is activated only for Reborn. Live crystal portal surfaces and
preview exploration controls remain separate port work.
Validation: production build passed. An isolated real NeoForge 26.3.0.99
dedicated-server/client run with Reborn 7.0.12 displayed the remote live Linking
Book view; screenshot inspected for the panel cutout, upright image and actual
terrain. The client completed without a renderer exception and logout left no
preview chunk leases. The user also reported that the books displayed live views
perfectly. All temporary worlds/client directories and fixture sources were
removed, and the distributable was rebuilt without test classes.
Startup warnings in this isolated run included Reborn's four deprecated
`logoFile` metadata entries; these are upstream branding metadata warnings.
The Linux narrator also lacks `libflite`, unrelated to the book renderer.
### Reborn live crystal portals — 1.0.1.142-beta.1
Crystal portals now create optional native Reborn render-only surfaces. Each
coplanar opening is one masked mesh on each side, including irregular outlines;
the destination image does not repeat once per portal block. Mesh planes sit
inside the portal block thickness so the crystal frame remains in front. Once
native surfaces are established, LIVE_VIEW hides only the animated portal model.
The blocks and their receptacle owner pointers stay intact.
Live views share destination resolution with portal book travel. Linking Books
use their written facing, while Descriptive Books preserve the approaching
entity's facing as their travel path does. The reference camera is anchored to
standing eye height above the opening's floor, rather than to its vertical center.
This prevents taller frames from shifting the preview's arrival height. Relative
links resolve using the opening's center as the view reference; the actual
entity's departure position is still used for travel.
Native surfaces cannot teleport entities, accept remote interaction or perform
cross-portal collision. Existing Rivencraft collision/queue and book rules remain
authoritative, including catalog readiness, Disarm, momentum and arrival safety.
Reborn handles visible-destination streaming through its normal portal system.
Geometry/destination tags allow maintenance to reuse matching entities and replace
stale ones. Activation establishes views immediately; normal receptacle maintenance
restores them after restart. Book removal, frame shutdown, missing destinations
and disabling the integration remove the native surfaces. Without Reborn the
animated surface and ordinary travel continue to work. The server setting is
[book_previews] reborn_crystal_live_views, default true.
Validation on NeoForge 26.3.0.99 / Reborn 7.0.12: 68 server checks passed, covering
all three plane axes, both sides, one mesh per opening, eye-position and facing
transforms, entity reuse, fallback toggling and book removal. Five additional
restart checks passed. An isolated real client/dedicated server rendered the live
opening, then crossed into the correct Age through normal Rivencraft travel.
Screenshots before/after were inspected; the user also reported success.
An initial render test mistakenly bound its synthetic Linking Book at Y=1 inside
the flat world's solid layers. The view correctly displayed underground terrain
and the collision check refused travel. The corrected fixture bound to the actual
surface (Y=4) and passed. No production spawn-height workaround was added for an
invalid synthetic book. All temporary fixtures and test worlds were removed, and
the distributable was rebuilt without test classes.
+81
View File
@@ -0,0 +1,81 @@
# Rivencraft 26.3.0.1
For **Minecraft 26.3**, **NeoForge 26.3.0.99** and **Java 25**.
This regular release collects the player-validated .137–.142 development slices
since 1.0.1.136-beta.1. Versions now use the Minecraft version followed by the
release slice and its iteration: `26.3.0.1`. The JAR is
`rivencraft-26.3.0.1.jar`. The existing Minecraft 1.21.1 release is unchanged.
## Live destination views with Immersive Portals Reborn
- Optional live rotating destination views in Linking and Descriptive Books,
including books on stands. Reborn streams the actual destination chunks.
- Crystal portals show a continuous live view across each coplanar opening,
masked to its shape, with the crystal frame remaining in front.
- Linking Book views use the written destination/facing; Descriptive Book
portals preserve the traveler's facing. Views anchor standing eye height to
the opening's floor, so taller frames do not shift arrival height.
- Rivencraft retains control of portal travel, Disarm, momentum and arrival
safety. The native view cannot independently teleport or interact remotely.
- Preview sessions release their chunk streams on close, logout, invalidation
or expiry. Remote book-preview audio is suppressed and the player camera
restored after rendering.
- Portal maintenance restores views after restart and removes them when the
book is removed or the frame is shut down.
- Tested with **[Immersive Portals Reborn 7.0.12 for NeoForge 26.3](https://www.curseforge.com/minecraft/mc-mods/immersive-portals-reborn-unofficial-fork/files/9435354)**.
Use one portal fork per instance. No optional mod is bundled or required.
- The renamed Seamless fork also receives runtime Age dimension/metadata
synchronization; this release's live rendering is enabled for Reborn.
## Random Ages, pages and book descriptions
- New random-Age and library-page allowlists/blocklists accept short paths such
as `sky/static`, as well as full namespaced IDs.
- Blocklists take precedence. An allowlist restricts only the categories it
mentions. Existing Ages and manually authored books remain usable.
- Required empty categories use safe defaults; optional empty categories can be
omitted. Native preset biome pools and necessary biome minimum fallbacks can
bypass individual biome-page exclusions.
- Random-Age Descriptive Books show Legacy glyph descriptions of their public
traits, beneath the readable Age name/author. Seed and instability scores
remain absent from these descriptions.
- Optional portal-engine dimension IDs and native metadata synchronize before
the Rivencraft catalog acknowledgement permits travel.
## Configuration
In `config/rivencraft-local.toml`:
- `random_pages.allowlist` and `random_pages.blocklist`: default empty.
- `book_previews.reborn_live_views`: default `true`; disable locally for
animated book panels.
- `book_previews.reborn_crystal_live_views`: default `true`; the server
controls live crystal surfaces. Disabling retains animated surfaces and travel.
The built-in Legacy animation remains available without optional renderers.
Dynamic Dimensions and Bad Packets are not required by the 26.3 line.
## Validation and known limits
The user passed the included slices in game. Disposable checks covered random
policy resolution, description/catalog serialization and persistence, native
dimension IDs and packet formats for both portal forks, preview rendering and
chunk-lease cleanup, all three crystal portal plane axes, both sides, destination
eye/facing transforms, fallback toggling and full restart restoration. A real
dedicated server/client displayed the live crystal view and crossed into its Age.
The no-optional-mod server also started successfully. Temporary fixtures/worlds
were removed and the production JAR contains no test or optional-engine classes.
**Known issue [#116](https://git.paddedcell.org/dnorberg/rivencraft/issues/116):**
with Reborn active, a Static Sky Age can render its sky outside the book GUI.
Use the animated book panel option if affected.
26.3 is now published on the regular release channel at the user's request.
This is not a claim that every 1.21.1 behavior or the final expanded-catalog
two-client regression is complete. The remaining multiplayer, parity and shared
follow-up issues stay open. Preview exploration controls and Create/Ponder scenes
are not included in this release.
Use matching client/server JARs. Back up worlds, keep the Minecraft tracks and
saves separate, and do not downgrade worlds containing newer data.
+38
View File
@@ -0,0 +1,38 @@
# Rivencraft 26.3.0.3
For **Minecraft 26.3**, **NeoForge 26.3.0.99** and **Java 25**.
## Book preview exploration
- The eye button now matches 1.21.1: it grabs the mouse for look-only exploration
while the destination view remains inside the book panel. Movement is disabled.
- Right-click returns to the ordinary book controls without releasing the preview.
Books on stands retain their disabled exploration button.
- Fixed the exit crash caused by a forced render and duplicate Reborn render-target
submission. Pending target submissions are reused until the renderer drains them.
- Removed remaining Beta branding from the in-game mod name/description.
## Port acceptance and maintenance
- Completed dedicated-server restart/return-link acceptance for #100, following
the two-client matrix and its manual handoff. The player confirmed return travel.
- Added changed-target build/smoke workflows, sequential test-server exclusion and
repeatable dual-version validation commands.
- Hardened release uploads against mismatched filename, embedded version,
Minecraft/Java target and accidental test-fixture packaging. The 1.21.1 metadata
declares its required Dynamic Dimensions AR and Bad Packets dependencies.
- Documented the feature parity review and ongoing independent support of 1.21.1.
Existing 1.21.1 code and published artifacts are unchanged.
## Validation and known issues
The player passed the live-view exploration/right-click/re-entry check and the
dedicated-server Age restart/Linking Book return check. The expanded-catalog
two-client matrix passed creation/catalog/travel/stale-ack/lifecycle checks;
its final return step stalled and was completed by the player, not retried.
Release-tooling checks pass. No optional mod or test fixture is bundled.
**Known issue #116:** Static Sky can leak outside book previews with Reborn.
It remains separately tracked. Other shared follow-ups remain open where needed.
Create/Ponder is not ported to 26.3. Without Reborn, built-in animated previews
and normal book/portal travel remain available. 1.21.1 saves are not imported.
+35
View File
@@ -0,0 +1,35 @@
# Random Page policy test — 1.0.1.137-beta.1
Install the new JAR and launch once so the new entries appear in
Minecraft's config/rivencraft-local.toml. Close the instance before editing.
On a dedicated server edit its own config, not the client's.
For a visible check, replace only the existing random_pages section with:
```toml
[random_pages]
allowlist = ["terrain/flat", "biome/single", "biome/plains", "sky/static", "lighting/normal", "weather/off", "feature/not_present"]
blocklist = ["sky/nether", "celestial/sun", "celestial/moon"]
```
The deliberately nonexistent feature entry restricts only optional features,
leaving an empty feature pool for this test.
1. Bind a Descriptive Book with only a Link Panel. Enter it: expect Flat terrain,
Plains, Static Sky, normal lighting, no rain and no suns/moons. Optional Page
features/structures should be absent; native biome decoration is independent.
2. Bind an authored Flat/Plains/Single/Nether Sky book. Nether Sky remains usable
despite the blocklist; the setting controls random generation and direct
library loot, not manual authorship. The Page remains visible in Creative/JEI.
3. Change sky/static to sky/end, restart and revisit the first Age. It must retain
Static Sky. A newly bound Link-Panel-only book should get End Sky.
4. Optional library check: allowlist = ["sky/static"], blocklist = ["sky/nether"].
New library direct Pages may include Static Sky or other unrestricted
categories, but never another Sky Page. Test both library loot modes where
desired. Existing chests/stands remain untouched; AGE_ONLY may have no Pages
when the book's written Page set has no eligible entries.
Restore both lists to [] afterward unless you want the restrictions to persist.
No existing Age should change. Keep the full world backup customary for beta
updates. Native biome presets and required minimum-biome fallbacks intentionally
bypass biome-Page filters; Sealed Notebooks keep their separate 1.21.1 draw pool.
+21
View File
@@ -0,0 +1,21 @@
# Random-Age glyph description test — 1.0.1.138-beta.1
Use the new build on both client and server (Age catalog protocol 11).
1. Open an existing or new Link-Panel-only Descriptive Book. Its front spread
should retain the plain-text Age name and author on the left, with the generated
description below rendered as Legacy glyphs. The destination animation remains
on the right and clicking it still travels.
2. Put the same book on a lectern and open it. The description and panel should
match handheld use.
3. Open an authored book containing Symbol Pages. Its front spread should retain
just the name/author, and page turning should show one authored symbol at a time.
4. Reconnect or restart, then reopen the random book. Glyph traits should remain
consistent with its saved Age. Test a longer name and GUI scale for clipping.
5. Check Writing Desk tab numbers around 24/25 and 49/50. Their existing glyph
size/position and base-25 behavior should be unchanged.
Descriptions are available only for loaded Ages; viewing a book does not force-load
an unloaded/retired Age. No instability score or seed appears in the description.
The desktop glyph display still needs your visual check; server/codec/layout
validation does not substitute for a real client render check.
+8
View File
@@ -0,0 +1,8 @@
# Superseded by the 1.0.1.140-beta.1 portal-fork test
Use TESTING_1.0.1.140-beta.1.md. It covers both supported fork candidates.
Correction: the user's installed Seamless file exactly matches current published
CurseForge file 9218525 (displayed 10.0.0, internal version/filename 1.0.0).
The startup failure applies only to older file 8937394. The earlier recommendation
to avoid the current download was incorrect.
+29
View File
@@ -0,0 +1,29 @@
# Portal fork runtime compatibility — 1.0.1.140-beta.1
Candidate downloads for Minecraft 26.3 / NeoForge:
- Immersive Portals Reborn 7.0.12:
https://www.curseforge.com/minecraft/mc-mods/immersive-portals-reborn-unofficial-fork/files/9435354
Version 7.0.18 is Fabric, so use the NeoForge 7.0.12 file for this test.
- Seamless latest file 9218525:
https://www.curseforge.com/minecraft/mc-mods/immersive-portal/files/9218525
Display name is 10.0.0, but its downloaded filename/internal version remain 1.0.0.
This exactly matches the user's installed working JAR.
Use ONE portal fork per instance: they share engine classes and mixins.
Prism files were left unchanged. Install matching Rivencraft builds on both sides
for dedicated testing. Current tested loader is NeoForge 26.3.0.99.
1. Enable/install the chosen fork and Rivencraft .140.
2. Create an authored Descriptive Book after joining, using a distinctive sky and
lighting. Travel into its newly created Age without reconnecting.
3. Return using a bound Linking Book and test a second freshly created Age.
4. Restart and revisit both Ages. Check for unknown/unloaded-dimension rejection,
disconnects or incorrect sky/lighting.
5. Test existing crystal portals through the usual Rivencraft fallback path.
This slice synchronizes runtime Ages and native metadata. Live book panels and
live crystal portal surfaces are NOT ported yet; animated fallbacks are expected.
Actual client travel/packet handling needs your check. Server lifecycle, packet
serialization and restart checks passed with both Reborn and latest Seamless
(32 lifecycle/packet plus 15 restart checks each). NeoForge .102 is a candidate for a later isolated upgrade
check; the project target has not been changed yet.
+26
View File
@@ -0,0 +1,26 @@
# 1.0.1.141-beta.1 — Reborn live book previews
Use NeoForge 26.3.0.99 and Immersive Portals Reborn 7.0.12, CurseForge file 9435354.
Install one portal fork per instance and matching Rivencraft builds on a dedicated
server/client. Reborn remains optional; nothing is bundled.
The user reports live views working in both book types. Follow-up coverage:
1. View a Linking Book and Descriptive Book pointing to different Ages. Check
orbiting terrain, upright orientation, destination lighting/sky and the panel
cutout. Click the live panel to travel using the existing book controls.
2. Repeat from a book stand, including a destination whose chunks are not loaded.
3. Close/reopen, switch books quickly and restart/revisit the Age. A stale view
must not replace the newly selected destination.
4. During rain, confirm the source world's weather audio remains audible and the
remote world's sounds do not leak into the GUI.
5. Set [book_previews] reborn_live_views = false in rivencraft-local.toml to
verify the animated fallback; restore true for live views.
The live image streams actual destination chunks. It is not restricted to the
old fixed block snapshot volume. Exploration controls and live crystal portal
surfaces are not included in this slice.
Build and isolated dedicated/client rendering check passed, with a live Linking
Book screenshot inspected and zero remaining preview leases on logout. Temporary
test worlds were removed regardless of result. Reborn's four deprecated logoFile
startup warnings concern its metadata, not Rivencraft rendering.
+30
View File
@@ -0,0 +1,30 @@
# 1.0.1.142-beta.1 — Reborn live crystal portal surfaces
Use Reborn 7.0.12 for NeoForge 26.3, CurseForge file 9435354, with NeoForge
26.3.0.99. Use one portal fork and matching Rivencraft builds on a server/client.
Implemented and validated:
- One continuous view per opening on each side, masked to the portal cells.
- Correct destination eye-height reference; Linking Book facing and Descriptive
Book inherited facing.
- Native surfaces render only; Rivencraft's blocks retain transport and effects.
- Fallback model hidden only for established live surfaces.
- Maintenance restores views after restart and removes them on shutdown/removal.
- Optional server setting [book_previews] reborn_crystal_live_views = false retains
animated surfaces and ordinary portal travel.
68 server checks and five restart checks passed, plus a real dedicated/client
live-view and walking-through check. Screenshots were inspected and the user
reported success. Temporary worlds were deleted and fixture sources removed.
Useful follow-up manual coverage:
1. Try both book types, compare portal arrival to using the same book, and inspect
both sides of the frame.
2. Try taller, horizontal and irregular frames; check that frame edges occlude the
scene and the view does not repeat per cell.
3. Restart, revisit, remove/reinsert the book and break a frame block.
4. Check a destination with different sky/lighting and an initially unloaded
destination area.
The initial synthetic render fixture used a blocked Y=1 target; that was corrected
to the resolved Y=4 surface. No global landing-height behavior was changed.
+18 -1
View File
@@ -17,7 +17,24 @@ repositories {
content { includeGroup 'mezz.jei'; includeGroup 'net.mezzdev.config' }
}
}
// Optional APIs stay out of published dependency metadata.
configurations {
localRuntime
runtimeClasspath.extendsFrom localRuntime
}
dependencies {
// Both optional forks retain qouteall APIs, but must never be loaded together.
if (project.hasProperty('withSeamlessPortals') && project.hasProperty('withImmersivePortalsReborn'))
throw new GradleException('Choose one Immersive Portals fork for the development run')
def portalDependency = project.hasProperty('withImmersivePortalsReborn')
? 'curse.maven:immersive-portals-reborn-unofficial-fork-1721833:9435354'
: project.hasProperty('seamlessPortalsJar') ? files(project.property('seamlessPortalsJar'))
: project.hasProperty('withSeamlessPortals') ? 'curse.maven:immersive-portal-1511174:9218525'
: 'curse.maven:immersive-portals-reborn-unofficial-fork-1721833:9435354'
compileOnly portalDependency
if (project.hasProperty('withSeamlessPortals') || project.hasProperty('withImmersivePortalsReborn'))
localRuntime portalDependency
// Compile against the 26.3 baseline API; a local current Ruins JAR can override it.
def ruinsDependency = project.hasProperty('ruinsJar') ? files(project.property('ruinsJar'))
: 'curse.maven:ruins-227873:8962119'
@@ -40,7 +57,7 @@ group = 'net.dnorberg.rivencraft'
version = mod_version
base {
archivesName = "rivencraft-${minecraft_version}"
archivesName = "rivencraft"
}
java.toolchain.languageVersion = JavaLanguageVersion.of(25)
+1 -1
View File
@@ -7,7 +7,7 @@ neo_version=26.3.0.99
mod_id=rivencraft
mod_name=Rivencraft
mod_license=LGPL-3.0-or-later
mod_version=1.0.1.136-beta.1
mod_version=26.3.0.3
jei_version=31.17.0.109
@@ -7,6 +7,10 @@ public final class PortConfig {
public enum LibraryLootMode { LEGACY, AGE_ONLY }
public static final ModConfigSpec.EnumValue<LibraryLootMode> LIBRARY_LOOT_MODE;
public enum ReturnLinkMode { ON_CREATION, ALWAYS, DISABLED }
public static final ModConfigSpec.ConfigValue<java.util.List<? extends String>> RANDOM_PAGE_ALLOWLIST;
public static final ModConfigSpec.ConfigValue<java.util.List<? extends String>> RANDOM_PAGE_BLOCKLIST;
public static final ModConfigSpec.BooleanValue LIVE_BOOK_PREVIEWS;
public static final ModConfigSpec.BooleanValue CRYSTAL_LIVE_VIEWS;
public static final ModConfigSpec SPEC;
public static final ModConfigSpec.BooleanValue AUTO_WHITELIST_SERENE_AGES;
public static final ModConfigSpec.IntValue SYMBOL_INK_COST;
@@ -17,6 +21,26 @@ public final class PortConfig {
static {
var builder = new ModConfigSpec.Builder();
builder.push("book_previews");
LIVE_BOOK_PREVIEWS = builder.comment("Show live rotating destination views when Immersive Portals Reborn is installed on client and server.",
"Disable locally to retain the animated Legacy link panels.")
.define("reborn_live_views", true);
CRYSTAL_LIVE_VIEWS = builder.comment("Server option: create native live crystal portal surfaces when Reborn is installed.",
"Disabling keeps crystal portal travel with the animated surface.")
.define("reborn_crystal_live_views", true);
builder.pop();
builder.push("random_pages");
RANDOM_PAGE_ALLOWLIST = builder.comment(
"Page paths allowed in new random Ages and newly generated library pages.",
"Restricts only categories it mentions; other categories remain unrestricted.",
"Example: [\"sky/static\"]; full namespaced IDs are accepted too.")
.defineList("allowlist", java.util.List.<String>of(), () -> "sky/static", PortConfig::validPageId);
RANDOM_PAGE_BLOCKLIST = builder.comment(
"Page paths excluded from new random Ages and newly generated library pages.",
"Blocklist wins. Existing Ages, Creative/JEI pages and manually authored books remain usable.",
"Required empty categories use safe defaults; Native biomes come from the preset, not biome pages.")
.defineList("blocklist", java.util.List.<String>of(), () -> "sky/static", PortConfig::validPageId);
builder.pop();
builder.push("serene_seasons");
AUTO_WHITELIST_SERENE_AGES = builder.comment("Automatically whitelist Ages with seed-selected/legacy cycling seasons. Explicit pages still apply; existing entries are preserved when disabled.")
.define("auto_whitelist_ages", true);
@@ -47,5 +71,10 @@ public final class PortConfig {
SPEC = builder.build();
}
private static boolean validPageId(Object value) {
if (!(value instanceof String id)) return false;
try { net.dnorberg.rivencraft.age.PortRandomPagePolicy.parsePageId(id); return true; }
catch (IllegalArgumentException exception) { return false; }
}
private PortConfig() {}
}
@@ -249,10 +249,13 @@ public final class Rivencraft {
COMPONENTS.register(modEventBus);
RECIPE_SERIALIZERS.register(modEventBus);
net.dnorberg.rivencraft.compat.RuinsCompatibility.register();
net.dnorberg.rivencraft.compat.RebornBookPreviews.register();
ModCreativeTabs.register(modEventBus);
PortBiomeSources.register(modEventBus);
PortChunkGenerators.register(modEventBus);
NeoForge.EVENT_BUS.addListener(RuntimeAgeBackend::onLevelLoad);
NeoForge.EVENT_BUS.addListener(net.neoforged.bus.api.EventPriority.LOWEST,
net.dnorberg.rivencraft.compat.ImmersivePortalsCompatibility::onServerStarted);
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAgeWeather::onLevelTickPre);
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAcceleratedTicks::onLevelTickPost);
NeoForge.EVENT_BUS.addListener(PortAgeCommands::register);
@@ -13,7 +13,8 @@ import net.minecraft.resources.Identifier;
/** Full server level catalog, sent on login and after a runtime Age is added. */
public record AgeCatalogPayload(int version, List<Identifier> dimensions,
Map<Identifier, PortAgeColors> colors,
Map<Identifier, PortDayCycle> dayCycles) implements CustomPacketPayload {
Map<Identifier, PortDayCycle> dayCycles,
Map<Identifier, PortAgeDescription> descriptions) implements CustomPacketPayload {
public static final Type<AgeCatalogPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age_catalog"));
public static final StreamCodec<RegistryFriendlyByteBuf, AgeCatalogPayload> STREAM_CODEC = StreamCodec.composite(
@@ -21,6 +22,7 @@ public record AgeCatalogPayload(int version, List<Identifier> dimensions,
Identifier.STREAM_CODEC.apply(ByteBufCodecs.list()), AgeCatalogPayload::dimensions,
ByteBufCodecs.map(HashMap::new, Identifier.STREAM_CODEC, PortAgeColors.STREAM_CODEC), AgeCatalogPayload::colors,
ByteBufCodecs.map(HashMap::new, Identifier.STREAM_CODEC, PortDayCycle.STREAM_CODEC), AgeCatalogPayload::dayCycles,
ByteBufCodecs.map(HashMap::new, Identifier.STREAM_CODEC, PortAgeDescription.STREAM_CODEC), AgeCatalogPayload::descriptions,
AgeCatalogPayload::new
);
@@ -28,6 +30,7 @@ public record AgeCatalogPayload(int version, List<Identifier> dimensions,
dimensions = List.copyOf(dimensions);
colors = Map.copyOf(colors);
dayCycles = Map.copyOf(dayCycles);
descriptions = Map.copyOf(descriptions);
}
@Override
@@ -21,7 +21,7 @@ public final class AgeCatalogSync {
}
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
var registrar = event.registrar("10");
var registrar = event.registrar("11");
registrar.playToClient(AgeCatalogPayload.TYPE, AgeCatalogPayload.STREAM_CODEC);
registrar.playToServer(AgeCatalogAckPayload.TYPE, AgeCatalogAckPayload.STREAM_CODEC,
(payload, context) -> {
@@ -55,6 +55,9 @@ public final class AgeCatalogSync {
State state = state(server);
state.version++;
state.acknowledged.clear();
// Optional synchronization failures must not turn a committed Age into a
// reported binding failure. Withhold acknowledgements, so travel stays safe.
if (!net.dnorberg.rivencraft.compat.ImmersivePortalsCompatibility.synchronize(server)) return;
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
try {
send(player);
@@ -76,9 +79,11 @@ public final class AgeCatalogSync {
MinecraftServer server = player.level().getServer();
State state = state(server);
state.acknowledged.remove(player.getUUID());
if (!net.dnorberg.rivencraft.compat.ImmersivePortalsCompatibility.beforeCatalog(player))
throw new IllegalStateException("Optional portal dimension catalog is unavailable");
var dimensions = server.levelKeys().stream().map(key -> key.identifier()).sorted().toList();
PacketDistributor.sendToPlayer(player, new AgeCatalogPayload(state.version, dimensions,
RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server)));
RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server), RuntimeAgeBackend.loadedDescriptions(server)));
}
private static void acknowledge(ServerPlayer player, int version) {
@@ -46,7 +46,7 @@ public final class PortAgeCommands {
StringBuilder mods = new StringBuilder("Optional mods: ");
for (String id : new String[]{"jei", "terrablender", "biomesoplenty",
"sereneseasons", "glitchcore", "ruins", "create",
"ponder", "immersive_portals_core"}) {
"ponder", "immersive_portals_core", "seamlessportals", "immersive_portals_reborn"}) {
if (mods.length() > "Optional mods: ".length()) mods.append(", ");
mods.append(id).append('=').append(ModList.get().isLoaded(id));
}
@@ -1777,7 +1777,7 @@ public final class PortAgeCommands {
private static void verifyCatalogRoundTrip(net.minecraft.server.MinecraftServer server) {
var payload = new AgeCatalogPayload(1, server.levelKeys().stream().map(key -> key.identifier()).toList(),
RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server));
RuntimeAgeBackend.savedColors(server), RuntimeAgeBackend.savedDayCycles(server), RuntimeAgeBackend.loadedDescriptions(server));
var buffer = new net.minecraft.network.RegistryFriendlyByteBuf(io.netty.buffer.Unpooled.buffer(),
server.registryAccess(), net.neoforged.neoforge.network.connection.ConnectionType.NEOFORGE);
try {
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.age;
import java.util.Map;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.Identifier;
/** Public presentation only: no seed, instability score or provider information. */
public record PortAgeDescription(PortAgeTerrain terrain, PortBiomePlan.Distribution distribution,
PortLighting lighting, PortWeatherMode weather) {
public static final StreamCodec<RegistryFriendlyByteBuf, PortAgeDescription> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.STRING_UTF8.map(PortAgeTerrain::valueOf, PortAgeTerrain::name), PortAgeDescription::terrain,
ByteBufCodecs.STRING_UTF8.map(PortBiomePlan.Distribution::valueOf, PortBiomePlan.Distribution::name), PortAgeDescription::distribution,
ByteBufCodecs.STRING_UTF8.map(PortLighting::valueOf, PortLighting::name), PortAgeDescription::lighting,
ByteBufCodecs.STRING_UTF8.map(PortWeatherMode::valueOf, PortWeatherMode::name), PortAgeDescription::weather,
PortAgeDescription::new);
private static volatile Map<Identifier, PortAgeDescription> clientDescriptions = Map.of();
public static void updateClient(Map<Identifier, PortAgeDescription> descriptions) {
clientDescriptions = Map.copyOf(descriptions);
}
public static PortAgeDescription forClient(Identifier id) { return clientDescriptions.get(id); }
}
@@ -36,10 +36,14 @@ public final class PortAgeTravel {
public static BlockPos resolveLanding(ServerLevel target) { return resolveLanding(target, true); }
public static BlockPos resolveLanding(ServerLevel target, net.minecraft.world.entity.Entity entity,
net.dnorberg.rivencraft.item.LinkProperties properties) {
return resolveLanding(target, (ServerLevel) entity.level(), entity.blockPosition(), properties);
}
public static BlockPos resolveLanding(ServerLevel target, ServerLevel source, BlockPos departure,
net.dnorberg.rivencraft.item.LinkProperties properties) {
BlockPos landing = resolveLanding(target, !properties.has(net.dnorberg.rivencraft.item.LinkProperties.DISABLE_PLATFORM));
if (landing == null || !properties.has(net.dnorberg.rivencraft.item.LinkProperties.RELATIVE_LINK)) return landing;
return findFootedDestination(target, net.dnorberg.rivencraft.item.PortLinkDestinations.relativeOrigin(
(ServerLevel) entity.level(), target, entity.blockPosition())).orElse(null);
source, target, departure)).orElse(null);
}
public static BlockPos resolveLanding(ServerLevel target, boolean generatePlatform) {
boolean fissure = target.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features
@@ -144,6 +144,10 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
/** Creation-only completion. Exact diagnostic fixtures can still use compile. */
public static PortDayCycle resolve(List<Identifier> pages, long seed, long nativeTicks) {
return resolve(pages, seed, nativeTicks, PortRandomPagePolicy.UNRESTRICTED);
}
public static PortDayCycle resolve(List<Identifier> pages, long seed, long nativeTicks, PortRandomPagePolicy policy) {
if (!validWriting(pages))
throw new IllegalArgumentException("An Age may have at most ten suns and ten moons");
if (pages.stream().anyMatch(PortDayCycle::isController)) return compile(pages, seed, nativeTicks);
@@ -152,7 +156,8 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
var random = new Random(seed ^ 0x534B5950524F464CL);
random.nextInt(5);
randomColor(random);
int suns = random.nextInt(4), moons = random.nextInt(4);
int suns = policy.allows(Identifier.parse("rivencraft:celestial/sun")) ? random.nextInt(4) : 0;
int moons = policy.allows(Identifier.parse("rivencraft:celestial/moon")) ? random.nextInt(4) : 0;
var bodies = new java.util.ArrayList<PortCelestialBody>();
for (int i = 0; i < suns + moons; i++)
bodies.addAll(randomUnmodified(seed, i, nativeTicks, i < suns ? SUN : MOON).bodies);
@@ -309,6 +314,8 @@ public record PortDayCycle(boolean authored, double period, int stoppedTime,
net.minecraft.core.registries.Registries.WORLD_CLOCK).getOrThrow(WorldClocks.OVERWORLD)).totalTicks();
}
public static PortDayCycle forClient(Identifier id) { return clientCycles.getOrDefault(id, NATIVE); }
public static void updateClient(Map<Identifier, PortDayCycle> cycles) { clientCycles = Map.copyOf(cycles); }
public static PortDayCycle forLevel(Level level) {
if (level instanceof PortAgeLevel age) return age.dayCycle();
@@ -1,6 +1,5 @@
package net.dnorberg.rivencraft.age;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import net.minecraft.resources.Identifier;
@@ -8,21 +7,28 @@ import net.minecraft.resources.Identifier;
/** Seed-completes missing environment controllers once, before an Age is saved. */
public record PortEnvironmentSelection(PortWeatherMode weather, PortLighting lighting, PortSkyStyle sky) {
public static PortEnvironmentSelection resolve(List<Identifier> pages, long seed, PortAgeTerrain terrain) {
PortLighting lighting = pages.stream().map(PortLighting::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x4C49474854494E47L), PortLighting.values()));
PortWeatherMode weather = terrain == PortAgeTerrain.CAVE ? PortWeatherMode.OFF
: pages.stream().map(PortWeatherMode::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x574541544845525FL), PortWeatherMode.values()));
return new PortEnvironmentSelection(weather, lighting, PortSkyStyle.resolve(pages, seed));
return resolve(pages, seed, terrain, PortRandomPagePolicy.UNRESTRICTED);
}
private static <T> T rankedDefault(Random random, T[] choices) {
public static PortEnvironmentSelection resolve(List<Identifier> pages, long seed, PortAgeTerrain terrain,
PortRandomPagePolicy policy) {
PortLighting lighting = pages.stream().map(PortLighting::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x4C49474854494E47L), PortLighting.values(), "lighting", policy));
PortWeatherMode weather = terrain == PortAgeTerrain.CAVE ? PortWeatherMode.OFF
: pages.stream().map(PortWeatherMode::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElseGet(() -> rankedDefault(new Random(seed ^ 0x574541544845525FL), PortWeatherMode.values(), "weather", policy));
return new PortEnvironmentSelection(weather, lighting, PortSkyStyle.resolve(pages, seed, policy));
}
private static <T extends Enum<T>> T rankedDefault(Random random, T[] choices, String category, PortRandomPagePolicy policy) {
// LegacyRankChoice: the first (Normal) option is rank 1, the rest rank 2.
// Its rank-1 weight is the complete rare group plus one. Derive this
// from the supported pool, rather than selecting unported weather.
int[] weights = new int[choices.length];
Arrays.fill(weights, 1);
weights[0] = choices.length;
return PortRandomAgeSelection.weighted(random, List.of(choices), weights);
var options = new java.util.ArrayList<PortRankedChoice.Option<T>>();
for (int i = 0; i < choices.length; i++)
options.add(new PortRankedChoice.Option<>(choices[i], i == 0 ? 1 : 2));
return PortRankedChoice.choose(random, policy.required(options,
option -> PortRandomPagePolicy.page(category, option.value()),
new PortRankedChoice.Option<>(choices[0], 1)));
}
}
@@ -333,9 +333,14 @@ public final class PortLibraryFeature {
}
static List<Identifier> symbolsForLoot(List<Identifier> ageSymbols, PortConfig.LibraryLootMode mode) {
return symbolsForLoot(ageSymbols, mode, PortRandomPagePolicy.configured());
}
static List<Identifier> symbolsForLoot(List<Identifier> ageSymbols, PortConfig.LibraryLootMode mode,
PortRandomPagePolicy policy) {
return mode == PortConfig.LibraryLootMode.AGE_ONLY
? ageSymbols.stream().filter(id -> PortSymbolPages.find(id) != null).distinct().toList()
: PortSymbolPages.sealedCandidateIds();
? ageSymbols.stream().filter(policy::allows).filter(id -> PortSymbolPages.find(id) != null).distinct().toList()
: PortSymbolPages.libraryCandidateIds().stream().filter(policy::allows).toList();
}
private static ItemStack page(List<Identifier> symbols, RandomSource random) {
@@ -17,17 +17,32 @@ public final class PortRandomAgeSelection {
public record Selection(PortAgeTerrain terrain, PortBiomePlan biomes) {}
public static Selection select(MinecraftServer server, long seed) {
return select(server, seed, PortRandomPagePolicy.UNRESTRICTED);
}
public static Selection select(MinecraftServer server, long seed, PortRandomPagePolicy policy) {
// Match the 1.21.1 Legacy-rank choices: Standard and Flat are common;
// the other terrain families are rarer. The sorted order is deliberate.
PortAgeTerrain terrain = terrain(seed);
PortAgeTerrain terrain = terrain(seed, policy);
if (terrain == PortAgeTerrain.VOID)
return new Selection(terrain, new PortBiomePlan(PortBiomePlan.Distribution.SINGLE,
List.of(Identifier.withDefaultNamespace("the_void"))));
PortBiomePlan.Distribution distribution = distribution(seed);
PortBiomePlan.Distribution distribution = distribution(seed, policy);
List<Identifier> eligible = eligibleBiomes(server, terrain);
if (eligible.size() < 2 && distribution != PortBiomePlan.Distribution.NATIVE)
distribution = PortBiomePlan.Distribution.SINGLE;
if (distribution != PortBiomePlan.Distribution.NATIVE) {
var filtered = new ArrayList<>(policy.eligible(eligible, PortRegistryPages::biomePageId));
// Native presets are not Page-selected. Other controllers retain their minimum,
// with the same preferred-biome/registry fallback as 1.21.1.
int minimum = distribution == PortBiomePlan.Distribution.SINGLE ? 1 : 2;
Identifier preferred = Identifier.withDefaultNamespace(terrain == PortAgeTerrain.CAVE ? "deep_dark" : "plains");
if (filtered.size() < minimum && eligible.contains(preferred) && !filtered.contains(preferred))
filtered.add(preferred);
for (var candidate : eligible)
if (filtered.size() < minimum && !filtered.contains(candidate)) filtered.add(candidate);
eligible = List.copyOf(filtered);
if (eligible.size() < 2) distribution = PortBiomePlan.Distribution.SINGLE;
}
if (distribution == PortBiomePlan.Distribution.NATIVE)
return new Selection(terrain, new PortBiomePlan(distribution,
terrain == PortAgeTerrain.CAVE ? eligible : List.of()));
@@ -48,18 +63,28 @@ public final class PortRandomAgeSelection {
}
public static PortAgeTerrain terrain(long seed) {
return weighted(new Random(seed ^ 0x5445525241494E4CL), terrains(), new int[]{1, 1, 5, 1, 6, 1});
return terrain(seed, PortRandomPagePolicy.UNRESTRICTED);
}
public static PortAgeTerrain terrain(long seed, PortRandomPagePolicy policy) {
return ranked(new Random(seed ^ 0x5445525241494E4CL), terrains(), new int[]{3, 3, 2, 3, 1, 3},
policy, value -> PortRandomPagePolicy.page("terrain", value), PortAgeTerrain.STANDARD);
}
/** First AgeTraits draw in 1.21.1; shared by random and partly authored Ages. */
public static PortBiomePlan.Distribution distribution(long seed) {
return weighted(new Random(seed ^ 0x524956454E435241L),
return distribution(seed, PortRandomPagePolicy.UNRESTRICTED);
}
public static PortBiomePlan.Distribution distribution(long seed, PortRandomPagePolicy policy) {
return ranked(new Random(seed ^ 0x524956454E435241L),
List.of(PortBiomePlan.Distribution.NATIVE, PortBiomePlan.Distribution.SINGLE,
PortBiomePlan.Distribution.MEDIUM, PortBiomePlan.Distribution.TILED,
PortBiomePlan.Distribution.GRID, PortBiomePlan.Distribution.TINY,
PortBiomePlan.Distribution.SMALL, PortBiomePlan.Distribution.LARGE,
PortBiomePlan.Distribution.HUGE),
new int[]{3, 3, 3, 1, 1, 1, 1, 1, 1});
new int[]{1, 1, 1, 2, 2, 2, 2, 2, 2}, policy,
value -> PortRandomPagePolicy.page("biome", value), PortBiomePlan.Distribution.NATIVE);
}
public static List<Identifier> eligibleBiomes(MinecraftServer server, PortAgeTerrain terrain) {
@@ -84,14 +109,13 @@ public final class PortRandomAgeSelection {
return caves.isEmpty() ? List.of(Identifier.withDefaultNamespace("lush_caves")) : caves;
}
static <T> T weighted(Random random, List<T> choices, int[] weights) {
int total = 0;
for (int weight : weights) total += weight;
int ticket = random.nextInt(total);
for (int index = 0; index < choices.size(); index++) {
ticket -= weights[index];
if (ticket < 0) return choices.get(index);
}
throw new IllegalStateException("Random Age ticket was not consumed");
private static <T> T ranked(Random random, List<T> choices, int[] ranks, PortRandomPagePolicy policy,
java.util.function.Function<T, Identifier> page, T fallback) {
var options = new ArrayList<PortRankedChoice.Option<T>>();
for (int i = 0; i < choices.size(); i++)
options.add(new PortRankedChoice.Option<>(choices.get(i), ranks[i]));
var eligible = policy.eligible(options, option -> page.apply(option.value()));
return eligible.isEmpty() ? fallback : PortRankedChoice.choose(random, eligible);
}
}
@@ -14,8 +14,13 @@ public final class PortRandomFeatureSelection {
public static Selection select(net.minecraft.server.MinecraftServer server, long seed,
PortAgeTerrain terrain, PortBiomePlan biomes) {
var base = select(seed, terrain);
var structures = PortRandomStructureSelection.select(server, seed, terrain, biomes);
return select(server, seed, terrain, biomes, PortRandomPagePolicy.UNRESTRICTED);
}
public static Selection select(net.minecraft.server.MinecraftServer server, long seed,
PortAgeTerrain terrain, PortBiomePlan biomes, PortRandomPagePolicy policy) {
var base = select(seed, terrain, policy);
var structures = PortRandomStructureSelection.select(server, seed, terrain, biomes, policy);
var plan = base.terrain();
plan = plan == null ? new PortTerrainFeaturePlan(List.of(), false, 0, false, structures.structures(), List.of(), true)
: new PortTerrainFeaturePlan(plan.choices(), plan.skylands(), plan.denseOres(), plan.starFissures(),
@@ -24,6 +29,10 @@ public final class PortRandomFeatureSelection {
}
public static Selection select(long seed, PortAgeTerrain terrain) {
return select(seed, terrain, PortRandomPagePolicy.UNRESTRICTED);
}
public static Selection select(long seed, PortAgeTerrain terrain, PortRandomPagePolicy policy) {
var random = new Random(seed ^ 0x4D4154455249414CL);
int count = random.nextInt(3);
var terrainPage = id("terrain/" + terrain.name().toLowerCase(java.util.Locale.ROOT));
@@ -31,12 +40,13 @@ public final class PortRandomFeatureSelection {
for (String name : List.of("rocks", "floating_islands", "tendrils", "spheres", "spikes", "obelisks",
"surface_lakes", "deep_lakes", "lava_lakes")) {
var page = id("feature/" + name);
if (PortAuthoredWriting.supports(List.of(terrainPage, page)))
if (policy.allows(page) && PortAuthoredWriting.supports(List.of(terrainPage, page)))
candidates.add(new PortRankedChoice.Option<>(page, rank(name)));
}
var pages = new ArrayList<Identifier>();
pages.add(terrainPage);
List<Identifier> solids = PortRegistryPages.solidMaterialIds(), fluids = PortRegistryPages.fluidMaterialIds();
List<Identifier> solids = policy.eligible(PortRegistryPages.solidMaterialIds(), PortRegistryPages::blockPageId);
List<Identifier> fluids = policy.eligible(PortRegistryPages.fluidMaterialIds(), PortRegistryPages::blockPageId);
for (int selected = 0; selected < count && !candidates.isEmpty();) {
Identifier feature = PortRankedChoice.choose(random, candidates);
candidates.removeIf(option -> option.value().equals(feature));
@@ -46,12 +56,12 @@ public final class PortRandomFeatureSelection {
pages.add(feature);
selected++;
}
if (terrain != PortAgeTerrain.FLAT && terrain != PortAgeTerrain.VOID
if (policy.allows(PortTerrainFeaturePlan.SKYLANDS) && terrain != PortAgeTerrain.FLAT && terrain != PortAgeTerrain.VOID
&& Math.floorMod(mix(seed ^ 0x534B594C414E4453L), 8) == 0)
pages.add(PortTerrainFeaturePlan.SKYLANDS);
if (terrain != PortAgeTerrain.CAVE && terrain != PortAgeTerrain.VOID && random.nextInt(10) == 0)
if (policy.allows(PortTerrainFeaturePlan.CRYSTAL_FORMATIONS) && terrain != PortAgeTerrain.CAVE && terrain != PortAgeTerrain.VOID && random.nextInt(10) == 0)
pages.add(PortTerrainFeaturePlan.CRYSTAL_FORMATIONS);
if (randomStarFissure(seed)) pages.add(PortTerrainFeaturePlan.STAR_FISSURES);
if (policy.allows(PortTerrainFeaturePlan.STAR_FISSURES) && randomStarFissure(seed)) pages.add(PortTerrainFeaturePlan.STAR_FISSURES);
return new Selection(PortAuthoredWriting.rockPlan(pages, seed), PortAuthoredWriting.lakePlan(pages, seed),
PortAuthoredWriting.terrainFeaturePlan(pages, seed), 0);
}
@@ -0,0 +1,66 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import net.dnorberg.rivencraft.PortConfig;
import net.minecraft.resources.Identifier;
/** Creation-time eligibility; the resolved Age settings are saved, never reselected on reload. */
public record PortRandomPagePolicy(Set<Identifier> allowlist, Set<Identifier> blocklist) {
public static final PortRandomPagePolicy UNRESTRICTED = new PortRandomPagePolicy(Set.of(), Set.of());
public PortRandomPagePolicy {
allowlist = Set.copyOf(allowlist);
blocklist = Set.copyOf(blocklist);
}
public static PortRandomPagePolicy configured() {
return new PortRandomPagePolicy(parse(PortConfig.RANDOM_PAGE_ALLOWLIST.get()),
parse(PortConfig.RANDOM_PAGE_BLOCKLIST.get()));
}
private static Set<Identifier> parse(List<? extends String> ids) {
return ids.stream().map(PortRandomPagePolicy::parsePageId).collect(Collectors.toUnmodifiableSet());
}
/** Server config IDs may omit the built-in Rivencraft namespace. */
public static Identifier parsePageId(String value) {
Identifier id = Identifier.tryParse(value.contains(":") ? value : "rivencraft:" + value);
if (id == null) throw new IllegalArgumentException("Invalid Page ID " + value);
return id;
}
public boolean allows(Identifier id) {
if (blocklist.contains(id)) return false;
String category = category(id);
return !restricts(category) || allowlist.contains(id);
}
public boolean restricts(String category) {
return allowlist.stream().anyMatch(entry -> category(entry).equals(category));
}
public <T> List<T> eligible(List<T> candidates, java.util.function.Function<T, Identifier> pageId) {
return candidates.stream().filter(candidate -> allows(pageId.apply(candidate))).toList();
}
public <T> List<T> required(List<T> candidates, java.util.function.Function<T, Identifier> pageId,
T fallback) {
List<T> eligible = eligible(candidates, pageId);
return eligible.isEmpty() ? List.of(fallback) : eligible;
}
private static String category(Identifier id) {
String path = id.getPath();
if (path.startsWith("biome/") && java.util.Set.of("native", "single", "tiled", "grid",
"tiny", "small", "medium", "large", "huge").contains(path.substring("biome/".length())))
return "biome_distribution";
return path.substring(0, path.indexOf('/') < 0 ? path.length() : path.indexOf('/'));
}
public static Identifier page(String category, Enum<?> value) {
return Identifier.fromNamespaceAndPath("rivencraft", category + "/"
+ value.name().toLowerCase(java.util.Locale.ROOT));
}
}
@@ -19,6 +19,11 @@ public final class PortRandomStructureSelection {
}
public static Selection select(MinecraftServer server, long seed, PortAgeTerrain terrain, PortBiomePlan plan) {
return select(server, seed, terrain, plan, PortRandomPagePolicy.UNRESTRICTED);
}
public static Selection select(MinecraftServer server, long seed, PortAgeTerrain terrain, PortBiomePlan plan,
PortRandomPagePolicy policy) {
Set<Identifier> biomes = actualBiomes(server, terrain, plan);
var eligible = new ArrayList<PortRankedChoice.Option<Identifier>>();
for (String name : List.of("dungeons", "mineshafts", "nether_fortresses", "strongholds", "villages",
@@ -32,7 +37,7 @@ public final class PortRandomStructureSelection {
.get(ResourceKey.create(Registries.PLACED_FEATURE,
Identifier.fromNamespaceAndPath("rivencraft", "dungeons"))).isPresent()
: PortStructurePages.randomEligible(feature, terrain, biomes, server.registryAccess());
if (allowed) eligible.add(new PortRankedChoice.Option<>(feature, rank(name)));
if (allowed && policy.allows(feature)) eligible.add(new PortRankedChoice.Option<>(feature, rank(name)));
}
var random = new Random(seed ^ 0x5354525543545552L);
int count = random.nextInt(3), dungeons = 0;
@@ -40,6 +40,9 @@ public final class PortSeasonPolicies extends SavedData {
catch (IllegalArgumentException e) { return null; }
}
static Policy select(List<Identifier> pages, long seed, PortAgeTerrain terrain, boolean installed) {
return select(pages, seed, terrain, installed, PortRandomPagePolicy.UNRESTRICTED);
}
static Policy select(List<Identifier> pages, long seed, PortAgeTerrain terrain, boolean installed, PortRandomPagePolicy policy) {
if (terrain == PortAgeTerrain.CAVE) return new Policy(Mode.NO_SEASONS, true);
Mode written = null;
for (var page : pages) if (forPage(page) != null) written = forPage(page);
@@ -49,9 +52,15 @@ public final class PortSeasonPolicies extends SavedData {
}
if (!installed) return null;
// 1.21.1 LegacyRankChoice ranks: Seasons=1, None=2, each Eternal=3.
int ticket = new Random(seed ^ 0x5E450A5E5EED21L).nextInt(15);
Mode mode = ticket < 6 ? Mode.SEASONS : ticket < 11 ? Mode.NO_SEASONS
: List.of(Mode.ETERNAL_WINTER, Mode.ETERNAL_SUMMER, Mode.ETERNAL_FALL, Mode.ETERNAL_SPRING).get(ticket - 11);
var modes = List.of(new PortRankedChoice.Option<>(Mode.SEASONS, 1),
new PortRankedChoice.Option<>(Mode.NO_SEASONS, 2),
new PortRankedChoice.Option<>(Mode.ETERNAL_WINTER, 3),
new PortRankedChoice.Option<>(Mode.ETERNAL_SUMMER, 3),
new PortRankedChoice.Option<>(Mode.ETERNAL_FALL, 3),
new PortRankedChoice.Option<>(Mode.ETERNAL_SPRING, 3));
Mode mode = PortRankedChoice.choose(new Random(seed ^ 0x5E450A5E5EED21L),
policy.required(modes, option -> PortRandomPagePolicy.page("season", option.value()),
new PortRankedChoice.Option<>(Mode.NO_SEASONS, 2)));
return new Policy(mode, false);
}
}
@@ -26,9 +26,13 @@ public enum PortSkyStyle {
}
public static PortSkyStyle resolve(List<Identifier> pages, long seed) {
return resolve(pages, seed, PortRandomPagePolicy.UNRESTRICTED);
}
public static PortSkyStyle resolve(List<Identifier> pages, long seed, PortRandomPagePolicy policy) {
return pages.stream().map(PortSkyStyle::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElseGet(() -> pages.stream().anyMatch(PortSkyStyle::isCelestial)
? OVERWORLD : random(seed));
? OVERWORLD : random(seed, policy));
}
/** Nether-style Ages use their fog clear color as the visible backdrop. */
@@ -50,11 +54,17 @@ public enum PortSkyStyle {
};
}
private static PortSkyStyle random(long seed) {
private static PortSkyStyle random(long seed, PortRandomPagePolicy policy) {
// Match the first draw in 1.21.1 AgeSkyProfile.random: its equally
// likely INHERITED and OVERWORLD slots currently share the native
// Overworld presentation here. Do not add unported colors/bodies.
int choice = new Random(seed ^ 0x534B5950524F464CL).nextInt(5);
var styles = policy.required(java.util.stream.IntStream.range(0, 5).boxed()
.filter(slot -> slot != 0 || !policy.restricts("sky")).toList(),
slot -> Identifier.fromNamespaceAndPath("rivencraft", "sky/" + switch (slot) {
case 0 -> "inherited"; case 1 -> "overworld"; case 2 -> "nether";
case 3 -> "end"; default -> "static";
}), 0);
int choice = styles.get(new Random(seed ^ 0x534B5950524F464CL).nextInt(styles.size()));
return switch (choice) {
case 0, 1 -> OVERWORLD;
case 2 -> NETHER;
@@ -169,6 +169,16 @@ public final class RuntimeAgeBackend {
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle,
List<Identifier> librarySymbols, String displayName) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures,
weather, lighting, sky, colors, dayCycle, librarySymbols, displayName, PortRandomPagePolicy.UNRESTRICTED);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather,
PortLighting lighting, PortSkyStyle sky, PortAgeColors colors, PortDayCycle dayCycle,
List<Identifier> librarySymbols, String displayName, PortRandomPagePolicy policy) {
checkServerThread(server);
String name = ageName(id);
AgeLevelIndex index = index(server);
@@ -186,7 +196,7 @@ public final class RuntimeAgeBackend {
ServerLevel level = null;
try {
seasons.put(id, PortSeasonPolicies.select(librarySymbols, seed, terrain,
net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.available()));
net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.available(), policy));
writing.put(id, librarySymbols);
level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, lighting, sky, colors, dayCycle);
@@ -396,6 +406,23 @@ public final class RuntimeAgeBackend {
return index(server).dayCycles();
}
/** Snapshot only loaded Age settings; viewing a book never loads its destination. */
public static Map<Identifier, PortAgeDescription> loadedDescriptions(MinecraftServer server) {
checkServerThread(server);
AgeLevelIndex index = index(server);
var descriptions = new java.util.HashMap<Identifier, PortAgeDescription>();
var knownAges = index.entries().keySet();
for (var level : server.getAllLevels()) {
Identifier id = level.dimension().identifier();
if (!(level instanceof PortAgeLevel age) || !knownAges.contains(id)) continue;
PortBiomePlan biomes = index.biomePlan(id);
descriptions.put(id, new PortAgeDescription(index.terrain(id),
biomes == null ? PortBiomePlan.Distribution.NATIVE : biomes.distribution(),
index.lighting(id), age.weatherMode()));
}
return Map.copyOf(descriptions);
}
/** Includes retired IDs, which must never be silently reused by a new book. */
public static boolean isNameTaken(MinecraftServer server, Identifier id) {
checkServerThread(server);
@@ -40,7 +40,8 @@ public final class CrystalPortalBlock extends BaseEntityBlock {
@Override protected RenderShape getRenderShape(BlockState state) {
// Keep the animated fallback until the optional destination view has
// actually been established, including after a world restart.
return RenderShape.MODEL;
return net.dnorberg.rivencraft.compat.RebornBookPreviews.available() && state.getValue(LIVE_VIEW)
? RenderShape.INVISIBLE : RenderShape.MODEL;
}
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new CrystalPortalBlockEntity(pos, state);
@@ -25,7 +25,10 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
public ItemStack getBook() { return book; }
public int skyColor() { return skyColor; }
public void setBook(ItemStack value) {
if (level instanceof ServerLevel server)
net.dnorberg.rivencraft.compat.RebornCrystalPortals.remove(server, worldPosition);
book = value.copyWithCount(1);
restore = true;
skyColor = 0xffffff;
if (level instanceof ServerLevel source) {
var destination = book.get(Rivencraft.LINK_BOOK_TARGET.get());
@@ -53,7 +56,11 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
}
sync();
}
public ItemStack takeBook() { ItemStack result = book; book = ItemStack.EMPTY; skyColor = 0xffffff; sync(); return result; }
public ItemStack takeBook() {
if (level instanceof ServerLevel server)
net.dnorberg.rivencraft.compat.RebornCrystalPortals.remove(server, worldPosition);
ItemStack result = book; book = ItemStack.EMPTY; skyColor = 0xffffff; sync(); return result;
}
private void sync() {
setChanged();
if (level != null && !level.isClientSide()) {
@@ -73,6 +80,7 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
boolean restoring = holder.restore;
if (restoring && !plan.valid() && CrystalPortalNetwork.recoverSavedOwners(level, pos))
plan = CrystalPortalNetwork.plan(level, pos);
if (restoring && plan.valid()) CrystalPortalNetwork.setLiveView(level, plan, false);
CrystalPortalNetwork.reconcile(level, pos, plan);
holder.restore = false;
if (restoring) holder.sync();
@@ -20,12 +20,15 @@ public final class AgeCatalogClient {
@SubscribeEvent
public static void logout(net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent.LoggingOut event) {
PortBookPreview.logout();
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(java.util.Map.of());
net.dnorberg.rivencraft.age.PortDayCycle.updateClient(java.util.Map.of());
net.dnorberg.rivencraft.age.PortAgeDescription.updateClient(java.util.Map.of());
}
@SubscribeEvent
public static void register(RegisterClientPayloadHandlersEvent event) {
event.register(net.dnorberg.rivencraft.network.BookPreviewPayload.TYPE, (payload, context) -> PortBookPreview.accept(payload));
event.register(net.dnorberg.rivencraft.network.WritingDeskCatalogPayload.TYPE, ClientWritingDeskCatalog::handle);
event.register(net.dnorberg.rivencraft.network.SereneAgeWhitelistPayload.TYPE, (payload, context) ->
net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility.acceptClient(payload.dimensionId(), payload.enabled()));
@@ -33,6 +36,7 @@ public final class AgeCatalogClient {
if (!(context.listener() instanceof ClientPacketListener listener)) return;
net.dnorberg.rivencraft.age.PortAgeColors.updateClient(payload.colors());
net.dnorberg.rivencraft.age.PortDayCycle.updateClient(payload.dayCycles());
net.dnorberg.rivencraft.age.PortAgeDescription.updateClient(payload.descriptions());
if (listener.getLevel() != null) {
listener.getLevel().environmentAttributes().invalidateTickCache();
listener.getLevel().updateSkyBrightness();
@@ -0,0 +1,120 @@
package net.dnorberg.rivencraft.client;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.minecraft.world.phys.Vec3;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.client.event.ClientTickEvent;
import net.neoforged.neoforge.client.event.InputEvent;
import net.neoforged.neoforge.client.event.RenderGuiEvent;
import com.mojang.blaze3d.platform.InputConstants;
/** Matches 1.21.1: grabbed mouse look with the book drawn over the source world. */
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
public final class PortBookExploration {
private static PortBookExploration current;
private final PortLegacyLinkPanel panel;
private Screen owner;
private Button eye;
private Vec3 sourcePosition;
private net.minecraft.client.multiplayer.ClientLevel sourceLevel;
private int guiWidth, guiHeight;
private boolean active;
PortBookExploration(PortLegacyLinkPanel panel) { this.panel = panel; }
void attach(Screen screen, java.util.function.Consumer<Button> add, int left, int top, boolean stand) {
owner = screen;
eye = Button.builder(Component.literal("👁"), button -> {
var minecraft = Minecraft.getInstance();
if (stand || !panel.canExplore() || minecraft.player == null) return;
sourcePosition = minecraft.player.position();
sourceLevel = minecraft.level;
guiWidth = minecraft.getWindow().getGuiScaledWidth();
guiHeight = minecraft.getWindow().getGuiScaledHeight();
active = true;
current = this;
eye.visible = false;
panel.exploring(true);
KeyMapping.releaseAll();
// Removing the screen grabs the mouse normally. removed() preserves this session.
minecraft.gui.setScreen(null);
}).bounds(left + 229, top + 145, 20, 20).build();
eye.active = !stand;
eye.setTooltip(Tooltip.create(Component.literal("Look around destination (right-click to return)")));
add.accept(eye);
}
void tick(boolean frontPage) {
if (eye != null) eye.visible = !active && frontPage && panel.canExplore();
}
boolean active() { return active; }
void stop() {
active = false;
if (current == this) current = null;
sourcePosition = null;
sourceLevel = null;
panel.exploring(false);
}
@SubscribeEvent
public static void beforeTick(ClientTickEvent.Pre event) {
if (current != null) KeyMapping.releaseAll();
}
@SubscribeEvent
public static void afterTick(ClientTickEvent.Post event) {
var overlay = current;
if (overlay == null) return;
var minecraft = Minecraft.getInstance();
if (minecraft.player == null || minecraft.level != overlay.sourceLevel || minecraft.gui.screen() != null) {
overlay.stop();
overlay.panel.close();
return;
}
minecraft.player.setPos(overlay.sourcePosition);
minecraft.player.setDeltaMovement(Vec3.ZERO);
overlay.panel.tick();
}
@SubscribeEvent
public static void drawBook(RenderGuiEvent.Post event) {
var overlay = current;
var minecraft = Minecraft.getInstance();
if (overlay == null || minecraft.gui.screen() != null) return;
int width = minecraft.getWindow().getGuiScaledWidth();
int height = minecraft.getWindow().getGuiScaledHeight();
if (width != overlay.guiWidth || height != overlay.guiHeight) {
overlay.guiWidth = width;
overlay.guiHeight = height;
overlay.owner.resize(width, height);
overlay.eye.visible = false;
}
overlay.owner.extractRenderState(event.getGuiGraphics(), -1, -1,
event.getPartialTick().getGameTimeDeltaPartialTick(false));
}
@SubscribeEvent
public static void mouseButton(InputEvent.MouseButton.Pre event) {
var overlay = current;
if (overlay == null || Minecraft.getInstance().gui.screen() != null) return;
// Neither mouse button may use the held book or attack in the source world.
event.setCanceled(true);
if (event.getButton() == InputConstants.MOUSE_BUTTON_RIGHT && event.getAction() == InputConstants.PRESS) {
overlay.stop();
KeyMapping.releaseAll();
// Unlike 1.21.1 setScreen, setScreenAndShow forces a render immediately.
// Resume through the regular GUI lifecycle without nesting a new frame.
Minecraft.getInstance().gui.setScreen(overlay.owner);
}
}
}
@@ -0,0 +1,85 @@
package net.dnorberg.rivencraft.client;
import java.util.UUID;
import net.dnorberg.rivencraft.PortConfig;
import net.dnorberg.rivencraft.compat.RebornBookPreviews;
import net.dnorberg.rivencraft.network.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.core.BlockPos;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
/** One session per open book; stale replies cannot replace another book's preview. */
public final class PortBookPreview {
interface LivePanel {
void accept(net.dnorberg.rivencraft.item.LinkBookTarget destination);
boolean render(GuiGraphicsExtractor graphics, int x, int y, int width, int height, float time);
void close();
default void exploring(boolean enabled) {}
default void tick() {}
}
private static PortBookPreview active;
private final UUID session = UUID.randomUUID();
private final RequestBookPreviewPayload request;
private final LivePanel live;
private int ticks;
private boolean received;
private boolean closed;
private net.minecraft.resources.Identifier destination;
PortBookPreview(InteractionHand hand, ItemStack book, UseBookStandPayload stand) {
var minecraft = Minecraft.getInstance();
request = minecraft.level == null ? null : new RequestBookPreviewPayload(session,
hand == InteractionHand.OFF_HAND, stand != null, minecraft.level.dimension().identifier(),
stand == null ? BlockPos.ZERO : stand.pos(), book.copy());
boolean enabled = RebornBookPreviews.available() && PortConfig.LIVE_BOOK_PREVIEWS.get();
live = enabled ? new RebornLiveBookPanel() : null;
if (active != null) active.close();
active = this;
sendRequest();
}
private void sendRequest() {
if (!closed && live != null && request != null && Minecraft.getInstance().getConnection() != null)
ClientPacketDistributor.sendToServer(request);
}
void tick() {
ticks++;
if (live != null) live.tick();
if (ticks % (received ? 200 : 40) == 0) sendRequest();
}
boolean render(GuiGraphicsExtractor graphics, int x, int y, int width, int height, float partialTick) {
if (closed || !received || live == null) return false;
return live.render(graphics, x, y, width, height, ticks + partialTick);
}
boolean canExplore() { return live != null && received && !closed; }
void exploring(boolean enabled) { if (live != null) live.exploring(enabled); }
public static void accept(BookPreviewPayload payload) {
if (active == null || active.closed || active.live == null || !active.session.equals(payload.session())
|| !payload.destination().valid()) return;
active.destination = payload.destination().dimension();
active.received = true;
active.live.accept(payload.destination());
}
void close() {
if (closed) return;
closed = true;
if (live != null) {
live.close();
if (Minecraft.getInstance().getConnection() != null)
ClientPacketDistributor.sendToServer(new ReleaseBookPreviewPayload(session));
}
if (active == this) active = null;
}
public static void logout() { if (active != null) active.close(); }
public static boolean shouldMute(ClientLevel level) {
var player = Minecraft.getInstance().player;
return active != null && !active.closed && active.destination != null
&& level.dimension().identifier().equals(active.destination)
&& (player == null || level != player.level());
}
}
@@ -15,7 +15,7 @@ import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
/** Legacy book layout and authored glyph pages; destination rendering remains a port placeholder. */
/** Legacy book spreads, authored symbol pages and random-Age glyph descriptions. */
public final class PortDescriptiveBookScreen extends Screen {
private static final int WIDTH = 327;
private static final int HEIGHT = 199;
@@ -23,8 +23,11 @@ public final class PortDescriptiveBookScreen extends Screen {
private final InteractionHand hand;
private final ItemStack book;
private final boolean bound;
private final net.minecraft.resources.Identifier ageId;
private final PortLegacyLinkPanel linkPanel = new PortLegacyLinkPanel();
private final PortBookExploration exploration = new PortBookExploration(linkPanel);
private final net.dnorberg.rivencraft.writing.DescriptiveBookPages pages;
private int pageIndex;
private net.dnorberg.rivencraft.network.UseBookStandPayload standSource;
@@ -33,8 +36,10 @@ public final class PortDescriptiveBookScreen extends Screen {
public PortDescriptiveBookScreen(InteractionHand hand, ItemStack book) {
super(book.getHoverName());
this.hand = hand;
this.book = book.copy();
pages = net.dnorberg.rivencraft.writing.DescriptiveBookPages.from(book);
bound = book.has(Rivencraft.AGE_BOOK_ID.get());
ageId = book.get(Rivencraft.AGE_BOOK_ID.get());
bound = ageId != null;
}
public PortDescriptiveBookScreen(net.dnorberg.rivencraft.network.UseBookStandPayload source) {
@@ -44,7 +49,12 @@ public final class PortDescriptiveBookScreen extends Screen {
@Override
protected void init() {
if (bound || standSource != null) return;
if (bound) {
linkPanel.open(hand, book, standSource);
exploration.attach(this, this::addRenderableWidget, (width - WIDTH) / 2, (height - HEIGHT) / 2, standSource != null);
return;
}
if (standSource != null) return;
int left = (width - WIDTH) / 2;
int top = (height - HEIGHT) / 2;
titleInput = new EditBox(font, left + 40, top + 69, 110, 20,
@@ -78,7 +88,9 @@ public final class PortDescriptiveBookScreen extends Screen {
int textY = PortLegacyBookArtwork.drawText(graphics, font, title, left + 40, top + 40, 110, TEXT_COLOR);
Component author = pages.author().isBlank()
? Component.translatable("screen.rivencraft.age.unknown_author") : Component.literal(pages.author());
PortLegacyBookArtwork.drawText(graphics, font, author, left + 50, textY + 2, 100, TEXT_COLOR);
int detailsY = PortLegacyBookArtwork.drawText(graphics, font, author,
left + 50, textY + 2, 100, TEXT_COLOR) + 4;
if (pages.symbols().isEmpty()) drawRandomDescription(graphics, left + 40, detailsY);
graphics.disableScissor();
} else {
graphics.text(font, title, left + 42, top + 29, TEXT_COLOR, false);
@@ -105,6 +117,32 @@ public final class PortDescriptiveBookScreen extends Screen {
super.extractRenderState(graphics, mouseX, mouseY, partialTick);
}
private void drawRandomDescription(GuiGraphicsExtractor graphics, int x, int y) {
var description = net.dnorberg.rivencraft.age.PortAgeDescription.forClient(ageId);
if (description == null) return;
var cycle = net.dnorberg.rivencraft.age.PortDayCycle.forClient(ageId);
var lines = java.util.List.of(
Component.translatable("screen.rivencraft.age.generator",
Component.translatable("generator.rivencraft." + description.terrain().name().toLowerCase(java.util.Locale.ROOT))),
Component.translatable("screen.rivencraft.age.trait.biomes", trait("biome_distribution", description.distribution())),
Component.translatable("screen.rivencraft.age.trait.lighting", trait("lighting", description.lighting())),
Component.translatable("screen.rivencraft.age.trait.weather", trait("weather", description.weather())),
Component.translatable("screen.rivencraft.age.trait.day_length", cycle.period() == 0
? Component.translatable("trait.rivencraft.day_length.stopped")
: Component.literal(String.format(java.util.Locale.ROOT, "%.2fx", cycle.period()))),
Component.translatable("screen.rivencraft.age.trait.celestial_angle",
Component.literal(String.format(java.util.Locale.ROOT, "%.0f°", (float) cycle.bearing()))),
Component.translatable("screen.rivencraft.age.trait.celestial_phase",
Component.literal(String.format(java.util.Locale.ROOT, "%.0f%%", cycle.phaseTick() * 100F / 24000F))));
for (var line : lines)
y = PortSymbolPageTextures.drawGlyphText(graphics, line.getString(), x, y, 110) + 2;
}
private static Component trait(String group, Enum<?> value) {
return Component.translatable("trait.rivencraft." + group + "."
+ value.name().toLowerCase(java.util.Locale.ROOT));
}
@Override
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
if (event.button() == InputConstants.MOUSE_BUTTON_LEFT) {
@@ -150,8 +188,11 @@ public final class PortDescriptiveBookScreen extends Screen {
@Override public void tick() {
if (bound) linkPanel.tick();
exploration.tick(pageIndex == 0);
}
@Override public void removed() { if (!exploration.active()) { exploration.stop(); linkPanel.close(); } super.removed(); }
@Override
public boolean isPauseScreen() {
return false;
@@ -14,6 +14,15 @@ final class PortLegacyLinkPanel {
private static final int CELL_SIZE = 3;
private static final int BORDER_COLOR = 0xFF7B6AA8;
private int ticks;
private PortBookPreview preview;
void open(net.minecraft.world.InteractionHand hand, net.minecraft.world.item.ItemStack book,
net.dnorberg.rivencraft.network.UseBookStandPayload stand) {
if (preview == null) preview = new PortBookPreview(hand, book, stand);
}
void close() { if (preview != null) { preview.close(); preview = null; } }
boolean canExplore() { return preview != null && preview.canExplore(); }
void exploring(boolean value) { if (preview != null) preview.exploring(value); }
private static final int LINK_COLOR = 0xFF006666;
static boolean contains(double x, double y) {
@@ -26,9 +35,11 @@ final class PortLegacyLinkPanel {
void tick() {
ticks++;
if (preview != null) preview.tick();
}
private void render(GuiGraphicsExtractor graphics, int left, int top, int width, int height, float partialTick) {
if (preview != null && preview.render(graphics, left, top, width, height, partialTick)) return;
graphics.fill(left, top, left + width, top + height, 0xFF000011);
float time = (ticks + partialTick) * 0.035F;
@@ -23,18 +23,21 @@ public final class PortLinkingBookScreen extends Screen {
private final InteractionHand hand;
private final ItemStack book;
private final boolean bound;
private final net.dnorberg.rivencraft.item.LinkBookTarget target;
private final boolean returnLink;
private final Component destination;
private int pageIndex;
private final PortLegacyLinkPanel linkPanel = new PortLegacyLinkPanel();
private final PortBookExploration exploration = new PortBookExploration(linkPanel);
private net.dnorberg.rivencraft.network.UseBookStandPayload standSource;
private EditBox titleInput;
public PortLinkingBookScreen(InteractionHand hand, ItemStack book) {
super(book.getHoverName());
this.hand = hand;
this.book = book.copy();
target = book.get(Rivencraft.LINK_BOOK_TARGET.get());
bound = target != null;
destination = bound ? net.dnorberg.rivencraft.item.LinkingBookItem.destinationName(book, target) : Component.empty();
@@ -51,7 +54,12 @@ public final class PortLinkingBookScreen extends Screen {
@Override
protected void init() {
if (bound || standSource != null) return;
if (bound) {
linkPanel.open(hand, book, standSource);
exploration.attach(this, this::addRenderableWidget, (width - WIDTH) / 2, (height - HEIGHT) / 2, standSource != null);
return;
}
if (standSource != null) return;
int left = (width - WIDTH) / 2;
int top = (height - HEIGHT) / 2;
titleInput = new EditBox(font, left + 40, top + 69, 110, 20,
@@ -145,8 +153,11 @@ public final class PortLinkingBookScreen extends Screen {
@Override public void tick() {
if (bound) linkPanel.tick();
exploration.tick(pageIndex == 0);
}
@Override public void removed() { if (!exploration.active()) { exploration.stop(); linkPanel.close(); } super.removed(); }
@Override
public boolean isPauseScreen() {
return false;
@@ -183,6 +183,36 @@ public final class PortSymbolPageTextures {
return result;
}
/** Translated prose becomes Legacy word glyphs, with genuine base-25 numerals. */
public static int drawGlyphText(net.minecraft.client.gui.GuiGraphicsExtractor graphics,
String text, int x, int y, int maxWidth) {
var layout = net.dnorberg.rivencraft.writing.PortGlyphTextLayout.layout(text, maxWidth, 12);
for (var glyph : layout.glyphs()) {
int number = net.dnorberg.rivencraft.writing.PortGlyphTextLayout.number(glyph.word());
if (number >= 0) drawNumeral(graphics, number, x + glyph.x(), y + glyph.y(), 12);
else for (int component : glyphs(glyph.word()))
drawComponent(graphics, component, x + glyph.x(), y + glyph.y(), 12);
}
return y + layout.height();
}
public static void drawNumeral(net.minecraft.client.gui.GuiGraphicsExtractor graphics,
int number, int x, int y, int size) {
int[] digits = PortSymbolNumerals.digitsFor(number);
int digitSize = digits.length == 1 ? size : Math.max(8, size * 3 / 4);
for (int digit : digits) {
for (int component : PortSymbolNumerals.componentsForDigit(digit))
drawComponent(graphics, component, x, y + (size - digitSize) / 2, digitSize);
x += digitSize + 1;
}
}
private static void drawComponent(net.minecraft.client.gui.GuiGraphicsExtractor graphics,
int component, int x, int y, int size) {
graphics.blit(net.minecraft.client.renderer.RenderPipelines.GUI_TEXTURED, COMPONENTS, x, y,
(component % 8) * 64, (component / 8) * 64, size, size, 64, 64, 512, 512, 0xFF211A14);
}
private static Identifier texture(String path) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "textures/" + path + ".png");
}
@@ -0,0 +1,109 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.pipeline.RenderTarget;
import com.mojang.blaze3d.pipeline.TextureTarget;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.renderpearl.api.GpuFormat;
import com.mojang.renderpearl.api.textures.FilterMode;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.phys.Vec3;
import org.joml.Matrix4f;
import org.joml.Vector4f;
import qouteall.imm_ptl.core.ClientWorldLoader;
import qouteall.imm_ptl.core.render.GuiPortalRendering;
import qouteall.imm_ptl.core.render.context_management.WorldRenderInfo;
/** Reborn renders before GUI drawing; the deferred GUI samples its target normally. */
public final class RebornLiveBookPanel implements PortBookPreview.LivePanel {
// The engine retains queued tasks. Keep one reusable target alive across screen
// removal instead of deleting textures still referenced by a queued GUI frame.
private static RenderTarget target;
private static RebornLiveBookPanel queued;
private LinkBookTarget destination;
private boolean closed;
private boolean exploring;
private Vec3 explorePosition;
private float yaw, pitch, playerYaw, playerPitch;
@Override public void exploring(boolean enabled) {
exploring = enabled;
if (enabled && destination != null) {
explorePosition = new Vec3(destination.x(), destination.y() + 1.62, destination.z());
yaw = destination.yaw(); pitch = destination.pitch();
var player = Minecraft.getInstance().player;
if (player != null) {
explorePosition = new Vec3(destination.x(), destination.y() + player.getEyeHeight(), destination.z());
playerYaw = player.getYRot(); playerPitch = player.getXRot();
}
}
}
@Override public void tick() {
var player = Minecraft.getInstance().player;
if (!exploring || destination == null || player == null) return;
yaw = destination.yaw() + net.minecraft.util.Mth.wrapDegrees(player.getYRot() - playerYaw);
pitch = Math.clamp(destination.pitch() + player.getXRot() - playerPitch, -89, 89);
}
@Override public void accept(LinkBookTarget value) { destination = value; }
@Override public boolean render(GuiGraphicsExtractor graphics, int x, int y, int width, int height, float time) {
if (closed || destination == null || !ClientWorldLoader.getIsInitialized()) return false;
var key = ResourceKey.create(Registries.DIMENSION, destination.dimension());
var world = ClientWorldLoader.getWorld(key);
if (world == null || !world.hasChunkAt(BlockPos.containing(destination.x(), destination.y(), destination.z()))) return false;
var minecraft = Minecraft.getInstance();
var main = minecraft.gameRenderer.mainRenderTarget();
if (target == null) target = new TextureTarget("Rivencraft live book", main.width, main.height,
GpuFormat.RGBA8_UNORM, main.getDepthTexture().getFormat());
Vec3 center = new Vec3(destination.x(), destination.y() + 2.5, destination.z());
double angle = time * 0.012;
Vec3 camera = center.add(Math.sin(angle) * 11, 5, Math.cos(angle) * 11);
Matrix4f transform;
if (exploring && explorePosition != null) {
camera = explorePosition;
transform = new Matrix4f().rotateX((float) Math.toRadians(pitch))
.rotateY((float) Math.toRadians(yaw + 180));
} else transform = new Matrix4f().rotateX((float) Math.toRadians(20)).rotateY((float) angle);
var info = new WorldRenderInfo.Builder().setWorld(world).setCameraPos(camera)
.setCameraTransformation(transform).setOverwriteCameraTransformation(true)
.setRenderDistance(Math.min(8, minecraft.options.getEffectiveRenderDistance()))
.setDoRenderHand(false).setEnableViewBobbing(false).setDoRenderSky(true).setHasFog(true).build();
// Overlay-to-screen transitions can extract this panel twice before Reborn
// drains its queue. Its API accepts only one task per target until then.
// Reuse that pending view for subsequent draws instead of submitting twice.
if (queued == null) {
// Submit first: Reborn may resize the target and invalidate its texture view.
// GPU clearing happens in beginRender(), before remote rendering.
GuiPortalRendering.submitNextFrameRendering(info, target);
queued = this;
}
graphics.blit(target.getColorTextureView(), RenderSystem.getSamplerCache().getClampToEdge(FilterMode.LINEAR),
x, y, x + width, y + height, 0, 1, 1, 0);
graphics.outline(x, y, width, height, 0xFF7B6AA8);
return true;
}
/** Called by the optional engine mixin on the render thread, never during GUI drawing. */
public static void beginRender() {
if (queued == null || target == null) return;
RenderSystem.getDevice().createCommandEncoder().clearColorAndDepthTextures(
target.getColorTexture(), new Vector4f(0, 0, 0, 1), target.getDepthTexture(), 0.0);
}
/** Remote render changes the shared camera; restore it before rain/audio use it. */
public static void endRender() {
if (queued == null) return;
queued = null;
var minecraft = Minecraft.getInstance();
if (minecraft.level != null && minecraft.getCameraEntity() != null) {
var camera = minecraft.gameRenderer.mainCamera();
camera.setLevel(minecraft.level);
camera.setEntity(minecraft.getCameraEntity());
camera.update(minecraft.getDeltaTracker());
}
}
@Override public void close() { exploring(false); closed = true; destination = null; }
}
@@ -294,15 +294,7 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
}
private static void drawNumeral(GuiGraphicsExtractor graphics, int number, int x, int y, int size) {
int[] digits = net.dnorberg.rivencraft.writing.PortSymbolNumerals.digitsFor(number);
int digitSize = digits.length == 1 ? size : Math.max(8, size * 3 / 4);
Identifier components = Identifier.fromNamespaceAndPath("rivencraft", "textures/gui/symbolcomponents.png");
for (int digit : digits) {
for (int component : net.dnorberg.rivencraft.writing.PortSymbolNumerals.componentsForDigit(digit))
graphics.blit(RenderPipelines.GUI_TEXTURED, components, x, y + (size - digitSize) / 2,
(component % 8) * 64, (component / 8) * 64, digitSize, digitSize, 64, 64, 512, 512, 0xFF211A14);
x += digitSize + 1;
}
PortSymbolPageTextures.drawNumeral(graphics, number, x, y, size);
}
private DisplayPage pageAtMouse(double mouseX, double mouseY) {
@@ -0,0 +1,38 @@
package net.dnorberg.rivencraft.compat;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.server.MinecraftServer;
import net.neoforged.fml.ModList;
import net.neoforged.neoforge.event.server.ServerStartedEvent;
/** Optional 26.3 Immersive Portals forks; only the guarded adapter links their API classes. */
public final class ImmersivePortalsCompatibility {
private ImmersivePortalsCompatibility() {}
public static boolean available() { return ModList.get().isLoaded("seamlessportals") || ModList.get().isLoaded("immersive_portals_reborn"); }
public static boolean synchronize(MinecraftServer server) { return synchronize(server, null); }
/** Login/catalog packets must precede Rivencraft's acknowledgement round trip. */
public static boolean beforeCatalog(net.minecraft.server.level.ServerPlayer player) {
return synchronize(player.level().getServer(), player);
}
private static boolean synchronize(MinecraftServer server, net.minecraft.server.level.ServerPlayer player) {
if (!available()) return true;
if (!server.isSameThread()) throw new IllegalStateException("Portal catalog update outside server thread");
try {
ImmersivePortalsDimensionAdapter.synchronize(server);
if (player != null) ImmersivePortalsDimensionAdapter.sendTo(player);
return true;
} catch (RuntimeException | LinkageError failure) {
Rivencraft.LOGGER.error("Could not synchronize the optional Immersive Portals dimension IDs; "
+ "Rivencraft catalog acknowledgement/travel remains blocked until synchronization succeeds", failure);
return false;
}
}
public static void onServerStarted(ServerStartedEvent event) {
synchronize(event.getServer());
}
}
@@ -0,0 +1,32 @@
package net.dnorberg.rivencraft.compat;
import java.util.Set;
import net.minecraft.server.MinecraftServer;
import qouteall.q_misc_util.dimension.DimensionIntId;
/** Updates the shared 26.3 dimension catalog without delegating Age construction. */
final class ImmersivePortalsDimensionAdapter {
private ImmersivePortalsDimensionAdapter() {}
static void sendTo(net.minecraft.server.level.ServerPlayer player) {
// Seamless's createPacket returns a vanilla Packet; Reborn's returns
// a CustomPacketPayload. Their createFromServer factory is shared and
// retains each fork's native (two- or three-tag) wire format.
player.connection.send(new net.minecraft.network.protocol.common.ClientboundCustomPayloadPacket(
qouteall.q_misc_util.MiscNetworking.DimIdSyncPacket.createFromServer(
player.level().getServer())));
}
static void synchronize(MinecraftServer server) {
var live = Set.copyOf(server.levelKeys());
var record = DimensionIntId.getServerMap(server);
if (record.getDimIdSet().equals(live)) return;
// This is the same public synchronization entry point called by its
// dynamic-update listener. The two supported forks have
// different event wrappers, but share this method. It updates IDs and
// sends native type/sea-level metadata before Rivencraft's catalog.
DimensionIntId.onServerDimensionChanged(server);
if (!DimensionIntId.getServerMap(server).getDimIdSet().equals(live))
throw new IllegalStateException("The portal mod did not accept the current server dimensions");
}
}
@@ -0,0 +1,35 @@
package net.dnorberg.rivencraft.compat;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.network.RequestBookPreviewPayload;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.fml.ModList;
import net.neoforged.neoforge.common.NeoForge;
/** No optional API appears in common class signatures. */
public final class RebornBookPreviews {
public static boolean available() { return ModList.get().isLoaded("immersive_portals_reborn"); }
public static void open(ServerPlayer player, RequestBookPreviewPayload payload) {
if (!available()) return;
try { RebornPreviewChunks.open(player, payload); }
catch (RuntimeException | LinkageError error) {
Rivencraft.LOGGER.warn("Reborn book preview unavailable; retaining animated panel", error);
RebornPreviewChunks.close(player);
}
}
public static void release(ServerPlayer player, java.util.UUID session) {
if (available()) RebornPreviewChunks.release(player, session);
}
public static void register() {
if (!available()) return;
NeoForge.EVENT_BUS.addListener((net.neoforged.neoforge.event.tick.ServerTickEvent.Post event) -> RebornPreviewChunks.tick(event.getServer()));
NeoForge.EVENT_BUS.addListener((net.neoforged.neoforge.event.entity.player.PlayerEvent.PlayerLoggedOutEvent event) -> {
if (event.getEntity() instanceof ServerPlayer player) RebornPreviewChunks.logout(player);
});
NeoForge.EVENT_BUS.addListener((net.neoforged.neoforge.event.entity.player.PlayerEvent.PlayerChangedDimensionEvent event) -> {
if (event.getEntity() instanceof ServerPlayer player) RebornPreviewChunks.close(player);
});
NeoForge.EVENT_BUS.addListener((net.neoforged.neoforge.event.server.ServerStoppingEvent event) -> RebornPreviewChunks.stop());
}
private RebornBookPreviews() {}
}
@@ -0,0 +1,30 @@
package net.dnorberg.rivencraft.compat;
import net.dnorberg.rivencraft.PortConfig;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.item.ItemStack;
/** Render-only optional integration. Crystal portal blocks retain all travel rules. */
public final class RebornCrystalPortals {
public static void refresh(ServerLevel source, BlockPos owner, CrystalPortalNetwork.Plan plan, ItemStack book) {
boolean live = false;
if (RebornBookPreviews.available()) {
try {
if (!book.isEmpty() && PortConfig.CRYSTAL_LIVE_VIEWS.get())
live = RebornCrystalView.refresh(source, owner, plan, book);
else RebornCrystalView.remove(source, owner);
} catch (RuntimeException | LinkageError failure) {
Rivencraft.LOGGER.warn("Could not establish Reborn crystal view at {}; retaining portal travel and fallback", owner, failure);
RebornCrystalView.remove(source, owner);
}
}
CrystalPortalNetwork.setLiveView(source, plan, live);
}
public static void remove(ServerLevel source, BlockPos owner) {
if (RebornBookPreviews.available()) RebornCrystalView.remove(source, owner);
}
private RebornCrystalPortals() {}
}
@@ -0,0 +1,131 @@
package net.dnorberg.rivencraft.compat;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.dnorberg.rivencraft.item.LinkProperties;
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
import net.dnorberg.rivencraft.portal.CrystalPortalSurfaces;
import net.dnorberg.rivencraft.portal.PortalBookTravel;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.EntitySpawnReason;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import qouteall.imm_ptl.core.api.PortalAPI;
import qouteall.imm_ptl.core.portal.Portal;
import qouteall.imm_ptl.core.portal.shape.SpecialFlatPortalShape;
import qouteall.q_misc_util.my_util.DQuaternion;
import qouteall.q_misc_util.my_util.Mesh2D;
/** One native mesh per coplanar opening/side, not one repeated image per block. */
final class RebornCrystalView {
private static final String PREFIX = "rivencraft:crystal:mc263:v1:";
private record View(CrystalPortalSurfaces.Surface surface, Direction normal,
LinkBookTarget target, boolean preserveFacing, boolean relative, String tag) {}
static boolean refresh(ServerLevel source, BlockPos owner, CrystalPortalNetwork.Plan plan, ItemStack book) {
List<View> wanted = views(source, owner, plan, book);
if (wanted.isEmpty()) { remove(source, owner); return false; }
if (!complete(source, owner, wanted)) {
remove(source, owner);
for (View view : wanted) spawn(source, view);
}
return complete(source, owner, wanted);
}
private static List<View> views(ServerLevel source, BlockPos owner, CrystalPortalNetwork.Plan plan, ItemStack book) {
List<View> views = new ArrayList<>();
boolean descriptive = book.is(Rivencraft.DESCRIPTIVE_BOOK.get());
boolean relative = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE).has(LinkProperties.RELATIVE_LINK);
// Ordinary descriptive arrival searches once, not for each side of each opening.
LinkBookTarget shared = relative ? null : PortalBookTravel.destination(source, book, owner, 0, 0);
if (!relative && (shared == null || !shared.valid())) return List.of();
for (var surface : CrystalPortalSurfaces.group(source, plan)) {
LinkBookTarget target = relative
? PortalBookTravel.destination(source, book, BlockPos.containing(surface.bounds().getCenter()), 0, 0) : shared;
if (target == null || !target.valid()) return List.of();
int shape = surface.cells().stream().mapToLong(BlockPos::asLong).sorted().boxed().toList().hashCode();
for (Direction normal : List.of(surface.normal(), surface.normal().getOpposite())) {
String tag = ownerTag(owner) + normal.getSerializedName() + ":" + shape + ":" + target + ":" + descriptive + ":" + relative;
views.add(new View(surface, normal, target, descriptive, relative, tag));
}
}
return List.copyOf(views);
}
static void remove(ServerLevel source, BlockPos owner) {
for (Portal portal : existing(source, owner)) portal.discard();
}
private static List<Portal> existing(ServerLevel source, BlockPos owner) {
String prefix = ownerTag(owner);
return source.getEntitiesOfClass(Portal.class,
new AABB(owner).inflate(CrystalPortalNetwork.MAX_RADIUS + 2),
entity -> entity.portalTag != null && entity.portalTag.startsWith(prefix));
}
private static boolean complete(ServerLevel source, BlockPos owner, List<View> wanted) {
List<Portal> existing = existing(source, owner);
if (existing.size() != wanted.size()) return false;
var tags = new HashSet<String>();
for (Portal view : existing) {
if (view.getTeleportable() || view.isInteractable() || !view.isPortalValid()
|| !tags.add(view.portalTag)) return false;
}
return tags.equals(wanted.stream().map(View::tag).collect(java.util.stream.Collectors.toSet()));
}
private static void spawn(ServerLevel source, View view) {
Portal portal = Portal.ENTITY_TYPE.create(source, EntitySpawnReason.TRIGGERED);
if (portal == null) return;
var surface = view.surface();
var normal = view.normal();
var target = view.target();
PortalAPI.setPortalOrthodoxShape(portal, normal, surface.bounds());
// Inside the opening's block thickness, so crystal frame edges stay in front.
portal.setOriginPos(portal.getOriginPos().add(normal.getUnitVec3().scale(-0.125)));
Mesh2D mesh = new Mesh2D();
double halfU = 1 / portal.getWidth(), halfV = 1 / portal.getHeight();
for (BlockPos cell : surface.cells()) {
Vec3 face = Vec3.atCenterOf(cell).add(normal.getUnitVec3().scale(0.5));
Vec3 offset = face.subtract(portal.getOriginPos());
double u = 2 * offset.dot(portal.getAxisW()) / portal.getWidth();
double v = 2 * offset.dot(portal.getAxisH()) / portal.getHeight();
mesh.addQuad(u - halfU, v - halfV, u + halfU, v + halfV);
}
portal.setPortalShape(new SpecialFlatPortalShape(mesh));
// Descriptive Book travel retains the approaching entity's yaw/pitch.
// Linking Books rotate the approach normal onto their written facing.
DQuaternion rotation = view.preserveFacing() ? DQuaternion.identity : cameraRotation(normal, target);
Vec3 eye = new Vec3(target.x(), target.y() + 1.62, target.z());
// Anchor a standing player's eyes above the opening's floor rather than
// above the mesh center (which changes when the frame is made taller).
Vec3 anchor = surface.bounds().getCenter();
if (normal.getAxis().isHorizontal()) anchor = new Vec3(anchor.x, surface.bounds().minY + 1.62, anchor.z);
else anchor = anchor.add(0, 1.62, 0);
Vec3 destination = eye.subtract(rotation.rotate(anchor.subtract(portal.getOriginPos())));
PortalAPI.setPortalTransformation(portal,
net.minecraft.resources.ResourceKey.create(net.minecraft.core.registries.Registries.DIMENSION, target.dimension()),
destination, rotation, 1);
portal.setTeleportable(false);
portal.setInteractable(false);
portal.setCrossPortalCollisionEnabled(false);
portal.portalTag = view.tag();
PortalAPI.spawnServerEntity(portal);
}
private static DQuaternion cameraRotation(Direction normal, LinkBookTarget target) {
Vec3 sourceLook = normal.getOpposite().getUnitVec3();
Vec3 sourceUp = normal.getAxis() == Direction.Axis.Y ? new Vec3(0, 0, -1) : new Vec3(0, 1, 0);
Vec3 destinationLook = Vec3.directionFromRotation(target.pitch(), target.yaw());
Vec3 destinationUp = Vec3.directionFromRotation(target.pitch() - 90, target.yaw());
DQuaternion from = DQuaternion.fromFacingVecs(sourceLook.cross(sourceUp), sourceUp);
DQuaternion to = DQuaternion.fromFacingVecs(destinationLook.cross(destinationUp), destinationUp);
return to.hamiltonProduct(from.getConjugated()).getNormalized();
}
private static String ownerTag(BlockPos owner) { return PREFIX + owner.asLong() + ":"; }
private RebornCrystalView() {}
}
@@ -0,0 +1,112 @@
package net.dnorberg.rivencraft.compat;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.UUID;
import net.dnorberg.rivencraft.network.RequestBookPreviewPayload;
import net.dnorberg.rivencraft.network.BookPreviewPayload;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.AgeCatalogSync;
import net.dnorberg.rivencraft.age.PortAgeTravel;
import net.dnorberg.rivencraft.item.*;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.network.PacketDistributor;
import qouteall.imm_ptl.core.api.PortalAPI;
import qouteall.imm_ptl.core.chunk_loading.ChunkLoader;
import qouteall.imm_ptl.core.chunk_loading.ImmPtlChunkTracking;
/** Optional API class: one expiring stream per player, validated before any chunk load. */
final class RebornPreviewChunks {
private static final Map<ServerPlayer, Lease> LEASES = new IdentityHashMap<>();
private static final Map<ServerPlayer, Long> REQUESTS = new IdentityHashMap<>();
private static final int LIFETIME = 600;
private record Lease(UUID session, ChunkLoader loader, LinkBookTarget destination,
RequestBookPreviewPayload source, long expires) {}
static void open(ServerPlayer player, RequestBookPreviewPayload request) {
long now = player.level().getServer().getTickCount();
if (now - REQUESTS.getOrDefault(player, -100L) < 10) return;
REQUESTS.put(player, now);
ItemStack book = validated(player, request);
if (book.isEmpty()) { close(player); return; }
Lease previous = LEASES.get(player);
if (previous != null && previous.session().equals(request.session())
&& previous.source().offhand() == request.offhand()
&& previous.source().fromStand() == request.fromStand()
&& previous.source().sourceDimension().equals(request.sourceDimension())
&& previous.source().standPos().equals(request.standPos())
&& ItemStack.matches(previous.source().book(), request.book())) {
LEASES.put(player, new Lease(previous.session(), previous.loader(), previous.destination(), request, now + LIFETIME));
PacketDistributor.sendToPlayer(player, new BookPreviewPayload(request.session(), previous.destination()));
return;
}
close(player);
LinkBookTarget target = book.get(Rivencraft.LINK_BOOK_TARGET.get());
var properties = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
var server = player.level().getServer();
if (book.is(Rivencraft.DESCRIPTIVE_BOOK.get())) {
var id = book.get(Rivencraft.AGE_BOOK_ID.get());
var level = id == null ? null : server.getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (level == null || !AgeCatalogSync.ready(player, id)) return;
BlockPos landing = PortAgeTravel.resolveLanding(level, player, properties.forDescriptiveBook());
if (landing == null) return;
target = new LinkBookTarget(id, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
player.getYRot(), player.getXRot());
} else if (book.is(Rivencraft.LINKING_BOOK.get()) && target != null) {
var level = server.getLevel(ResourceKey.create(Registries.DIMENSION, target.dimension()));
if (level == null || !AgeCatalogSync.ready(player, target.dimension())) return;
target = PortLinkDestinations.resolve(player.level(), level, player.blockPosition(), target, properties);
} else return;
if (!target.valid()) return;
var level = server.getLevel(ResourceKey.create(Registries.DIMENSION, target.dimension()));
var center = BlockPos.containing(target.x(), target.y(), target.z());
if (level == null || !level.isInWorldBounds(center) || !level.getWorldBorder().isWithinBounds(center)) return;
close(player);
// Match the root live renderer's eight-chunk orbit view. Reborn schedules
// generation/streaming; no synchronous square of getChunk() calls here.
var loader = new ChunkLoader(level.dimension(), center.getX() >> 4, center.getZ() >> 4, 8);
PortalAPI.addChunkLoaderForPlayer(player, loader);
LEASES.put(player, new Lease(request.session(), loader, target, request, now + LIFETIME));
ImmPtlChunkTracking.immediatelyUpdateForPlayer(player);
PacketDistributor.sendToPlayer(player, new BookPreviewPayload(request.session(), target));
}
private static ItemStack validated(ServerPlayer player, RequestBookPreviewPayload request) {
if (!player.level().dimension().identifier().equals(request.sourceDimension()) || !player.isAlive()) return ItemStack.EMPTY;
if (request.fromStand()) return PortBookNetworking.validatedStandBook(player,
new net.dnorberg.rivencraft.network.UseBookStandPayload(request.sourceDimension(), request.standPos(), request.book()));
ItemStack held = player.getItemInHand(request.offhand() ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND);
return ItemStack.matches(held, request.book()) ? held : ItemStack.EMPTY;
}
static void release(ServerPlayer player, UUID session) {
Lease lease = LEASES.get(player);
if (lease != null && lease.session().equals(session)) close(player);
}
static void close(ServerPlayer player) {
Lease lease = LEASES.remove(player);
if (lease != null) PortalAPI.removeChunkLoaderForPlayer(player, lease.loader());
}
static void tick(MinecraftServer server) {
if (server.getTickCount() % 20 != 0) return;
for (ServerPlayer player : java.util.List.copyOf(LEASES.keySet())) {
Lease lease = LEASES.get(player);
if (server.getTickCount() >= lease.expires()
|| server.getLevel(lease.loader().dimension()) == null
|| validated(player, lease.source()).isEmpty()) close(player);
}
}
static void logout(ServerPlayer player) { close(player); REQUESTS.remove(player); }
static void stop() {
for (ServerPlayer player : java.util.List.copyOf(LEASES.keySet())) close(player);
REQUESTS.clear();
}
private RebornPreviewChunks() {}
}
@@ -0,0 +1,59 @@
package net.dnorberg.rivencraft.mixin;
import net.dnorberg.rivencraft.client.PortBookPreview;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.core.Holder;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Mutes only the remote client world displayed by a Rivencraft book preview. */
@Mixin(value = ClientLevel.class, priority = 2000)
public abstract class ClientLevelSoundMixin {
@Inject(
method = "playSound(DDDLnet/minecraft/sounds/SoundEvent;Lnet/minecraft/sounds/SoundSource;FFZJ)V",
at = @At("HEAD"),
cancellable = true
)
private void rivencraft$mutePreviewPositionSound(
double x, double y, double z, SoundEvent sound, SoundSource source,
float volume, float pitch, boolean distanceDelay, long seed, CallbackInfo callback
) {
rivencraft$cancelPreviewSound(callback);
}
@Inject(
method = "playLocalSound(Lnet/minecraft/world/entity/Entity;Lnet/minecraft/sounds/SoundEvent;Lnet/minecraft/sounds/SoundSource;FF)V",
at = @At("HEAD"),
cancellable = true
)
private void rivencraft$mutePreviewLocalEntitySound(
Entity entity, SoundEvent sound, SoundSource source, float volume, float pitch,
CallbackInfo callback
) {
rivencraft$cancelPreviewSound(callback);
}
@Inject(
method = "playSeededSound(Lnet/minecraft/world/entity/Entity;Lnet/minecraft/world/entity/Entity;Lnet/minecraft/core/Holder;Lnet/minecraft/sounds/SoundSource;FFJ)V",
at = @At("HEAD"),
cancellable = true
)
private void rivencraft$mutePreviewSeededEntitySound(
Entity player, Entity entity, Holder<SoundEvent> sound, SoundSource source,
float volume, float pitch, long seed, CallbackInfo callback
) {
rivencraft$cancelPreviewSound(callback);
}
private void rivencraft$cancelPreviewSound(CallbackInfo callback) {
if (PortBookPreview.shouldMute((ClientLevel)(Object)this)) {
callback.cancel();
}
}
}
@@ -0,0 +1,22 @@
package net.dnorberg.rivencraft.mixin;
import net.dnorberg.rivencraft.client.RebornLiveBookPanel;
import net.dnorberg.rivencraft.compat.RebornBookPreviews;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Pseudo;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Pseudo
@Mixin(targets = "qouteall.imm_ptl.core.render.GuiPortalRendering", remap = false)
public abstract class RebornGuiRenderingMixin {
@Inject(method = "_onGameRenderEnd", at = @At("HEAD"), remap = false)
private static void rivencraft$clearLiveTarget(CallbackInfo ci) {
if (RebornBookPreviews.available()) RebornLiveBookPanel.beginRender();
}
@Inject(method = "_onGameRenderEnd", at = @At("RETURN"), remap = false)
private static void rivencraft$restoreCamera(CallbackInfo ci) {
if (RebornBookPreviews.available()) RebornLiveBookPanel.endRender();
}
}
@@ -0,0 +1,17 @@
package net.dnorberg.rivencraft.network;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public record BookPreviewPayload(UUID session, LinkBookTarget destination) implements CustomPacketPayload {
public static final Type<BookPreviewPayload> TYPE = new Type<>(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "book_preview"));
public static final StreamCodec<RegistryFriendlyByteBuf, BookPreviewPayload> STREAM_CODEC = StreamCodec.of(
(buf, value) -> { buf.writeUUID(value.session()); LinkBookTarget.STREAM_CODEC.encode(buf, value.destination()); },
buf -> new BookPreviewPayload(buf.readUUID(), LinkBookTarget.STREAM_CODEC.decode(buf)));
@Override public Type<BookPreviewPayload> type() { return TYPE; }
}
@@ -33,6 +33,17 @@ public final class PortBookNetworking {
public static void registerPayloads(RegisterPayloadHandlersEvent event) {
var registrar = event.registrar("1");
registrar.playToClient(BookPreviewPayload.TYPE, BookPreviewPayload.STREAM_CODEC);
registrar.playToServer(RequestBookPreviewPayload.TYPE, RequestBookPreviewPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player)
net.dnorberg.rivencraft.compat.RebornBookPreviews.open(player, payload);
});
registrar.playToServer(ReleaseBookPreviewPayload.TYPE, ReleaseBookPreviewPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player)
net.dnorberg.rivencraft.compat.RebornBookPreviews.release(player, payload.session());
});
registrar.playToServer(SetLinkModifierSeedPayload.TYPE, SetLinkModifierSeedPayload.STREAM_CODEC,
(payload, context) -> {
if (context.player() instanceof ServerPlayer player
@@ -130,6 +141,8 @@ public final class PortBookNetworking {
public static Identifier createAge(ServerPlayer player, String title, List<Identifier> pages) {
if (title == null || title.isBlank() || title.length() > MAX_BOOK_TITLE_LENGTH) return null;
long seed = player.getRandom().nextLong();
var policy = pages.isEmpty() ? net.dnorberg.rivencraft.age.PortRandomPagePolicy.configured()
: net.dnorberg.rivencraft.age.PortRandomPagePolicy.UNRESTRICTED;
var recovery = net.dnorberg.rivencraft.writing.PortWritingRecovery.recover(pages);
List<Identifier> activePages = recovery.activePages();
net.dnorberg.rivencraft.age.PortBiomePlan biomePlan;
@@ -137,7 +150,7 @@ public final class PortBookNetworking {
List<Identifier> generationPages;
try {
if (pages.isEmpty()) {
var random = PortRandomAgeSelection.select(player.level().getServer(), seed);
var random = PortRandomAgeSelection.select(player.level().getServer(), seed, policy);
terrain = random.terrain();
biomePlan = random.biomes();
generationPages = pages;
@@ -163,9 +176,9 @@ public final class PortBookNetworking {
id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name);
}
try {
var environment = net.dnorberg.rivencraft.age.PortEnvironmentSelection.resolve(activePages, seed, terrain);
var environment = net.dnorberg.rivencraft.age.PortEnvironmentSelection.resolve(activePages, seed, terrain, policy);
var randomFeatures = pages.isEmpty()
? net.dnorberg.rivencraft.age.PortRandomFeatureSelection.select(server, seed, terrain, biomePlan) : null;
? net.dnorberg.rivencraft.age.PortRandomFeatureSelection.select(server, seed, terrain, biomePlan, policy) : null;
RuntimeAgeBackend.create(server, id, seed, biomePlan, terrain,
randomFeatures == null ? PortAuthoredWriting.rockPlan(generationPages, seed) : randomFeatures.rocks(),
PortAuthoredWriting.vegetationPlan(generationPages),
@@ -175,7 +188,7 @@ public final class PortBookNetworking {
environment.weather(), environment.lighting(), environment.sky(),
net.dnorberg.rivencraft.age.PortAgeColors.resolve(pages, seed),
net.dnorberg.rivencraft.age.PortDayCycle.resolve(pages, seed,
net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld())), pages, title);
net.dnorberg.rivencraft.age.PortDayCycle.nativeTicks(server.overworld()), policy), pages, title, policy);
} catch (RuntimeException exception) {
Rivencraft.LOGGER.warn("Age creation failed for {}", id, exception);
player.sendOverlayMessage(Component.literal("Age creation failed: "
@@ -0,0 +1,15 @@
package net.dnorberg.rivencraft.network;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public record ReleaseBookPreviewPayload(UUID session) implements CustomPacketPayload {
public static final Type<ReleaseBookPreviewPayload> TYPE = new Type<>(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "release_book_preview"));
public static final StreamCodec<RegistryFriendlyByteBuf, ReleaseBookPreviewPayload> STREAM_CODEC = StreamCodec.of(
(buf, value) -> buf.writeUUID(value.session()), buf -> new ReleaseBookPreviewPayload(buf.readUUID()));
@Override public Type<ReleaseBookPreviewPayload> type() { return TYPE; }
}
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.network;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
import net.minecraft.world.item.ItemStack;
/** The server verifies the actual held/stand book, never a client-supplied destination. */
public record RequestBookPreviewPayload(UUID session, boolean offhand, boolean fromStand,
Identifier sourceDimension, BlockPos standPos, ItemStack book) implements CustomPacketPayload {
public static final Type<RequestBookPreviewPayload> TYPE = new Type<>(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "request_book_preview"));
public static final StreamCodec<RegistryFriendlyByteBuf, RequestBookPreviewPayload> STREAM_CODEC = StreamCodec.of(
(buf, value) -> {
buf.writeUUID(value.session()); buf.writeBoolean(value.offhand()); buf.writeBoolean(value.fromStand());
Identifier.STREAM_CODEC.encode(buf, value.sourceDimension());
BlockPos.STREAM_CODEC.encode(buf, value.standPos()); ItemStack.STREAM_CODEC.encode(buf, value.book());
}, buf -> new RequestBookPreviewPayload(buf.readUUID(), buf.readBoolean(), buf.readBoolean(),
Identifier.STREAM_CODEC.decode(buf), BlockPos.STREAM_CODEC.decode(buf), ItemStack.STREAM_CODEC.decode(buf)));
@Override public Type<RequestBookPreviewPayload> type() { return TYPE; }
}
@@ -176,10 +176,12 @@ public final class CrystalPortalNetwork {
return false;
}
}
net.dnorberg.rivencraft.compat.RebornCrystalPortals.refresh(level, receptacle, plan, holder.getBook());
return true;
}
public static void shutdown(ServerLevel level, BlockPos receptacle) {
net.dnorberg.rivencraft.compat.RebornCrystalPortals.remove(level, receptacle);
// A broken support can disconnect the graph. Scan a bounded volume rather
// than trusting a path through blocks that no longer exist.
for (BlockPos pos : BlockPos.betweenClosed(receptacle.offset(-CLEANUP_RADIUS, -CLEANUP_RADIUS, -CLEANUP_RADIUS),
@@ -221,6 +223,8 @@ public final class CrystalPortalNetwork {
level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
}
orientSurfaces(level, plan);
var holder = (BookReceptacleBlockEntity) level.getBlockEntity(receptacle);
net.dnorberg.rivencraft.compat.RebornCrystalPortals.refresh(level, receptacle, plan, holder.getBook());
return true;
}
@@ -24,6 +24,26 @@ public final class PortalBookTravel {
: book.has(Rivencraft.LINK_BOOK_TARGET.get()) ? book.get(Rivencraft.LINK_BOOK_TARGET.get()).dimension() : null;
return id != null && source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id)) != null;
}
/** The same destination rules serve native rendering and actual portal arrivals. */
public static LinkBookTarget destination(ServerLevel source, ItemStack book,
net.minecraft.core.BlockPos departure, float yaw, float pitch) {
var properties = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
if (book.is(Rivencraft.DESCRIPTIVE_BOOK.get())) {
var id = book.get(Rivencraft.AGE_BOOK_ID.get());
var level = id == null ? null : source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (level == null) return null;
var landing = PortAgeTravel.resolveLanding(level, source, departure, properties.forDescriptiveBook());
return landing == null ? null : new LinkBookTarget(id, landing.getX() + 0.5, landing.getY(),
landing.getZ() + 0.5, yaw, pitch);
}
if (!book.is(Rivencraft.LINKING_BOOK.get())) return null;
var target = book.get(Rivencraft.LINK_BOOK_TARGET.get());
if (target == null || !target.valid()) return null;
var level = source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, target.dimension()));
return level == null ? null : net.dnorberg.rivencraft.item.PortLinkDestinations.resolve(
source, level, departure, target, properties);
}
public static void travel(Entity entity, ServerLevel source, ItemStack book) {
if (entity instanceof ServerPlayer player) {
if (book.is(Rivencraft.DESCRIPTIVE_BOOK.get())) {
@@ -38,22 +58,10 @@ public final class PortalBookTravel {
LinkProperties properties = book.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
if (properties.has(LinkProperties.DISARM) && !LinkDisarmService.mayTravel(entity)) return;
boolean descriptive = book.is(Rivencraft.DESCRIPTIVE_BOOK.get());
LinkBookTarget destination = book.get(Rivencraft.LINK_BOOK_TARGET.get());
if (descriptive) {
var id = book.get(Rivencraft.AGE_BOOK_ID.get());
ServerLevel level = id == null ? null : source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (level == null) return;
var landing = PortAgeTravel.resolveLanding(level, entity, properties.forDescriptiveBook());
if (landing == null) return;
destination = new LinkBookTarget(id, landing.getX() + 0.5, landing.getY(), landing.getZ() + 0.5,
entity.getYRot(), entity.getXRot());
}
LinkBookTarget destination = destination(source, book, entity.blockPosition(), entity.getYRot(), entity.getXRot());
if (destination == null || !destination.valid()) return;
ServerLevel target = source.getServer().getLevel(ResourceKey.create(Registries.DIMENSION, destination.dimension()));
if (target == null || !entity.canTeleport(source, target) || !descriptive && !properties.allowsTravel(target == source)) return;
if (!descriptive) destination = net.dnorberg.rivencraft.item.PortLinkDestinations.resolve(source, target,
entity.blockPosition(), destination, properties);
if (!destination.valid()) return;
var feet = net.minecraft.core.BlockPos.containing(destination.x(), destination.y(), destination.z());
if (feet.getY() < target.getMinY() || entity.getBbHeight() + feet.getY() >= target.getMaxY()
|| !target.getWorldBorder().isWithinBounds(feet)) return;
@@ -0,0 +1,40 @@
package net.dnorberg.rivencraft.writing;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
/** Legacy prose word splitting and base-25 width, independent of the client renderer. */
public final class PortGlyphTextLayout {
private static final Pattern WORD_BREAK = Pattern.compile("[^\\p{L}\\p{N}]+");
private static final Pattern NUMERAL = Pattern.compile("[0-9]+");
public record Glyph(String word, int x, int y, int width) {}
public record Layout(List<Glyph> glyphs, int height) {
public Layout { glyphs = List.copyOf(glyphs); }
}
private PortGlyphTextLayout() {}
public static int number(String word) {
if (!NUMERAL.matcher(word).matches()) return -1;
try { return Integer.parseInt(word); }
catch (NumberFormatException exception) { return -1; }
}
public static Layout layout(String text, int maxWidth, int size) {
if (size < 1 || maxWidth < size) throw new IllegalArgumentException("Invalid glyph text dimensions");
var glyphs = new ArrayList<Glyph>();
int x = 0, y = 0;
for (String word : WORD_BREAK.split(text)) {
if (word.isEmpty()) continue;
int value = number(word), width = size;
if (value >= 0) {
int digits = PortSymbolNumerals.digitsFor(value).length;
if (digits > 1) width = digits * (Math.max(8, size * 3 / 4) + 1) - 1;
}
if (x != 0 && x + width > maxWidth) { x = 0; y += size + 1; }
glyphs.add(new Glyph(word, x, y, width));
x += width + 3;
}
return new Layout(glyphs, glyphs.isEmpty() ? 0 : y + size + 1);
}
}
@@ -55,6 +55,18 @@ public final class PortSymbolPages {
.map(Entry::id).sorted().toList();
}
/** Library loot includes exact Ruins templates; Sealed Notebooks keep their own pool. */
public static List<Identifier> libraryCandidateIds() {
return java.util.stream.Stream.concat(ENTRIES.values().stream()
.filter(entry -> entry.id().getNamespace().equals(Rivencraft.MOD_ID)
&& !entry.id().getPath().startsWith("block/")
&& !entry.id().getPath().startsWith("biome/")
&& !entry.role().equals("unknown")).map(Entry::id),
net.dnorberg.rivencraft.compat.RuinsTemplatePages.templates().stream()
.map(net.dnorberg.rivencraft.compat.RuinsTemplatePages::symbolId))
.distinct().sorted().toList();
}
public static List<ItemStack> pages(String category, HolderLookup.Provider registries) {
List<ItemStack> pages = new ArrayList<>();
ENTRIES.values().stream().filter(entry -> entry.category().equals(category))
@@ -16,7 +16,9 @@
],
"client": [
"StaticSkyStateMixin",
"StaticSkyRendererMixin"
"StaticSkyRendererMixin",
"RebornGuiRenderingMixin",
"ClientLevelSoundMixin"
],
"injectors": {
"defaultRequire": 1
@@ -4,11 +4,11 @@ issueTrackerURL="https://git.paddedcell.org/dnorberg/rivencraft/issues"
[[mods]]
modId="${mod_id}"
version="${mod_version}"
displayName="${mod_name} 26.3 Beta"
displayName="${mod_name}"
displayURL="https://git.paddedcell.org/dnorberg/rivencraft"
authors="dnorberg and contributors"
description='''
Experimental Minecraft 26.3 beta with books, writing stations and selected
Minecraft 26.3 release with books, writing stations and selected
authored Age generation. Full 1.21.1 gameplay parity is still in progress.
Use a test world and keep backups; 1.21.1 worlds are not compatible.
'''
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Select affected game targets; docs and release tooling do not trigger game tests."""
import argparse
import json
import subprocess
def affected(paths):
targets = set()
for path in paths:
if path.startswith("src/main/java/") or path in {"build.gradle", "settings.gradle", "gradle.properties"}:
targets.add("mc1211")
if path.startswith("ports/mc263/") and not path.endswith(".md"):
targets.add("mc263")
if path.startswith(("src/main/resources/", "src/generated/", "buildSrc/", "gradle/")) or path in {"gradlew", "gradlew.bat"}:
targets.update(("mc1211", "mc263"))
return sorted(targets)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base", required=True)
args = parser.parse_args()
paths = subprocess.check_output(["git", "diff", "--name-only", args.base, "HEAD"], text=True).splitlines()
values = {"mc1211": ("21", "build"), "mc263": ("25", "-p ports/mc263 build")}
matrix = {"include": [{"target": target, "java": values[target][0], "gradle_args": values[target][1]}
for target in affected(paths)]}
print(json.dumps(matrix, separators=(",", ":")))
if __name__ == "__main__":
main()
@@ -0,0 +1,58 @@
package net.dnorberg.rivencraft.regression;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.*;
import net.dnorberg.rivencraft.network.*;
import net.minecraft.client.Minecraft;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.client.network.ClientPacketDistributor;
@EventBusSubscriber(modid = "rivencraft", value = Dist.CLIENT)
public final class MatrixClient {
private static MatrixPayloads.Step pending;
private static int delay;
@SubscribeEvent public static void register(net.neoforged.neoforge.client.network.event.RegisterClientPayloadHandlersEvent event) {
event.register(MatrixPayloads.Step.TYPE, (payload, context) -> { pending = payload; delay = 0; });
}
@SubscribeEvent public static void tick(net.neoforged.neoforge.client.event.ClientTickEvent.Post event) {
var minecraft = Minecraft.getInstance();
// A player object/catalog can arrive before the receiving-level screen
// finishes. Real book input is only possible once normal play resumes.
if (pending == null || minecraft.player == null || minecraft.gui.screen() != null || ++delay < 8) return;
var step = pending; pending = null;
String result = "ok";
try {
switch (step.action()) {
case "bind" -> ClientPacketDistributor.sendToServer(new BindDescriptiveBookPayload(false, "Matrix Live A"));
case "enter" -> ClientPacketDistributor.sendToServer(new UseDescriptiveBookPayload(false));
case "return" -> ClientPacketDistributor.sendToServer(new UseLinkingBookPayload(false));
case "stale" -> {
ClientPacketDistributor.sendToServer(new AgeCatalogAckPayload(step.version()));
ClientPacketDistributor.sendToServer(new UseDescriptiveBookPayload(false));
}
case "verify" -> {
var listener = minecraft.getConnection();
if (listener == null || !listener.levels.contains(ResourceKey.create(Registries.DIMENSION, step.age())))
throw new AssertionError("missing live dimension");
String actual = PortAgeDescription.forClient(step.age()) + "|" + PortDayCycle.forClient(step.age());
if (!actual.equals(step.expected())) throw new AssertionError("environment differs: " + actual);
result = "verified";
}
case "verify_removed" -> {
var listener = minecraft.getConnection();
if (listener == null || listener.levels.contains(ResourceKey.create(Registries.DIMENSION, step.age())))
throw new AssertionError("retired dimension retained");
if (PortAgeDescription.forClient(step.age()) != null) throw new AssertionError("stale description retained");
result = "removed";
}
case "exit" -> { minecraft.stop(); return; }
default -> throw new AssertionError("unknown action");
}
} catch (Throwable error) { result = "FAIL " + error; }
ClientPacketDistributor.sendToServer(new MatrixPayloads.Report(result));
}
}
@@ -0,0 +1,25 @@
package net.dnorberg.rivencraft.regression;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** Test-only channels; these sources are never part of either shipping source set. */
public final class MatrixPayloads {
public record Step(String action, Identifier age, String expected, int version) implements CustomPacketPayload {
public static final Type<Step> TYPE = new Type<>(Identifier.fromNamespaceAndPath("rivencraft", "regression_step"));
public static final StreamCodec<RegistryFriendlyByteBuf, Step> CODEC = StreamCodec.composite(
ByteBufCodecs.STRING_UTF8, Step::action, Identifier.STREAM_CODEC, Step::age,
ByteBufCodecs.STRING_UTF8, Step::expected, ByteBufCodecs.VAR_INT, Step::version, Step::new);
@Override public Type<Step> type() { return TYPE; }
}
public record Report(String result) implements CustomPacketPayload {
public static final Type<Report> TYPE = new Type<>(Identifier.fromNamespaceAndPath("rivencraft", "regression_report"));
public static final StreamCodec<RegistryFriendlyByteBuf, Report> CODEC =
StreamCodec.composite(ByteBufCodecs.STRING_UTF8, Report::result, Report::new);
@Override public Type<Report> type() { return TYPE; }
}
private MatrixPayloads() {}
}
@@ -0,0 +1,208 @@
package net.dnorberg.rivencraft.regression;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.PortConfig;
import net.dnorberg.rivencraft.age.*;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.storage.LevelResource;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.network.PacketDistributor;
@EventBusSubscriber(modid = "rivencraft")
public final class MatrixServer {
private static final Map<String, String> reports = new HashMap<>();
private static int phase, ticks, checks;
private static boolean initialized, restart, finished;
private static Identifier a, b;
private static long seed;
private static final long RESEED = 102030405060L;
@SubscribeEvent public static void payloads(net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent event) {
var registrar = event.registrar("matrix-1");
registrar.playToClient(MatrixPayloads.Step.TYPE, MatrixPayloads.Step.CODEC);
registrar.playToServer(MatrixPayloads.Report.TYPE, MatrixPayloads.Report.CODEC, (payload, context) -> {
if (context.player() instanceof ServerPlayer player) {
if (payload.result().startsWith("FAIL")) {
finished = true; System.out.println("MC263_MATRIX FAIL " + player.getName().getString() + ": " + payload.result()); return;
}
reports.put(player.getName().getString(), payload.result());
}
});
}
private static void check(boolean ok, String message) {
if (!ok) throw new AssertionError(message);
checks++;
}
private static Identifier id(String path) { return Identifier.fromNamespaceAndPath("rivencraft", path); }
private static Path marker(MinecraftServer server) { return server.getWorldPath(LevelResource.ROOT).resolve("matrix.properties"); }
private static void phase(int next, String label) {
phase = next; ticks = 0; reports.clear();
System.out.println("MC263_MATRIX PHASE " + next + " " + label);
}
private static List<ServerPlayer> players(MinecraftServer server) {
return server.getPlayerList().getPlayers().stream().sorted(Comparator.comparing(p -> p.getName().getString())).toList();
}
private static void send(ServerPlayer player, String action, Identifier age, String expected, int version) {
PacketDistributor.sendToPlayer(player, new MatrixPayloads.Step(action, age, expected, version));
}
private static void both(MinecraftServer server, String action, Identifier age, String expected) {
for (var player : players(server)) send(player, action, age, expected, 0);
}
private static void give(ServerPlayer player, ItemStack book) {
player.getInventory().setSelectedSlot(0);
player.getInventory().setItem(0, book.copy());
player.containerMenu.broadcastChanges();
}
private static ItemStack book(Identifier age) {
var stack = new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get());
stack.set(Rivencraft.AGE_BOOK_ID.get(), age);
return stack;
}
private static boolean ready(MinecraftServer server, Identifier age) {
return players(server).size() == 2 && players(server).stream().allMatch(p -> AgeCatalogSync.ready(p, age));
}
private static boolean arrived(MinecraftServer server, Identifier age) {
return players(server).size() == 2 && players(server).stream().allMatch(p -> p.level().dimension().identifier().equals(age));
}
private static boolean reported(String expected) {
return reports.size() == 2 && reports.values().stream().allMatch(expected::equals);
}
private static String presentation(MinecraftServer server, Identifier age) {
return RuntimeAgeBackend.loadedDescriptions(server).get(age) + "|" + RuntimeAgeBackend.savedDayCycles(server).get(age);
}
private static void enter(MinecraftServer server, Identifier age) {
for (var player : players(server)) { give(player, book(age)); send(player, "enter", age, "", 0); }
}
private static void leave(MinecraftServer server) {
var source = server.overworld();
for (var player : players(server)) {
int x = player.getName().getString().endsWith("One") ? -4 : 4;
int y = source.getHeight(net.minecraft.world.level.levelgen.Heightmap.Types.MOTION_BLOCKING, x, 0);
var stack = new ItemStack(Rivencraft.LINKING_BOOK.get());
stack.set(Rivencraft.LINK_BOOK_TARGET.get(), new LinkBookTarget(Level.OVERWORLD.identifier(), x + .5, y, .5, 0, 0));
give(player, stack); send(player, "return", Level.OVERWORLD.identifier(), "", 0);
}
}
@SubscribeEvent public static void tick(net.neoforged.neoforge.event.tick.ServerTickEvent.Post event) {
if (finished) return;
var server = event.getServer();
try {
if (!initialized) {
initialized = true;
restart = Files.exists(marker(server));
if (restart) {
var p = new Properties();
try (var input = Files.newInputStream(marker(server))) { p.load(input); }
a = Identifier.parse(p.getProperty("a")); b = Identifier.parse(p.getProperty("b"));
seed = Long.parseLong(p.getProperty("seed"));
}
phase(0, restart ? "await restarted clients" : "await two connected clients");
}
ticks++;
var ps = players(server);
if (phase == 0) {
if (ps.size() != 2 || !ready(server, restart ? a : Level.OVERWORLD.identifier())) return;
for (var player : ps) player.setGameMode(net.minecraft.world.level.GameType.CREATIVE);
if (restart) {
check(arrived(server, a), "both saved players restored in Age A");
check(RuntimeAgeBackend.savedAges(server).get(a) == seed, "reseed survives full restart");
check(!RuntimeAgeBackend.savedAges(server).containsKey(b), "retired Age not restored");
both(server, "verify", a, presentation(server, a)); phase(20, "restarted catalog and presentation");
} else {
PortConfig.RANDOM_PAGE_ALLOWLIST.set(List.of("terrain/flat", "biome/single", "biome/minecraft/plains",
"sky/static", "weather/storm", "lighting/bright"));
give(ps.getFirst(), new ItemStack(Rivencraft.DESCRIPTIVE_BOOK.get()));
send(ps.getFirst(), "bind", Level.OVERWORLD.identifier(), "", 0);
phase(1, "normal handheld binding with both clients online");
}
} else if (phase == 1) {
var age = ps.getFirst().getMainHandItem().get(Rivencraft.AGE_BOOK_ID.get());
if (age == null || !ready(server, age)) return;
a = age;
check(ps.stream().allMatch(p -> p.level().dimension() == Level.OVERWORLD), "creation does not teleport either player");
both(server, "verify", a, presentation(server, a)); phase(2, "new catalog reached both clients without reconnect");
} else if (phase == 2 && reported("verified")) {
b = PortBookNetworking.createAge(ps.getLast(), "Matrix Authored B", List.of(
id("terrain/flat"), id("biome/single"), id("biome/minecraft/plains"),
id("sky/end"), id("lighting/dark"), id("weather/off")));
check(b != null && !a.equals(b), "distinct authored Age from normal Binder backend");
phase(3, "expanded authored catalog");
} else if (phase == 3 && ready(server, b)) {
both(server, "verify", b, presentation(server, b)); phase(4, "authored environment arrived at both clients");
} else if (phase == 4 && reported("verified")) {
enter(server, a); phase(5, "normal books enter live-created Age A");
} else if (phase == 5 && arrived(server, a)) {
check(true, "both clients entered live-created A");
leave(server); phase(6, "normal Linking Books return");
} else if (phase == 6 && arrived(server, Level.OVERWORLD.identifier())) {
// Deliberately withhold readiness; the stale acknowledgement itself is a real client payload.
var field = AgeCatalogSync.class.getDeclaredField("STATES"); field.setAccessible(true);
var state = ((Map<?, ?>) field.get(null)).get(server);
var ack = state.getClass().getDeclaredField("acknowledged"); ack.setAccessible(true);
((Map<?, ?>) ack.get(state)).remove(ps.getLast().getUUID());
var version = state.getClass().getDeclaredField("version"); version.setAccessible(true);
give(ps.getLast(), book(a));
send(ps.getLast(), "stale", a, "", version.getInt(state) - 1);
phase(7, "stale acknowledgement and failed travel");
} else if (phase == 7 && ticks >= 30 && reports.containsKey(ps.getLast().getName().getString())) {
check(ps.getLast().level().dimension() == Level.OVERWORLD, "unacknowledged travel stays at source");
check(!AgeCatalogSync.ready(ps.getLast(), a), "stale payload did not acknowledge current catalog");
AgeCatalogSync.onCatalogChanged(server); phase(8, "fresh catalog recovery");
} else if (phase == 8 && ready(server, b)) {
enter(server, b); phase(9, "normal books enter authored Age B");
} else if (phase == 9 && arrived(server, b)) {
boolean refused = false;
try { RuntimeAgeBackend.unload(server, b); } catch (RuntimeException expected) { refused = true; }
check(refused, "occupied Age unload refused");
leave(server); phase(10, "vacate before lifecycle operations");
} else if (phase == 10 && arrived(server, Level.OVERWORLD.identifier())) {
RuntimeAgeBackend.unload(server, a);
check(server.getLevel(ResourceKey.create(Registries.DIMENSION, a)) == null, "unload removed live level");
RuntimeAgeBackend.reload(server, a);
RuntimeAgeBackend.reseed(server, a, RESEED);
check(RuntimeAgeBackend.savedAges(server).get(a) == RESEED, "new seed persisted");
RuntimeAgeBackend.retire(server, b);
check(!RuntimeAgeBackend.savedAges(server).containsKey(b), "retired B removed from catalog");
phase(11, "unload reload reseed retire catalog propagation");
} else if (phase == 11 && ready(server, a)) {
both(server, "verify_removed", b, ""); phase(12, "both clients removed retired dimension");
} else if (phase == 12 && reported("removed")) {
enter(server, b); phase(13, "stale retired book cannot recreate B");
} else if (phase == 13 && ticks >= 30 && reported("ok")) {
check(arrived(server, Level.OVERWORLD.identifier()), "retired books leave players at source");
enter(server, a); phase(14, "save both players inside surviving Age");
} else if (phase == 14 && arrived(server, a)) {
var p = new Properties(); p.setProperty("a", a.toString()); p.setProperty("b", b.toString());
p.setProperty("seed", Long.toString(RESEED));
try (var output = Files.newOutputStream(marker(server))) { p.store(output, "Disposable multiplayer restart evidence"); }
both(server, "exit", a, ""); finished = true;
System.out.println("MC263_MATRIX FIRST_PASS checks=" + checks);
} else if (phase == 20 && reported("verified")) {
both(server, "verify_removed", b, ""); phase(21, "retirement persisted through restart");
} else if (phase == 21 && reported("removed")) {
leave(server); phase(22, "return linking works after reconnect and restart");
} else if (phase == 22 && arrived(server, Level.OVERWORLD.identifier())) {
check(true, "both players returned after full restart");
both(server, "exit", a, ""); finished = true;
System.out.println("MC263_MATRIX RESTART_PASS checks=" + checks);
}
} catch (Throwable error) {
finished = true; System.out.println("MC263_MATRIX FAIL phase=" + phase); error.printStackTrace();
}
}
}
+56 -10
View File
@@ -13,6 +13,8 @@ from pathlib import Path
import secrets
import subprocess
import sys
import tomllib
import zipfile
import urllib.error
import urllib.request
@@ -97,7 +99,8 @@ def request_json(url: str, *, token: str | None = None, token_header: str = "Aut
request = urllib.request.Request(url, data=payload, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=120) as response:
return json.load(response)
body = response.read()
return json.loads(body) if body else None
except urllib.error.HTTPError as error:
if error.code == 404 and not_found_ok:
return None
@@ -150,6 +153,15 @@ def release_metadata(args: argparse.Namespace) -> tuple[dict, dict]:
"releaseType": args.channel,
"isMarkedForManualRelease": False,
}
if args.minecraft == "1.21.1":
modrinth["dependencies"] = [
{"project_id": "ftdbN0KK", "dependency_type": "required"},
{"project_id": "qbUzidyB", "dependency_type": "required"},
]
curseforge["relations"] = {"projects": [
{"slug": "badpackets", "type": "requiredDependency"},
{"slug": "dynamicdimension-ar-edition", "projectID": "1469680", "type": "requiredDependency"},
]}
return modrinth, curseforge
@@ -180,11 +192,48 @@ def publish_curseforge(metadata: dict, jar: Path) -> None:
print(f"CurseForge: https://www.curseforge.com/minecraft/mc-mods/rivencraft/files/{result['id']} (pending approval)")
def verify_artifact(jar: Path, version: str, minecraft: str, java: str, channel: str) -> None:
"""Refuse renamed/wrong-track artifacts before obtaining any upload credential."""
expected_java = {"1.21.1": "21", "26.3": "25"}
if minecraft not in expected_java or java != expected_java[minecraft]:
raise RuntimeError("Unsupported Minecraft/Java target pairing")
_, filename = release_identity(version, minecraft, channel)
if jar.name != filename:
raise RuntimeError("JAR filename does not match Minecraft and release versions")
with zipfile.ZipFile(jar) as archive:
metadata = tomllib.loads(archive.read("META-INF/neoforge.mods.toml").decode())
mod = next((entry for entry in metadata.get("mods", []) if entry.get("modId") == "rivencraft"), None)
if mod is None or mod.get("version") != version:
raise RuntimeError("Embedded mod version does not match the requested release")
dependencies = metadata.get("dependencies", {}).get("rivencraft", [])
game = next((entry for entry in dependencies if entry.get("modId") == "minecraft"), None)
if game is None or game.get("versionRange") != f"[{minecraft}]":
raise RuntimeError("Embedded Minecraft range does not match the requested target")
if any(name.startswith("net/dnorberg/rivencraft/regression/") for name in archive.namelist()):
raise RuntimeError("Refusing an artifact containing disposable multiplayer test fixtures")
def release_identity(version: str, minecraft: str, channel: str) -> tuple[str, str]:
"""New releases prefix the version with Minecraft; retain old beta upload support."""
if version.startswith(minecraft + "."):
base, separator, suffix = version.partition("-")
counters = base[len(minecraft) + 1:].split(".")
if len(counters) != 2 or not all(value.isdecimal() for value in counters):
raise RuntimeError("Expected Minecraft.release-slice.iteration version")
if channel == "release":
if separator:
raise RuntimeError("Regular releases must not have a prerelease suffix")
elif not separator or suffix != f"{channel}.1":
raise RuntimeError("Prerelease version must end in the selected channel and .1")
return f"V{version}", f"rivencraft-{version}.jar"
base, separator, suffix = version.partition("-")
if not separator or suffix != f"{channel}.1":
raise RuntimeError("Legacy upload requires a selected-channel .1 suffix")
return f"V{base}-mc{minecraft}-{suffix}", f"rivencraft-{minecraft}-{version}.jar"
def publish_gitea(args: argparse.Namespace) -> None:
base_version, suffix = args.version.split("-", 1)
if suffix != f"{args.channel}.1":
raise RuntimeError("Gitea tag convention requires a version ending in the selected channel and .1")
tag = f"V{base_version}-mc{args.minecraft}-{suffix}"
tag, _ = release_identity(args.version, args.minecraft, args.channel)
# The release must refer to a tag already pushed by the normal Git workflow.
remote_tag = subprocess.run(
["git", "ls-remote", "origin", f"refs/tags/{tag}"],
@@ -197,7 +246,7 @@ def publish_gitea(args: argparse.Namespace) -> None:
release_url = f"{GITEA_API}/releases/tags/{tag}"
release = request_json(release_url, token=token, not_found_ok=True)
notes = args.changelog.read_text(encoding="utf-8")
title = f"Rivencraft {args.minecraft} {args.channel} {args.version}"
title = args.name
if release is None:
metadata = {
"tag_name": tag,
@@ -255,10 +304,7 @@ def main() -> None:
raise RuntimeError(f"JAR does not exist: {args.jar}")
if not args.changelog.is_file():
raise RuntimeError(f"Changelog does not exist: {args.changelog}")
if args.minecraft != "26.3":
raise RuntimeError("Only the 26.3 line is configured; 1.21.1 needs required dependency relations")
if args.jar.name != f"rivencraft-{args.minecraft}-{args.version}.jar":
raise RuntimeError("JAR filename does not match Minecraft and release versions")
verify_artifact(args.jar, args.version, args.minecraft, args.java, args.channel)
modrinth, curseforge = release_metadata(args)
sites = SITES if args.site == "all" else (("modrinth", "curseforge") if args.site == "both" else (args.site,))
print(f"JAR: {args.jar.resolve()} ({args.jar.stat().st_size} bytes)")
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Two real 26.3 clients, one sequential server, normal book travel and full restart."""
from server_test_lock import server_test_lock
import json
import os
from pathlib import Path
import shutil
import signal
import socket
import subprocess
import tempfile
import time
ROOT = Path(__file__).resolve().parents[1]
LOGS = ROOT / "build/verification/multiplayer263"
class Handoff(RuntimeError):
pass
def wait_for(processes, log, marker, timeout=60):
last_progress = time.monotonic()
last_stage = ""
while True:
text = log.read_text(errors="replace") if log.exists() else ""
if marker in text:
return
if "MC263_MATRIX FAIL" in text:
raise RuntimeError(f"Multiplayer assertion failed; inspect {log}")
stages = [line for line in text.splitlines() if "MC263_MATRIX PHASE" in line]
if stages and stages[-1] != last_stage:
last_stage = stages[-1]
last_progress = time.monotonic()
print(last_stage, flush=True)
if any(process.poll() is not None for process in processes):
raise RuntimeError(f"Process exited before {marker}; inspect {LOGS}")
if time.monotonic() - last_progress >= timeout:
raise Handoff(f"Stalled at {last_stage or marker}; stop and hand off, do not retry")
time.sleep(.25)
def stop(process, server=False):
if process is None or process.poll() is not None:
return
if server:
try:
process.stdin.write("stop\n")
process.stdin.flush()
process.wait(timeout=25)
return
except (BrokenPipeError, subprocess.TimeoutExpired):
pass
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait()
def run():
if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
raise SystemExit("This real-client matrix needs a graphics session. Use the manual checklist.")
LOGS.mkdir(parents=True, exist_ok=True)
work = Path(tempfile.mkdtemp(prefix="rivencraft-multiplayer263-"))
server = work / "server"
server.mkdir()
with socket.socket() as reservation:
reservation.bind(("127.0.0.1", 0))
port = reservation.getsockname()[1]
settings = {"layers": [{"height": 1, "block": "minecraft:bedrock"},
{"height": 2, "block": "minecraft:dirt"},
{"height": 1, "block": "minecraft:grass_block"}],
"biome": "minecraft:plains"}
(server / "eula.txt").write_text("eula=true\n")
(server / "server.properties").write_text(
f"server-ip=127.0.0.1\nserver-port={port}\nonline-mode=false\nwhite-list=false\n"
"view-distance=2\nsimulation-distance=2\ngenerate-structures=false\n"
"level-type=minecraft:flat\nlevel-seed=263100\ngenerator-settings=" + json.dumps(settings) + "\n")
for name in ("One", "Two"):
client = work / f"client-{name}"
(client / "config").mkdir(parents=True)
(client / "config/neoforge-client.toml").write_text("showLoadWarnings=false\n")
(client / "options.txt").write_text("renderDistance:2\nsimulationDistance:5\nmaxFps:25\npauseOnLostFocus:false\n")
init = work / "fixture.gradle"
init.write_text("""
gradle.beforeProject { p ->
p.layout.buildDirectory.set(p.file(System.getenv('RIVENCRAFT_MATRIX_WORK') + '/build'))
p.afterEvaluate {
p.sourceSets.main.java.srcDir(p.file('""" + str(ROOT / "scripts/fixtures/mc263_multiplayer") + """'))
p.neoForge.runs.client.gameDirectory = p.file(System.getenv('RIVENCRAFT_MATRIX_WORK') + '/client-' + p.findProperty('matrixClient'))
p.neoForge.runs.client.programArgument('--width')
p.neoForge.runs.client.programArgument('640')
p.neoForge.runs.client.programArgument('--height')
p.neoForge.runs.client.programArgument('480')
}
}
""")
env = dict(os.environ, RIVENCRAFT_MATRIX_WORK=str(work))
base = [str(ROOT / "gradlew"), "-p", str(ROOT / "ports/mc263"),
"-I", str(init), "--no-daemon", "--console=plain"]
server_process = None
clients = []
handles = []
try:
# Build fixture classes once in an isolated output tree. Shipping outputs
# are never overwritten or packaged with these extra test channels.
with (LOGS / "compile.log").open("w") as output:
subprocess.run(base + ["classes"], cwd=ROOT, env=env, stdout=output,
stderr=subprocess.STDOUT, check=True, timeout=300)
for phase in ("first", "restart"):
log = LOGS / f"server-{phase}.log"
output = log.open("w")
handles.append(output)
server_process = subprocess.Popen(base + ["runServer", f"-PserverRunDir={server}"],
cwd=ROOT, env=env, stdin=subprocess.PIPE, stdout=output,
stderr=subprocess.STDOUT, text=True, start_new_session=True)
wait_for([server_process], log, "Done (")
clients = []
for name in ("One", "Two"):
output = (LOGS / f"client-{name}-{phase}.log").open("w")
handles.append(output)
clients.append(subprocess.Popen(base + ["runClient", f"-PmatrixClient={name}",
f"-PclientName=Matrix{name}", f"-PconnectAddress=127.0.0.1:{port}"],
cwd=ROOT, env=env, stdout=output, stderr=subprocess.STDOUT,
text=True, start_new_session=True))
wait_for([server_process], log, "MC263_MATRIX " + ("FIRST_PASS" if phase == "first" else "RESTART_PASS"))
for process in clients:
process.wait(timeout=30)
if process.returncode:
raise RuntimeError("Client exited unsuccessfully; inspect client log")
clients.clear()
stop(server_process, server=True)
if server_process.returncode:
raise RuntimeError("Server exited unsuccessfully")
server_process = None
print(f"PASS two-client {phase}", flush=True)
except Handoff as error:
directions = (
f"{error}\n\nManual test: two clients on one 26.3 server. Create a new Age while both "
"are online; both visit via Descriptive Books and return via Linking Books. "
"Create a second Age with different sky/lighting/weather. Restart and reconnect both, "
"confirm names, seeds and return links. Refuse occupied unload, then vacate and "
"unload/reload/reseed/retire an empty Age. A retired book must leave the player at source.\n"
"Temporary worlds were removed; do not rerun this stalled test automatically.\n")
(LOGS / "HANDOFF.md").write_text(directions)
print("HANDOFF: " + directions, flush=True)
raise SystemExit(2)
finally:
for process in clients:
stop(process)
stop(server_process, server=True)
for handle in handles:
handle.close()
shutil.rmtree(work)
print(f"Removed all temporary client/server worlds and fixture outputs; logs: {LOGS}", flush=True)
def main():
with server_test_lock():
run()
if __name__ == "__main__":
main()
+18
View File
@@ -0,0 +1,18 @@
"""One managed temporary Minecraft server at a time on this host."""
from contextlib import contextmanager
import fcntl
from pathlib import Path
import tempfile
@contextmanager
def server_test_lock():
with (Path(tempfile.gettempdir()) / "rivencraft-server-tests.lock").open("a") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise SystemExit("Another Rivencraft server test is active; finish it before starting this one")
try:
yield
finally:
fcntl.flock(lock, fcntl.LOCK_UN)
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Small dual-target dedicated smoke. Worlds are disposable; stalled checks hand off."""
import argparse
from server_test_lock import server_test_lock
import json
import os
from pathlib import Path
import shutil
import signal
import subprocess
import tempfile
import time
ROOT = Path(__file__).resolve().parents[1]
class StalledTest(RuntimeError):
pass
def wait_for(process, log, marker, timeout=60):
started = time.monotonic()
while time.monotonic() - started < timeout:
text = log.read_text(errors="replace") if log.exists() else ""
if marker in text:
return text
if process.poll() is not None:
raise RuntimeError(f"Server exited before {marker!r}; inspect {log}")
time.sleep(0.25)
raise StalledTest(f"No result for {marker!r} within {timeout}s; stop and hand off, do not retry")
def stop(process):
if process is None or process.poll() is not None:
return
try:
process.stdin.write("stop\n")
process.stdin.flush()
process.wait(timeout=25)
except (BrokenPipeError, subprocess.TimeoutExpired):
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target", choices=("mc1211", "mc263"), required=True)
args = parser.parse_args()
logs = ROOT / "build/verification" / args.target
logs.mkdir(parents=True, exist_ok=True)
work = Path(tempfile.mkdtemp(prefix=f"rivencraft-{args.target}-smoke-"))
settings = {"layers": [{"height": 1, "block": "minecraft:bedrock"},
{"height": 2, "block": "minecraft:dirt"},
{"height": 1, "block": "minecraft:grass_block"}],
"biome": "minecraft:plains"}
(work / "eula.txt").write_text("eula=true\n")
(work / "server.properties").write_text(
"server-ip=127.0.0.1\nserver-port=0\nonline-mode=false\nwhite-list=false\n"
"view-distance=2\nsimulation-distance=2\ngenerate-structures=false\n"
"level-type=minecraft:flat\nlevel-seed=263100\ngenerator-settings=" + json.dumps(settings) + "\n")
command = [str(ROOT / "gradlew")]
if args.target == "mc263":
command += ["-p", str(ROOT / "ports/mc263")]
command += ["--no-daemon", "runServer", f"-PserverRunDir={work}", "--console=plain"]
process = None
try:
for phase in ("first", "restart"):
log = logs / f"{phase}.log"
with log.open("w") as output:
process = subprocess.Popen(command, cwd=ROOT, stdin=subprocess.PIPE,
stdout=output, stderr=subprocess.STDOUT, text=True, start_new_session=True)
wait_for(process, log, "Done (")
if args.target == "mc263":
if phase == "first":
for name, seed in (("smoke_a", 123456789), ("smoke_b", 987654321)):
process.stdin.write(f"rivencraft_port age create {name} {seed} flat\n")
process.stdin.flush()
wait_for(process, log, f"Created rivencraft:age/{name} with seed {seed}")
else:
text = log.read_text()
for name, seed in (("smoke_a", 123456789), ("smoke_b", 987654321)):
if f"Loaded 26.3 prototype Age rivencraft:age/{name} with seed {seed}" not in text:
raise RuntimeError("Age identity/seed failed full restart")
process.stdin.write("rivencraft_port age list\n")
process.stdin.flush()
process.stdin.write("save-all flush\n")
process.stdin.flush()
wait_for(process, log, "Saved the game")
else:
# 1.21.1's Gradle launcher does not forward this pipe. Its
# unchanged gameplay uses the recorded/manual regression;
# this smoke checks startup and graceful process shutdown.
os.killpg(process.pid, signal.SIGINT)
if args.target == "mc1211":
try:
process.wait(timeout=25)
except subprocess.TimeoutExpired:
stop(process)
else:
stop(process)
if process.returncode not in (0, 130, -signal.SIGINT):
raise RuntimeError(f"Server exited with {process.returncode}; inspect {log}")
process = None
print(f"PASS {args.target} {phase}", flush=True)
except StalledTest as error:
print(f"HANDOFF: {error}. Manual check: start {args.target}, create two Ages, travel, "
"restart and revisit both; confirm names/seeds and return travel.", flush=True)
raise SystemExit(2)
finally:
stop(process)
shutil.rmtree(work)
print(f"Removed temporary world; logs: {logs}", flush=True)
if __name__ == "__main__":
with server_test_lock():
main()
+2 -323
View File
@@ -1,326 +1,5 @@
#!/usr/bin/env bash
# Disposable, headless smoke check for the incomplete 26.3 runtime Age backend.
# Compatibility entry point; keep smoke narrow instead of rerunning every feature probe.
set -euo pipefail
repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
smoke_dir=$(mktemp -d "${TMPDIR:-/tmp}/rivencraft-263-smoke.XXXXXX")
server_dir="$smoke_dir/server"
server_pid=
input_fd=
cleanup() {
if [[ -n "$server_pid" ]] && kill -0 "$server_pid" 2>/dev/null; then
if [[ -n "$input_fd" ]]; then printf 'stop\n' >&"$input_fd" || true; fi
wait "$server_pid" 2>/dev/null || true
fi
if [[ -n "$input_fd" ]]; then exec {input_fd}>&-; fi
rm -rf -- "$smoke_dir"
}
trap cleanup EXIT
mkdir -p "$server_dir"
printf 'eula=true\n' > "$server_dir/eula.txt"
printf 'server-port=0\nonline-mode=false\nwhite-list=false\n' > "$server_dir/server.properties"
wait_for_log() {
local log_file=$1 pattern=$2 seconds=$3
local tries=$((seconds * 4))
for ((i = 0; i < tries; i++)); do
if grep -Fq -- "$pattern" "$log_file" 2>/dev/null; then return 0; fi
if ! kill -0 "$server_pid" 2>/dev/null; then
printf 'Server exited before: %s\n' "$pattern" >&2
tail -80 "$log_file" >&2
return 1
fi
sleep 0.25
done
printf 'Timed out waiting for: %s\n' "$pattern" >&2
tail -80 "$log_file" >&2
return 1
}
start_server() {
local phase=$1
log_file="$smoke_dir/$phase.log"
fifo="$smoke_dir/$phase.stdin"
mkfifo "$fifo"
exec {input_fd}<>"$fifo"
"$repo_dir/gradlew" -p "$repo_dir/ports/mc263" runServer \
-PserverRunDir="$server_dir" --no-daemon < "$fifo" > "$log_file" 2>&1 &
server_pid=$!
wait_for_log "$log_file" 'Done (' 120
}
stop_server() {
printf 'stop\n' >&"$input_fd"
wait "$server_pid"
server_pid=
exec {input_fd}>&-
rm -- "$fifo"
}
send_and_wait() {
local command=$1 result=$2
printf '%s\n' "$command" >&"$input_fd"
wait_for_log "$log_file" "$result" 60
}
start_server first
send_and_wait 'rivencraft_port compat' 'Registered biomes by namespace:'
send_and_wait 'rivencraft_port age create smoke_alpha 123456789' 'Created rivencraft:age/smoke_alpha with seed 123456789'
send_and_wait 'rivencraft_port age create smoke_beta 987654321' 'Created rivencraft:age/smoke_beta with seed 987654321'
send_and_wait 'rivencraft_port age create smoke_flat 24680 flat' 'Created rivencraft:age/smoke_flat with seed 24680 terrain=FLAT'
send_and_wait 'rivencraft_port age create smoke_void 13579 void' 'Created rivencraft:age/smoke_void with seed 13579 terrain=VOID'
send_and_wait 'rivencraft_port age create smoke_cave 112233 cave' 'Created rivencraft:age/smoke_cave with seed 112233 terrain=CAVE'
send_and_wait 'rivencraft_port age create_multi smoke_flat_multi 24681 flat' 'Created rivencraft:age/smoke_flat_multi with seed 24681 terrain=FLAT'
send_and_wait 'rivencraft_port age create_multi smoke_void_multi 13580 void' 'Created rivencraft:age/smoke_void_multi with seed 13580 terrain=VOID'
send_and_wait 'rivencraft_port age create_multi smoke_cave_multi 112234 cave' 'Created rivencraft:age/smoke_cave_multi with seed 112234 terrain=CAVE'
send_and_wait 'rivencraft_port age create_random smoke_random 889900' 'Random Age plan: terrain='
send_and_wait 'rivencraft_port age create_rocks smoke_rocks 445566' 'Created rivencraft:age/smoke_rocks with seed 445566 terrain=FLAT'
send_and_wait 'rivencraft_port age create_rocks_multi smoke_rocks_multi 445566' 'Created rivencraft:age/smoke_rocks_multi with seed 445566 terrain=FLAT'
send_and_wait 'rivencraft_port age create_rocks_random smoke_rocks_random 445566' 'Created rivencraft:age/smoke_rocks_random with seed 445566 terrain=FLAT'
send_and_wait 'rivencraft_port age create_vegetation smoke_vegetation 24680' 'Created rivencraft:age/smoke_vegetation with seed 24680 terrain=FLAT'
send_and_wait 'rivencraft_port age create_dungeons smoke_dungeons 135791' 'Created rivencraft:age/smoke_dungeons with seed 135791 terrain=STANDARD'
send_and_wait 'rivencraft_port age create smoke_lake_control 667788' 'Created rivencraft:age/smoke_lake_control with seed 667788 terrain=STANDARD'
send_and_wait 'rivencraft_port age create_lakes smoke_lakes 667788' 'Created rivencraft:age/smoke_lakes with seed 667788 terrain=STANDARD'
send_and_wait 'rivencraft_port age create_lakes_random smoke_lakes_random 667788' 'Created rivencraft:age/smoke_lakes_random with seed 667788 terrain=STANDARD'
# This fixture includes a rare island candidate near the origin at normal rarity.
send_and_wait 'rivencraft_port age create_islands smoke_islands 887767 void' 'Created rivencraft:age/smoke_islands with seed 887767 terrain=VOID'
send_and_wait 'rivencraft_port age create_islands smoke_islands_flat 887767 flat' 'Created rivencraft:age/smoke_islands_flat with seed 887767 terrain=FLAT'
send_and_wait 'rivencraft_port age create_islands smoke_islands_noise 887767 standard' 'Created rivencraft:age/smoke_islands_noise with seed 887767 terrain=STANDARD'
send_and_wait 'rivencraft_port age create_islands_random smoke_islands_random 887767 void' 'Created rivencraft:age/smoke_islands_random with seed 887767 terrain=VOID'
# The index must reach disk while the server is still running. A graceful
# shutdown would hide the interrupted-save regression this checks.
index_file="$server_dir/world/dimensions/minecraft/overworld/data/rivencraft/runtime_ages.dat"
[[ -s "$index_file" ]] || {
printf 'Age index was not persisted before shutdown: %s\n' "$index_file" >&2
exit 1
}
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_alpha'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_beta'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_flat'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_void'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_cave'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_flat_multi'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_void_multi'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_cave_multi'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_random'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_rocks'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_rocks_multi'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_rocks_random'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_vegetation'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_dungeons'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_lakes'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_lakes_random'
for island_age in smoke_islands smoke_islands_flat smoke_islands_noise smoke_islands_random; do
gzip -dc "$index_file" | strings | grep -Fq "rivencraft:age/$island_age"
done
send_and_wait 'rivencraft_port age sample smoke_alpha' 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_beta' 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_flat' 'rivencraft:age/smoke_flat seed=24680 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_void' 'rivencraft:age/smoke_void seed=13579 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_cave' 'rivencraft:age/smoke_cave seed=112233 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_flat_multi' 'rivencraft:age/smoke_flat_multi seed=24681 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_void_multi' 'rivencraft:age/smoke_void_multi seed=13580 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_cave_multi' 'rivencraft:age/smoke_cave_multi seed=112234 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_random' 'rivencraft:age/smoke_random seed=889900 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_rocks' 'rivencraft:age/smoke_rocks seed=445566 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_rocks_multi' 'rivencraft:age/smoke_rocks_multi seed=445566 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_rocks_random' 'rivencraft:age/smoke_rocks_random seed=445566 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_vegetation' 'rivencraft:age/smoke_vegetation seed=24680 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_dungeons' 'rivencraft:age/smoke_dungeons seed=135791 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age dungeons_probe smoke_dungeons' 'Dungeons probe rivencraft:age/smoke_dungeons pages=8 spawners='
dungeons_before=$(grep -F 'Dungeons probe rivencraft:age/smoke_dungeons pages=8 spawners=' "$log_file" | tail -1 | sed 's/.*spawners=//')
[[ "$dungeons_before" =~ ^[1-9][0-9]*$ ]] || {
printf 'Authored Dungeons placed no monster spawners: %s\n' "$dungeons_before" >&2
exit 1
}
send_and_wait 'rivencraft_port age lakes_probe smoke_lake_control' 'Lakes probe rivencraft:age/smoke_lake_control pages=0'
lake_control=$(grep -F 'Lakes probe rivencraft:age/smoke_lake_control pages=0' "$log_file" | tail -1 | sed 's/.* water=//; s/ lava=.*//')
send_and_wait 'rivencraft_port age lakes_probe smoke_lakes' 'Lakes probe rivencraft:age/smoke_lakes pages=24 fluids=[minecraft:water]'
lakes_before=$(grep -F 'Lakes probe rivencraft:age/smoke_lakes pages=24' "$log_file" | tail -1 | sed 's/.*Lakes probe /Lakes probe /')
lake_water=$(printf '%s' "$lakes_before" | sed 's/.* water=//; s/ lava=.*//')
[[ "$lake_water" -gt "$lake_control" ]] || {
printf 'Water lake pages did not add water: %s -> %s\n%s\n' "$lake_control" "$lake_water" "$lakes_before" >&2
exit 1
}
send_and_wait 'rivencraft_port age lakes_probe smoke_lakes_random' 'Lakes probe rivencraft:age/smoke_lakes_random pages=24'
lakes_random_before=$(grep -F 'Lakes probe rivencraft:age/smoke_lakes_random pages=24' "$log_file" | tail -1 | sed 's/.*Lakes probe /Lakes probe /')
[[ "$lakes_random_before" == *minecraft:water* && "$lakes_random_before" == *minecraft:lava* ]] || {
printf 'Unmodified lake pages did not resolve both vanilla fluids: %s\n' "$lakes_random_before" >&2
exit 1
}
declare -A islands_before
for island_age in smoke_islands smoke_islands_flat smoke_islands_noise smoke_islands_random; do
send_and_wait "rivencraft_port age islands_probe $island_age" "Islands probe rivencraft:age/$island_age pages=4"
island_result=$(grep -F "Islands probe rivencraft:age/$island_age pages=4" "$log_file" | tail -1 | sed 's/.*Islands probe /Islands probe /')
island_blocks=$(printf '%s' "$island_result" | sed 's/.* count=//; s/ fingerprint=.*//')
[[ "$island_blocks" =~ ^[1-9][0-9]*$ ]] || {
printf 'Floating Islands placed no selected material: %s\n' "$island_result" >&2
exit 1
}
islands_before[$island_age]=$island_result
done
island_materials=${islands_before[smoke_islands_random]#*materials=[}
island_materials=${island_materials%%]*}
IFS=',' read -r -a island_material_list <<< "$island_materials"
[[ ${#island_material_list[@]} -eq 4 ]] || {
printf 'Repeated unmodified islands did not choose independent materials: %s\n' "$island_materials" >&2
exit 1
}
send_and_wait 'execute in rivencraft:age/smoke_islands if block 0 0 0 minecraft:air run say Island Void remains empty below' 'Island Void remains empty below'
send_and_wait 'execute in rivencraft:age/smoke_islands_flat if block 0 0 0 minecraft:bedrock run say Island Flat retains its floor' 'Island Flat retains its floor'
send_and_wait 'rivencraft_port age biome smoke_islands 128 128' 'rivencraft:age/smoke_islands biome at 128,128=minecraft:forest'
send_and_wait 'execute in rivencraft:age/smoke_flat run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_flat to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_void run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_void to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_flat_multi run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_flat_multi to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_void_multi run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_void_multi to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_cave run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_cave to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_cave_multi run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_cave_multi to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_rocks run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_rocks to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_rocks_multi run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_rocks_multi to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_rocks_random run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_rocks_random to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_vegetation run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_vegetation to be force loaded'
send_and_wait 'rivencraft_port age vegetation_probe smoke_flat' 'Vegetation probe rivencraft:age/smoke_flat flowers='
flat_vegetation=$(grep -F 'Vegetation probe rivencraft:age/smoke_flat flowers=' "$log_file" | tail -1 | sed 's/.*flowers=//')
send_and_wait 'rivencraft_port age vegetation_probe smoke_vegetation' 'Vegetation probe rivencraft:age/smoke_vegetation flowers='
authored_vegetation=$(grep -F 'Vegetation probe rivencraft:age/smoke_vegetation flowers=' "$log_file" | tail -1 | sed 's/.*flowers=//')
flat_flowers=${flat_vegetation%% logs=*}
flat_logs=${flat_vegetation##* logs=}
authored_flowers=${authored_vegetation%% logs=*}
authored_logs=${authored_vegetation##* logs=}
[[ "$authored_flowers" -gt "$flat_flowers" && "$authored_logs" -gt "$flat_logs" ]] || {
printf 'Authored vegetation did not increase flowers and logs: %s -> %s\n' \
"$flat_vegetation" "$authored_vegetation" >&2
exit 1
}
send_and_wait 'rivencraft_port age rocks_probe smoke_rocks' 'Rocks probe rivencraft:age/smoke_rocks materials=[minecraft:gold_block] count='
rocks_before=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks materials=[minecraft:gold_block] count=' "$log_file" | tail -1 | sed 's/.*count=//')
[[ "$rocks_before" =~ ^[1-9][0-9]*$ ]] || {
printf 'Authored Rocks placed no gold blocks: %s\n' "$rocks_before" >&2
exit 1
}
send_and_wait 'rivencraft_port age rocks_probe smoke_rocks_multi' 'Rocks probe rivencraft:age/smoke_rocks_multi materials=[minecraft:gold_block, minecraft:gold_block, minecraft:gold_block, minecraft:gold_block] count='
rocks_multi_before=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks_multi materials=' "$log_file" | tail -1 | sed 's/.*count=//')
[[ "$rocks_multi_before" =~ ^[1-9][0-9]*$ && "$rocks_multi_before" -gt "$rocks_before" ]] || {
printf 'Repeated Rocks pages did not increase placement: %s -> %s\n' "$rocks_before" "$rocks_multi_before" >&2
exit 1
}
send_and_wait 'rivencraft_port age rocks_probe smoke_rocks_random' 'Rocks probe rivencraft:age/smoke_rocks_random materials=['
rocks_random_before=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks_random materials=' "$log_file" | tail -1 | sed 's/.*Rocks probe /Rocks probe /')
[[ "$rocks_random_before" =~ count=[1-9][0-9]*$ ]] || {
printf 'Unmodified Rocks page placed no material: %s\n' "$rocks_random_before" >&2
exit 1
}
send_and_wait 'execute in rivencraft:age/smoke_flat if block 0 0 0 minecraft:bedrock run say Flat bedrock starts at Y0' 'Flat bedrock starts at Y0'
send_and_wait 'execute in rivencraft:age/smoke_flat if block 0 3 0 minecraft:grass_block run say Flat surface is Y3' 'Flat surface is Y3'
send_and_wait 'execute in rivencraft:age/smoke_void if block 0 0 0 minecraft:air run say Void remains empty' 'Void remains empty'
send_and_wait 'execute in rivencraft:age/smoke_flat_multi if block 0 0 0 minecraft:bedrock run say Multi Flat bedrock starts at Y0' 'Multi Flat bedrock starts at Y0'
send_and_wait 'execute in rivencraft:age/smoke_void_multi if block 0 0 0 minecraft:air run say Multi Void remains empty' 'Multi Void remains empty'
# Check region interiors after loading the sampled chunks.
send_and_wait 'rivencraft_port age biome smoke_flat_multi 128 128' 'rivencraft:age/smoke_flat_multi biome at 128,128=minecraft:plains'
send_and_wait 'rivencraft_port age biome smoke_flat_multi 384 128' 'rivencraft:age/smoke_flat_multi biome at 384,128=minecraft:forest'
send_and_wait 'rivencraft_port age biome smoke_void_multi 384 128' 'rivencraft:age/smoke_void_multi biome at 384,128=minecraft:forest'
send_and_wait 'rivencraft_port age biome smoke_cave_multi 128 128' 'rivencraft:age/smoke_cave_multi biome at 128,128=minecraft:lush_caves'
send_and_wait 'rivencraft_port age biome smoke_cave_multi 384 128' 'rivencraft:age/smoke_cave_multi biome at 384,128=minecraft:dripstone_caves'
alpha_before=$(grep -F 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
beta_before=$(grep -F 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
flat_before=$(grep -F 'rivencraft:age/smoke_flat seed=24680 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
void_before=$(grep -F 'rivencraft:age/smoke_void seed=13579 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
random_before=$(grep -F 'rivencraft:age/smoke_random seed=889900 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
if [[ "$alpha_before" == "$beta_before" ]]; then
printf 'Different Age seeds produced the same terrain probe: %s\n' "$alpha_before" >&2
exit 1
fi
if [[ "$flat_before" == "$void_before" ]]; then
printf 'Flat and Void Ages produced the same height probe: %s\n' "$flat_before" >&2
exit 1
fi
stop_server
start_server restart
send_and_wait 'rivencraft_port age list' 'smoke_alpha'
send_and_wait 'rivencraft_port age sample smoke_alpha' 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_beta' 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_flat' 'rivencraft:age/smoke_flat seed=24680 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_void' 'rivencraft:age/smoke_void seed=13579 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_cave' 'rivencraft:age/smoke_cave seed=112233 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_flat_multi' 'rivencraft:age/smoke_flat_multi seed=24681 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_void_multi' 'rivencraft:age/smoke_void_multi seed=13580 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_cave_multi' 'rivencraft:age/smoke_cave_multi seed=112234 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_random' 'rivencraft:age/smoke_random seed=889900 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_rocks' 'rivencraft:age/smoke_rocks seed=445566 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_rocks_multi' 'rivencraft:age/smoke_rocks_multi seed=445566 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_rocks_random' 'rivencraft:age/smoke_rocks_random seed=445566 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_vegetation' 'rivencraft:age/smoke_vegetation seed=24680 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_dungeons' 'rivencraft:age/smoke_dungeons seed=135791 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age dungeons_probe smoke_dungeons' 'Dungeons probe rivencraft:age/smoke_dungeons pages=8 spawners='
dungeons_after=$(grep -F 'Dungeons probe rivencraft:age/smoke_dungeons pages=8 spawners=' "$log_file" | tail -1 | sed 's/.*spawners=//')
send_and_wait 'rivencraft_port age lakes_probe smoke_lakes' 'Lakes probe rivencraft:age/smoke_lakes pages=24 fluids=[minecraft:water]'
lakes_after=$(grep -F 'Lakes probe rivencraft:age/smoke_lakes pages=24' "$log_file" | tail -1 | sed 's/.*Lakes probe /Lakes probe /')
send_and_wait 'rivencraft_port age lakes_probe smoke_lakes_random' 'Lakes probe rivencraft:age/smoke_lakes_random pages=24'
lakes_random_after=$(grep -F 'Lakes probe rivencraft:age/smoke_lakes_random pages=24' "$log_file" | tail -1 | sed 's/.*Lakes probe /Lakes probe /')
for island_age in smoke_islands smoke_islands_flat smoke_islands_noise smoke_islands_random; do
send_and_wait "rivencraft_port age islands_probe $island_age" "Islands probe rivencraft:age/$island_age pages=4"
island_result=$(grep -F "Islands probe rivencraft:age/$island_age pages=4" "$log_file" | tail -1 | sed 's/.*Islands probe /Islands probe /')
[[ "${islands_before[$island_age]}" == "$island_result" ]] || {
printf 'Floating Islands changed across restart: %s\n%s\n' "${islands_before[$island_age]}" "$island_result" >&2
exit 1
}
done
send_and_wait 'rivencraft_port age rocks_probe smoke_rocks' 'Rocks probe rivencraft:age/smoke_rocks materials=[minecraft:gold_block] count='
rocks_after=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks materials=[minecraft:gold_block] count=' "$log_file" | tail -1 | sed 's/.*count=//')
send_and_wait 'rivencraft_port age rocks_probe smoke_rocks_multi' 'Rocks probe rivencraft:age/smoke_rocks_multi materials=['
rocks_multi_after=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks_multi materials=' "$log_file" | tail -1 | sed 's/.*count=//')
send_and_wait 'rivencraft_port age rocks_probe smoke_rocks_random' 'Rocks probe rivencraft:age/smoke_rocks_random materials=['
rocks_random_after=$(grep -F 'Rocks probe rivencraft:age/smoke_rocks_random materials=' "$log_file" | tail -1 | sed 's/.*Rocks probe /Rocks probe /')
send_and_wait 'rivencraft_port age vegetation_probe smoke_vegetation' 'Vegetation probe rivencraft:age/smoke_vegetation flowers='
authored_vegetation_after=$(grep -F 'Vegetation probe rivencraft:age/smoke_vegetation flowers=' "$log_file" | tail -1 | sed 's/.*flowers=//')
send_and_wait 'rivencraft_port age biome smoke_flat_multi 384 128' 'rivencraft:age/smoke_flat_multi biome at 384,128=minecraft:forest'
send_and_wait 'rivencraft_port age biome smoke_void_multi 384 128' 'rivencraft:age/smoke_void_multi biome at 384,128=minecraft:forest'
send_and_wait 'rivencraft_port age biome smoke_cave_multi 384 128' 'rivencraft:age/smoke_cave_multi biome at 384,128=minecraft:dripstone_caves'
alpha_after=$(grep -F 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
beta_after=$(grep -F 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
flat_after=$(grep -F 'rivencraft:age/smoke_flat seed=24680 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
void_after=$(grep -F 'rivencraft:age/smoke_void seed=13579 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
random_after=$(grep -F 'rivencraft:age/smoke_random seed=889900 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
[[ "$alpha_before" == "$alpha_after" && "$beta_before" == "$beta_after" ]] || {
printf 'Age terrain changed across restart: %s/%s -> %s/%s\n' \
"$alpha_before" "$beta_before" "$alpha_after" "$beta_after" >&2
exit 1
}
[[ "$flat_before" == "$flat_after" && "$void_before" == "$void_after" ]] || {
printf 'Flat/Void terrain changed across restart: %s/%s -> %s/%s\n' \
"$flat_before" "$void_before" "$flat_after" "$void_after" >&2
exit 1
}
[[ "$random_before" == "$random_after" ]] || {
printf 'Random Age terrain changed across restart: %s -> %s\n' \
"$random_before" "$random_after" >&2
exit 1
}
[[ "$rocks_before" == "$rocks_after" ]] || {
printf 'Authored Rocks changed across restart: %s -> %s\n' \
"$rocks_before" "$rocks_after" >&2
exit 1
}
[[ "$rocks_multi_before" == "$rocks_multi_after" && "$rocks_random_before" == "$rocks_random_after" ]] || {
printf 'Repeated or unmodified Rocks changed across restart\n' >&2
exit 1
}
[[ "$authored_vegetation" == "$authored_vegetation_after" ]] || {
printf 'Authored vegetation changed across restart: %s -> %s\n' \
"$authored_vegetation" "$authored_vegetation_after" >&2
exit 1
}
[[ "$dungeons_before" == "$dungeons_after" ]] || {
printf 'Authored Dungeons changed across restart: %s -> %s\n' \
"$dungeons_before" "$dungeons_after" >&2
exit 1
}
[[ "$lakes_before" == "$lakes_after" && "$lakes_random_before" == "$lakes_random_after" ]] || {
printf 'Explicit or random lake plans/blocks changed across restart\n' >&2
exit 1
}
stop_server
printf '26.3 dedicated-server smoke passed: terrain, biomes, Rocks, vegetation, Dungeons, lakes, and Floating Islands persisted across restart.\n'
exec python3 "$repo_dir/scripts/smoke_server.py" --target mc263
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Offline checks for track selection, release identity, dependency metadata and server exclusion."""
import argparse
from pathlib import Path
import tempfile
import unittest
import zipfile
from changed_targets import affected
from publish_mod_sites import release_identity, release_metadata, verify_artifact
from server_test_lock import server_test_lock
class WorkflowChecks(unittest.TestCase):
def test_changes_stay_on_their_track(self):
self.assertEqual(affected(["src/main/java/net/dnorberg/rivencraft/Rivencraft.java"]), ["mc1211"])
self.assertEqual(affected(["ports/mc263/src/main/java/Example.java"]), ["mc263"])
self.assertEqual(affected(["src/main/resources/assets/rivencraft/sounds.json"]), ["mc1211", "mc263"])
self.assertEqual(affected(["README.md", "scripts/publish_mod_sites.py"]), [])
def test_release_identity_rejects_mislabeled_channels(self):
self.assertEqual(release_identity("26.3.0.3", "26.3", "release"),
("V26.3.0.3", "rivencraft-26.3.0.3.jar"))
self.assertEqual(release_identity("1.21.1.0.1", "1.21.1", "release"),
("V1.21.1.0.1", "rivencraft-1.21.1.0.1.jar"))
for version, channel in [("26.3.0.3-beta.1", "release"), ("26.3.0.3", "beta")]:
with self.assertRaises(RuntimeError):
release_identity(version, "26.3", channel)
def test_required_dependencies_follow_minecraft(self):
with tempfile.TemporaryDirectory() as work:
notes = Path(work) / "notes.md"
notes.write_text("Release notes")
args = argparse.Namespace(changelog=notes, name="Rivencraft", version="1.21.1.0.1",
minecraft="1.21.1", java="21", channel="release")
mr, cf = release_metadata(args)
self.assertEqual(len(mr["dependencies"]), 2)
self.assertTrue(all(d["dependency_type"] == "required" for d in mr["dependencies"]))
self.assertEqual(len(cf["relations"]["projects"]), 2)
args.minecraft, args.java, args.version = "26.3", "25", "26.3.0.3"
mr, cf = release_metadata(args)
self.assertEqual(mr["dependencies"], [])
self.assertNotIn("relations", cf)
def test_wrong_track_and_fixture_jars_are_rejected(self):
with tempfile.TemporaryDirectory() as work:
jar = Path(work) / "rivencraft-26.3.0.3.jar"
metadata = (
'[[mods]]\nmodId="rivencraft"\nversion="26.3.0.3"\n'
'[[dependencies.rivencraft]]\nmodId="minecraft"\nversionRange="[26.3]"\n'
)
with zipfile.ZipFile(jar, "w") as archive:
archive.writestr("META-INF/neoforge.mods.toml", metadata)
verify_artifact(jar, "26.3.0.3", "26.3", "25", "release")
with self.assertRaises(RuntimeError):
verify_artifact(jar, "26.3.0.3", "26.3", "21", "release")
with self.assertRaises(RuntimeError):
verify_artifact(jar, "1.21.1.0.1", "1.21.1", "21", "release")
with zipfile.ZipFile(jar, "a") as archive:
archive.writestr("net/dnorberg/rivencraft/regression/Fixture.class", b"test")
with self.assertRaises(RuntimeError):
verify_artifact(jar, "26.3.0.3", "26.3", "25", "release")
def test_server_lock_refuses_overlap_and_releases(self):
with server_test_lock():
with self.assertRaises(SystemExit):
with server_test_lock():
self.fail("overlapping server admitted")
with server_test_lock():
pass
if __name__ == "__main__":
unittest.main()