Complete 26.3 parity acceptance and dual-version release workflow

This commit is contained in:
dnorberg committed 2026-11-14 12:17:49 -05:00
1 parent 41daff1e61
commit 00344a1c8a
26 files changed
+1241 -369

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
+7
View File
@@ -25,6 +25,13 @@ 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.
+54 -3
View File
@@ -43,11 +43,62 @@ JARs; use new worlds and remove them regardless of pass/fail. Stop test processe
If generation stalls, hand the test to the user.
Before closing a port umbrella issue, compare its acceptance criteria and comments
to actual evidence. Current expanded-catalog two-client create/travel/reconnect/
restart/stale-ack regression remains in #100. #103 retains dual-line release
automation/regression completion and #104 retains the full parity gate.
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.
+12
View File
@@ -8,6 +8,18 @@ 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
[26.3.0.1](../ports/mc263/RELEASE_NOTES_26.3.0.1.md) collects the player-validated
+9 -7
View File
@@ -1,6 +1,6 @@
# Minecraft 26.3 beta port workspace
# Minecraft 26.3
The current **Minecraft 26.3 release is 26.3.0.1**, for NeoForge 26.3.0.99 and
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,
@@ -16,11 +16,13 @@ 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).
This regular release is player-validated but does not claim complete 1.21.1 parity.
Issues #100, #103 and #104 retain the final multiplayer/parity/release-workflow
checks. Preview exploration controls and Create/Ponder scenes remain unported.
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.1.md).
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.
+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.
+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=26.3.0.1
mod_version=26.3.0.3
jei_version=31.17.0.109
@@ -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);
}
}
}
@@ -18,6 +18,8 @@ public final class PortBookPreview {
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();
@@ -46,12 +48,16 @@ public final class PortBookPreview {
}
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;
@@ -27,6 +27,7 @@ public final class PortDescriptiveBookScreen extends Screen {
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;
@@ -48,7 +49,11 @@ public final class PortDescriptiveBookScreen extends Screen {
@Override
protected void init() {
if (bound) { linkPanel.open(hand, book, standSource); 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;
@@ -183,9 +188,10 @@ public final class PortDescriptiveBookScreen extends Screen {
@Override public void tick() {
if (bound) linkPanel.tick();
exploration.tick(pageIndex == 0);
}
@Override public void removed() { linkPanel.close(); super.removed(); }
@Override public void removed() { if (!exploration.active()) { exploration.stop(); linkPanel.close(); } super.removed(); }
@Override
public boolean isPauseScreen() {
@@ -19,7 +19,9 @@ final class PortLegacyLinkPanel {
net.dnorberg.rivencraft.network.UseBookStandPayload stand) {
if (preview == null) preview = new PortBookPreview(hand, book, stand);
}
void close() { if (preview != null) preview.close(); }
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;
@@ -30,6 +30,7 @@ public final class PortLinkingBookScreen extends Screen {
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;
@@ -53,7 +54,11 @@ public final class PortLinkingBookScreen extends Screen {
@Override
protected void init() {
if (bound) { linkPanel.open(hand, book, standSource); 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;
@@ -148,9 +153,10 @@ public final class PortLinkingBookScreen extends Screen {
@Override public void tick() {
if (bound) linkPanel.tick();
exploration.tick(pageIndex == 0);
}
@Override public void removed() { linkPanel.close(); super.removed(); }
@Override public void removed() { if (!exploration.active()) { exploration.stop(); linkPanel.close(); } super.removed(); }
@Override
public boolean isPauseScreen() {
@@ -26,7 +26,28 @@ public final class RebornLiveBookPanel implements PortBookPreview.LivePanel {
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;
@@ -40,16 +61,25 @@ public final class RebornLiveBookPanel implements PortBookPreview.LivePanel {
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 = new Matrix4f().rotateX((float) Math.toRadians(20))
.rotateY((float) angle);
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();
// Submit first: Reborn may resize the target, invalidating its old texture view.
// GPU clearing happens in beginRender(), after extraction but before remote rendering.
GuiPortalRendering.submitNextFrameRendering(info, target);
queued = this;
// 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);
@@ -75,5 +105,5 @@ public final class RebornLiveBookPanel implements PortBookPreview.LivePanel {
camera.update(minecraft.getDeltaTracker());
}
}
@Override public void close() { closed = true; destination = null; }
@Override public void close() { exploring(false); closed = true; destination = null; }
}
@@ -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();
}
}
}
+35 -6
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,6 +192,27 @@ 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 + "."):
@@ -271,11 +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")
_, expected_filename = release_identity(args.version, args.minecraft, args.channel)
if args.jar.name != expected_filename:
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()