Compare commits

..
7 Commits
Author SHA1 Message Date
dnorberg ee905cab68 Add Legacy missing items and OP Link Modifier (#90)
Build / build (push) Canceled after 0s
2026-10-20 17:50:09 -04:00
dnorberg 5c08f391a2 Update optional Serene Seasons compatibility to 10.1.0.9
Build / build (push) Canceled after 0s
2026-10-20 17:33:07 -04:00
dnorberg b8d26a9036 Update optional JEI compatibility to 19.69.0.457
Build / build (push) Canceled after 0s
2026-10-20 17:12:21 -04:00
dnorberg d0af75239a Match Legacy handheld book drops with model entity (#85)
Build / build (push) Canceled after 0s
2026-10-19 16:41:07 -04:00
dnorberg 2e5d6e0427 Adopt NeoForge 21.1.264 after compatibility testing
Build / build (push) Canceled after 0s
2026-10-19 05:37:46 -04:00
dnorberg 54c10cca6c Default automatic Return Links to disabled and review compatibility
Build / build (push) Canceled after 0s
2026-10-19 05:21:00 -04:00
dnorberg c39ebd78ba Add Milestone 7 progression advancements
Build / build (push) Canceled after 0s
2026-10-19 04:57:15 -04:00
79 changed files with 1842 additions and 65 deletions

No files matched your search

+2 -2
View File
@@ -198,7 +198,7 @@ Linking creates a bound Return Link to the exact source position and orientation
according to the configured Return Link policy.
Server owners can set `descriptive_books.return_link_mode` in Rivencraft's
server config to `ON_CREATION` (default), `ALWAYS`, or `DISABLED`.
server config to `DISABLED` (default), `ON_CREATION`, or `ALWAYS`.
Age creation, persistence, lifecycle commands, configuration, backups, and
administrator recovery are documented in [docs/AGES.md](docs/AGES.md).
Data-pack runtime profiles are documented in
@@ -208,7 +208,7 @@ Data-pack runtime profiles are documented in
- A Java 21 JDK
- Minecraft 1.21.1
- NeoForge 21.1.251 or newer in the 21.1 line
- NeoForge 21.1.264 or newer in the 21.1 line
- Dynamic Dimensions AR Edition 0.9.1 and Bad Packets 0.8.2 (client and server)
Create is optional. When installed, Rivencraft uses its Ponder renderer for
+2
View File
@@ -132,6 +132,8 @@ neoForge {
server {
server()
// Keep dedicated compatibility runs away from the normal dev world.
gameDirectory = project.file(project.findProperty('serverRunDir') ?: 'run')
programArgument '--nogui'
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

+2 -3
View File
@@ -69,10 +69,9 @@ definition.
Rivencraft writes gameplay settings to the world/server configuration managed
by NeoForge. `descriptive_books.return_link_mode` accepts:
- `ON_CREATION` (default): create one Return Link the first time a player
enters each Age.
- `DISABLED` (default): never create Return Links automatically.
- `ON_CREATION`: create one Return Link the first time a player enters each Age.
- `ALWAYS`: create a Return Link after every successful Descriptive Book trip.
- `DISABLED`: never create Return Links automatically.
Each client has a separate `rivencraft-client.toml`. Its `book_preview.mode`
setting accepts `AUTO`, `IMMERSIVE_PORTALS`, `PONDER`, or `LEGACY`. This changes
+1 -1
View File
@@ -46,7 +46,7 @@ For a manual check, create a fresh surface Age and run `/rivencraft library near
Issue #75's comment calls this a despawning drop. The code differs subtly: [`EntityLinkbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/entity/EntityLinkbook.java#L190-L224) explicitly cannot despawn, but takes one starvation damage every 10,000 ticks and damage while wet. It can eventually be destroyed if left unattended; a vanilla item entity's fixed five-minute timeout is not parity. A modern equivalent should retain the visible book and avoid silently deleting its Age identity at that timeout.
Legacy's travel path creates no automatic Return Link. Rivencraft currently defaults to `ON_CREATION` via `DescriptiveBookItem` and `descriptive_books.return_link_mode`. Per #75's comment, change the **new-config default** to `DISABLED` while retaining `ON_CREATION` and `ALWAYS` as opt-ins. Existing saved server configs must keep their explicit value. Handheld-book drop and Return Link default are tracked in [#85](https://truenas-scale.tail180fb1.ts.net:8443/dnorberg/rivencraft/issues/85), not portal-frame logic.
Legacy's travel path creates no automatic Return Link. Rivencraft now defaults to `DISABLED` in new server configs while retaining `ON_CREATION` and `ALWAYS` as opt-ins. Existing saved server configs keep their explicit value. Issue #85 adds the active-book drop only to hand-held Linking and Descriptive Book travel. On a successful link, the exact active stack becomes a dedicated `DroppedBookEntity` at the departure point, rendered with the same open `BookModel` and textures as the book stand; a rejected link restores it to the original hand. Stand and portal use leave their books in place. The entity has unlimited vanilla lifetime and loses one of its five item-health points every 10,000 loaded ticks, plus one per wet tick, matching Legacy's slow/wet decay. Unlike Legacy's custom entity, the modern book is picked up by ordinary contact, not sneaking with an empty hand, and remains subject to ordinary item hazards. Following is not yet a Rivencraft modifier, so no Following exception is exposed or silently simulated. Both hands can activate a book, whereas Legacy's explicit stack removal tested only its selected main-hand slot.
## Crafting, sounds, and progression
+40
View File
@@ -0,0 +1,40 @@
# Milestone 7 advancements and discovery (#81)
Rivencraft's advancement tab follows the visible progression in Mystcraft
Legacy's `assets/mystcraft/advancements/`: Page, unwritten Linking Book,
Writing Desk writing, Age Book, and safe Age travel. Titles such as *The Ending
Has Not Yet Been Written*, *D'ni Ink*, and *The Art of Writing* reference Myst
without requiring any assets from that game. The Legacy hidden Quinn trigger
has no established Rivencraft action and is deliberately omitted.
`root`, `page`, and `linking_book` use vanilla server-side tick/inventory
criteria. `link_panel`, `copy_page`, `bind_age`, `open_portal`, and `enter_age`
use an impossible criterion awarded only after the server commits the
corresponding action. The Age-travel award requires a successful book or portal
link ending in a registered Rivencraft Age. Opening a book GUI, rendering a
destination preview, viewing a recipe, or merely loading an Age cannot award
it. Progress is stored by Minecraft per player and survives world restarts.
Vanilla recipe-book unlock advancements now cover the Writing Desk, Ink Mixer,
Book Binder, and Notebook, in addition to the existing Ink Vial, Crystal
Block, and Book Receptacle unlocks. The component-sensitive Linking Book
crafting recipe remains a special recipe: its Link Panel requirement and the
Writing Desk/Ink Mixer/Book Binder processes are explained by Rivencraft's
optional JEI integration. No recipe or advancement requires JEI, Create, or
Immersive Portals to be installed.
Manual Survival check: collect a Page and Linking Book; use the Ink Mixer to
take a Link Panel; copy a Page at the Writing Desk; bind a Descriptive Book;
enter its Age; activate a Crystal Portal. Confirm each advancement appears
only after the completed action, not from an output preview or GUI opening.
Repeat the actions with two players to confirm only the actor receives each
award. Restart an existing save and confirm completed progress remains, then
complete one previously unearned milestone. With JEI absent, the vanilla
recipe book should still reveal standard crafting recipes as their ingredients
are discovered; with JEI installed, the special writing guides should appear.
The automated Book Binder output-award scenario stalls in the GameTest storage
runner, so it is intentionally omitted under the project test policy. For its
manual check, open a ready Binder, observe that merely previewing the finished
book does not award *A Book of Ages*, then take the book and confirm the award.
Repeat by shift-clicking the result in a fresh Binder.
+53
View File
@@ -0,0 +1,53 @@
# Milestone 7 compatibility review (#82)
Reviewed 2026-10-19 for Minecraft 1.21.1. The project now compiles against
NeoForge 21.1.264 and advertises that as its minimum. The official
[NeoForge Maven metadata](https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml)
lists 21.1.264 as the newest 21.1 release. Its
[changelog](https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.264/neoforge-21.1.264-changelog.txt)
includes received split-packet size checks (21.1.260), network setup payload
direction/decoding limits (21.1.261), ad-hoc channel identifier limits
(21.1.262), and an initial attachment-sync fix (21.1.264). Those are concrete
reasons to evaluate an upgrade, but do not by themselves prove Rivencraft and
its optional integrations work on that loader.
**Decision: adopt 21.1.264 beginning with 0.6.6.2.** An override build
succeeded, but its GameTest server repeated its storage-progress display
without producing test results. That run was stopped; it is neither a pass nor
evidence of a NeoForge defect. The player subsequently passed an in-game
singleplayer test on 21.1.264 with the optional stack, then passed a dedicated
server test including Immersive Portals travel to a Rivencraft Age. The
dedicated server also reached the ready state with only the required mods
before the full optional stack was installed. Both modes used Rivencraft
0.6.6.1. The earlier 21.1.251 baseline passed 70 required GameTests for
release V0.6.6.0.
| Dependency | Project baseline | Newest checked 1.21.1 release | Follow-up |
| --- | --- | --- | --- |
| Dynamic Dimensions AR Edition (required) | 0.9.1 | 0.9.1 | No newer release found on [CurseForge](https://www.curseforge.com/minecraft/mc-mods/dynamicdimension-ar-edition/files/all). |
| Bad Packets (required) | neo-0.8.2 | neo-0.8.2 | No newer matching [Modrinth](https://modrinth.com/mod/badpackets/versions) release found. |
| JEI (optional) | 19.57.0.449 at #82 review | 19.69.0.457 beta | Updated in #86 after client and dedicated GameTest passes; see [JEI review](MILESTONE_7_JEI_REVIEW.md). |
| Create (optional) | 6.0.10 | 6.0.10 | No newer matching [Modrinth](https://modrinth.com/mod/create/versions) release found. Its jar bundles Ponder 1.0.82. |
| Ponder (bundled with Create) | 1.0.82 | 1.0.87 library artifact | [Maven](https://maven.createmod.net/net/createmod/ponder/ponder-neoforge/maven-metadata.xml) has a newer artifact, but Create 6.0.10 still bundles 1.0.82. Do not treat this as a separately installed Create update. |
| Immersive Portals (optional) | 6.0.7 | 6.0.7 | No newer matching [Modrinth](https://modrinth.com/mod/immersive-portals-neoforge/versions) release found. |
| TerraBlender (optional) | 4.1.0.8 | 4.1.0.8 | No newer matching [Modrinth](https://modrinth.com/mod/terrablender/versions) release found. |
| Serene Seasons (optional) | 10.1.0.8 at #82 review | 10.1.0.9 | Updated in #88 after the player's full seasonal matrix passed; see [Serene review](MILESTONE_7_SERENE_REVIEW.md). |
| GlitchCore (Serene dependency) | 2.1.0.2 | 2.1.0.2 | No newer matching [Modrinth](https://modrinth.com/mod/glitchcore/versions) release found. |
| Biomes O' Plenty (TerraBlender test addon) | 21.1.0.14 in user's Prism instance | 21.1.0.14 | No newer matching [Modrinth](https://modrinth.com/mod/biomes-o-plenty/versions) release found. |
The Modrinth version comparisons above were filtered to **NeoForge and
Minecraft 1.21.1** on 2026-10-19. Create's bundled Ponder version was read
from `META-INF/jarjar/metadata.json` in `create-1.21.1-6.0.10.jar`; the
separate library-artifact comparison came from Create's Maven. Dynamic
Dimensions was checked on CurseForge. Versions for other Minecraft lines and
similarly named mods were excluded.
The full dedicated test stack contained Create/Ponder, Immersive Portals, JEI,
TerraBlender with Biomes O' Plenty, and Serene Seasons with GlitchCore. The
server loaded a new `rivencraft:age/test`, and its shutdown log confirms that
all Overworld, Nether, End, and Age chunks were saved. The player explicitly
approved 21.1.264 after that test. This does not independently certify every
optional combination, the LEGACY preview fallback on a client without Create
and Immersive Portals, or a full restart/rejoin cycle; keep those in the
regression checklist. Serene's GameTest mock-player network limitation still
requires real-client checks for seasonal behavior.
+41
View File
@@ -0,0 +1,41 @@
# JEI compatibility review (#86)
Reviewed 2026-10-20 for Minecraft 1.21.1 and NeoForge 21.1.264. Rivencraft
now targets JEI 19.69.0.457 after the client's in-game acceptance check. JEI
remains a `compileOnly` API and an opt-in
`localRuntime` dependency for development; the published mod has no required
JEI dependency.
The [official 1.21.1 NeoForge files](https://www.curseforge.com/minecraft/mc-mods/jei/files/all?page=1&pageSize=20&version=1.21.1)
list 19.69.0.457 (beta, 2026-10-20) after 19.64.0.455 (release, 2026-10-16).
The [19.69.0.457 Modrinth changelog](https://modrinth.com/mod/jei/versions?g=1.21.1&l=neoforge)
has a published-jar bytecode compatibility check. Its preceding 19.69.0.456
build changes ingredient search to Lucene and adds a runtime ingredient-search
refresh API. This makes page search a required client check, even though the
current Rivencraft plugin still compiles against the 457 API. The new Maven
runtime artifact is much larger (about 7 MB versus 2.3 MB for 455); a successful
compile alone is not evidence that its runtime plugin works.
Checks performed before changing the pinned dependency:
| Configuration | Result |
| --- | --- |
| `./gradlew -Pjei_version=19.69.0.457 -PwithJei=true build` | Passed; existing JEI API uses compile with four deprecation warnings and no errors. |
| `./gradlew -Pjei_version=19.69.0.457 -PwithJei=true runGameTestServer` | Passed; dedicated GameTest server loaded JEI 19.69.0.457 and all 74 required tests passed. |
| `./gradlew -Pjei_version=19.69.0.457 runGameTestServer` | Passed without JEI; all 74 required tests passed. |
| Prism client with JEI 19.69.0.457 and Rivencraft 0.6.6.6 | Client joined an integrated world; JEI registered Rivencraft extra ingredients and started without a JEI-plugin exception. The player reported an in-game pass for guide display, page search, and recipe visibility. |
On first 457 launch, JEI migrated its old `lowMemorySlowSearchEnabled` config
key and saved a backup of the original `jei-client.ini`; that warning was
handled by JEI and did not block startup. No Rivencraft config was changed.
The in-game acceptance report covers the visible JEI integration. A dedicated
GameTest server proves no server-side startup failure but does not exercise the
JEI GUI; keep page search, recipes, blank-Page hiding, and the Ink Mixer,
Writing Desk, and Book Binder guides in future upgrade checks. JEI is not
bundled into or required by the published Rivencraft jar.
The final pinned `0.6.6.7` build passed. A repeat GameTest run from the pinned
configuration stalled during test-world preparation before running any cases;
it was stopped. The two successful pre-pin runs above used the same JEI version
and code, with the dependency supplied by a Gradle property override.
+31
View File
@@ -0,0 +1,31 @@
# Serene Seasons 10.1.0.9 compatibility review (#88)
Reviewed 2026-10-20 for Minecraft 1.21.1 and NeoForge 21.1.264. Rivencraft
now compiles against [Serene Seasons 10.1.0.9](https://modrinth.com/mod/serene-seasons/version/pHEgQQUE)
with [GlitchCore 2.1.0.2](https://modrinth.com/mod/glitchcore/version/S2TfWrZR).
Both remain optional. The Serene update fixes a precipitation-mixin recursion;
it does not introduce a new dynamic-dimension API.
The 10.1.0.8 and 10.1.0.9 bytecode for Rivencraft's version-sensitive
`SeasonsConfig`, `SeasonHandler`, `SeasonSavedData`, and `SeasonTime` integration
classes is identical. In particular, the dimension whitelist field, per-level
saved season clock, and season update method have unchanged signatures and
implementations. The adapter still writes the whitelist through GlitchCore's
config API and sends Rivencraft's whitelist update to already connected clients.
Verification:
| Check | Result |
| --- | --- |
| Build against 10.1.0.9 and GlitchCore 2.1.0.2 | Passed. |
| Real Prism client with Serene 10.1.0.9, GlitchCore 2.1.0.2, and Rivencraft 0.6.6.7 | Both mods loaded and the player joined an Age. |
| No Seasons, normal Seasons, and all four Eternal modes in new and saved Ages | Player reported the full in-game matrix passed. |
| Reconnect, restart, and Overworld independence | Player reported the full in-game matrix passed. |
| Isolated dedicated-server startup without Serene/GlitchCore | Reached `Done` on port 25568 with only required runtime mods, then saved and stopped. |
| Isolated dedicated-server startup with Serene/GlitchCore | Reached `Done` on port 25569 with 10.1.0.9/2.1.0.2, then saved and stopped. |
Serene's sync packet is incompatible with GameTest mock players, so its
in-game behavior must be checked with a real client and server. The seasonal
test path in [SERENE_SEASONS_COMPATIBILITY.md](SERENE_SEASONS_COMPATIBILITY.md)
remains the regression checklist for future upgrades. Rivencraft does not
bundle or require Serene Seasons or GlitchCore.
+1 -1
View File
@@ -45,7 +45,7 @@ being captured in the preview.
## Supported combinations and upstream limits
The compatibility baseline is Minecraft 1.21.1, NeoForge 21.1.251, Create
The compatibility baseline is Minecraft 1.21.1, NeoForge 21.1.264, Create
6.0.10, and Immersive Portals 6.0.7. Core Age generation and the `LEGACY`
backend run without Create or Immersive Portals; the headless GameTest server
uses that dependency-free path.
+4 -4
View File
@@ -18,11 +18,11 @@ minecraft_version=1.21.1
# as they do not follow standard versioning conventions.
minecraft_version_range=[1.21.1]
# The Neo version must agree with the Minecraft version to get a valid artifact
neo_version=21.1.251
neo_version=21.1.264
ponder_version=1.0.82
dynamic_dimensions_file_id=7707789
badpackets_version=0.8.2
jei_version=19.57.0.449
jei_version=19.69.0.457
# The loader version range can only use the major version of FML as bounds
loader_version_range=[1,)
@@ -36,10 +36,10 @@ mod_name=Rivencraft
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=LGPL-3.0-or-later
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
mod_version=0.6.5.2
mod_version=0.6.6.9
immersive_portals_version=6.0.7
terrablender_version=4.1.0.8
serene_seasons_version_id=B6nTjh4b
serene_seasons_version_id=pHEgQQUE
glitchcore_version_id=S2TfWrZR
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources.
@@ -54,6 +54,7 @@ public final class Rivencraft {
ModBiomeSources.register(modEventBus);
ModChunkGenerators.register(modEventBus);
ModBlocks.register(modEventBus);
net.dnorberg.rivencraft.registry.ModEntities.register(modEventBus);
ModBlockEntities.register(modEventBus);
ModItems.register(modEventBus);
ModMenus.register(modEventBus);
@@ -0,0 +1,30 @@
package net.dnorberg.rivencraft.advancement;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
/** Awards action-only progression after the server has committed the action. */
public final class ProgressionAdvancements {
public static final String LINK_PANEL = "link_panel";
public static final String COPY_PAGE = "copy_page";
public static final String BIND_AGE = "bind_age";
public static final String OPEN_PORTAL = "open_portal";
public static final String ENTER_AGE = "enter_age";
private ProgressionAdvancements() {}
public static void award(ServerPlayer player, String path) {
var advancement = player.getServer().getAdvancements().get(
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "progression/" + path));
if (advancement != null) player.getAdvancements().award(advancement, "completed");
}
public static void awardAgeTravel(ServerPlayer player) {
if (AgeRegistrySavedData.get(player.serverLevel()).findByDimension(
player.serverLevel().dimension().location()).isPresent()) {
award(player, ENTER_AGE);
}
}
}
@@ -1,5 +1,6 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
import net.dnorberg.rivencraft.portal.CrystalPortalNetwork;
@@ -117,6 +118,8 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
server, pos, plan, destination));
}
level.playSound(null, pos, SoundEvents.ENCHANTMENT_TABLE_USE, SoundSource.BLOCKS, 1, 1);
if (player instanceof ServerPlayer serverPlayer)
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.OPEN_PORTAL);
return ItemInteractionResult.SUCCESS;
}
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.block;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity;
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Containers;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.SimpleMenuProvider;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.DirectionProperty;
import net.minecraft.world.phys.BlockHitResult;
public final class LinkModifierBlock extends BaseEntityBlock {
public static final MapCodec<LinkModifierBlock> CODEC = simpleCodec(LinkModifierBlock::new);
public static final DirectionProperty FACING = BlockStateProperties.HORIZONTAL_FACING;
public LinkModifierBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH));
}
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
@Override protected void createBlockStateDefinition(StateDefinition.Builder<net.minecraft.world.level.block.Block,
BlockState> builder) { builder.add(FACING); }
@Override public BlockState getStateForPlacement(BlockPlaceContext context) {
return defaultBlockState().setValue(FACING, context.getHorizontalDirection().getOpposite());
}
@Override protected RenderShape getRenderShape(BlockState state) { return RenderShape.MODEL; }
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new LinkModifierBlockEntity(pos, state);
}
@Override protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
Player player, BlockHitResult hit) {
if (!level.isClientSide && player instanceof ServerPlayer serverPlayer
&& level.getBlockEntity(pos) instanceof LinkModifierBlockEntity modifier) {
serverPlayer.openMenu(new SimpleMenuProvider(
(id, inventory, ignored) -> new LinkModifierMenu(id, inventory, modifier),
Component.translatable("container.rivencraft.link_modifier")),
buffer -> buffer.writeBlockPos(pos));
}
return InteractionResult.sidedSuccess(level.isClientSide);
}
@Override protected void onRemove(BlockState state, Level level, BlockPos pos,
BlockState replacement, boolean moving) {
if (!state.is(replacement.getBlock()) && !level.isClientSide
&& level.getBlockEntity(pos) instanceof LinkModifierBlockEntity modifier) {
Containers.dropContents(level, pos, modifier);
}
super.onRemove(state, level, pos, replacement, moving);
}
}
@@ -12,6 +12,7 @@ import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.WritingGrammar;
@@ -96,7 +97,7 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
case COVER -> validCover(stack);
case LINK_PANEL -> stack.is(ModItems.PAGE.get())
&& PageItem.content(stack).kind() == PageContent.Kind.LINK_PANEL;
case NOTEBOOK -> stack.is(ModItems.NOTEBOOK.get());
case NOTEBOOK -> CollationFolderItem.isCollection(stack);
default -> slot >= FIRST_PAGE && slot < SIZE && validPage(stack);
};
}
@@ -296,7 +297,7 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
/** Import a Notebook from either its stored slot or the player's cursor. */
public ItemStack importNotebookFromStack(ItemStack notebook, int index) {
if (!notebook.is(ModItems.NOTEBOOK.get()) || notebook.getCount() != 1) return ItemStack.EMPTY;
if (!CollationFolderItem.isCollection(notebook) || notebook.getCount() != 1) return ItemStack.EMPTY;
List<ItemStack> pages = notebook.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
if (pages.isEmpty()) return ItemStack.EMPTY;
int end = nextPageIndex();
@@ -0,0 +1,63 @@
package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.NonNullList;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.Container;
import net.minecraft.world.ContainerHelper;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/** One persisted book slot; edits are performed by the server menu. */
public final class LinkModifierBlockEntity extends BlockEntity implements Container {
private NonNullList<ItemStack> items = NonNullList.withSize(1, ItemStack.EMPTY);
public LinkModifierBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.LINK_MODIFIER.get(), pos, state);
}
@Override public int getContainerSize() { return 1; }
@Override public boolean isEmpty() { return items.getFirst().isEmpty(); }
@Override public ItemStack getItem(int slot) { return items.get(slot); }
@Override public ItemStack removeItem(int slot, int amount) {
ItemStack removed = ContainerHelper.removeItem(items, slot, amount);
if (!removed.isEmpty()) setChanged();
return removed;
}
@Override public ItemStack removeItemNoUpdate(int slot) {
ItemStack removed = ContainerHelper.takeItem(items, slot);
if (!removed.isEmpty()) setChanged();
return removed;
}
@Override public void setItem(int slot, ItemStack stack) {
items.set(slot, stack.copyWithCount(Math.min(1, stack.getCount())));
setChanged();
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
return slot == 0 && (stack.is(ModItems.LINKING_BOOK.get())
|| stack.is(ModItems.DESCRIPTIVE_BOOK.get()));
}
@Override public boolean stillValid(Player player) {
return level != null && level.isLoaded(worldPosition) && level.getBlockEntity(worldPosition) == this
&& player.distanceToSqr(worldPosition.getX() + 0.5, worldPosition.getY() + 0.5,
worldPosition.getZ() + 0.5) <= 64.0;
}
@Override public void clearContent() {
items = NonNullList.withSize(1, ItemStack.EMPTY);
setChanged();
}
@Override protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.loadAdditional(tag, registries);
items = NonNullList.withSize(1, ItemStack.EMPTY);
ContainerHelper.loadAllItems(tag, items, registries);
}
@Override protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.saveAdditional(tag, registries);
ContainerHelper.saveAllItems(tag, items, registries);
}
}
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.block.entity;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.writing.InkVialExchange;
import net.dnorberg.rivencraft.writing.WritingInkService;
import net.dnorberg.rivencraft.writing.PageContent;
@@ -87,7 +88,7 @@ public final class WritingDeskBlockEntity extends BlockEntity implements Contain
worldPosition.getZ() + 0.5) <= 64.0;
}
@Override public boolean canPlaceItem(int slot, ItemStack stack) {
if (slot < COLLECTIONS) return stack.is(ModItems.NOTEBOOK.get())
if (slot < COLLECTIONS) return CollationFolderItem.isCollection(stack)
&& stack.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY)
.pages().stream().noneMatch(page -> PageItem.content(page).kind() == PageContent.Kind.LINK_PANEL)
|| stack.is(ModItems.PAGE.get())
@@ -0,0 +1,56 @@
package net.dnorberg.rivencraft.book;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.AtomicMoveNotSupportedException;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.storage.LevelResource;
/** Retires an exact Age and moves its unloaded region data out of the live world. */
public final class AgeDeletionService {
public enum Result { RETIRED, ALREADY_DEAD, OCCUPIED, UNLOAD_FAILED, STORAGE_FAILED }
private AgeDeletionService() { }
public static Result retire(MinecraftServer server, AgeDefinition age) {
AgeRegistrySavedData registry = AgeRegistrySavedData.get(server.overworld());
if (registry.isDead(age.id())) return Result.ALREADY_DEAD;
var level = server.getLevel(AgeDimensionService.dimensionKey(age));
if (level != null && !level.players().isEmpty()) return Result.OCCUPIED;
if (AgeDimensionService.unload(server, age) == AgeDimensionService.UnloadResult.FAILED)
return Result.UNLOAD_FAILED;
Path world = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
Path ageRoot = world.resolve("dimensions/rivencraft/age").normalize();
Path data = world.resolve("dimensions")
.resolve(age.dimensionId().getNamespace())
.resolve(age.dimensionId().getPath()).normalize();
// Exact Rivencraft Age only; never move the shared age/ directory.
if (!data.startsWith(ageRoot) || data.equals(ageRoot)
|| !"rivencraft".equals(age.dimensionId().getNamespace())
|| !age.dimensionId().getPath().startsWith("age/")) return Result.STORAGE_FAILED;
try {
if (Files.isSymbolicLink(world.resolve("dimensions"))
|| Files.isSymbolicLink(world.resolve("dimensions/rivencraft"))
|| Files.isSymbolicLink(ageRoot) || Files.isSymbolicLink(data)) return Result.STORAGE_FAILED;
if (Files.exists(data, LinkOption.NOFOLLOW_LINKS)) {
Path archive = world.getParent().resolve(world.getFileName() + "-retired-ages")
.resolve(age.id().toString());
if (Files.exists(archive, LinkOption.NOFOLLOW_LINKS)) return Result.STORAGE_FAILED;
Files.createDirectories(archive.getParent());
try { Files.move(data, archive, StandardCopyOption.ATOMIC_MOVE); }
catch (AtomicMoveNotSupportedException exception) { Files.move(data, archive); }
Rivencraft.LOGGER.warn("Retired Age {} data moved from {} to {}", age.id(), data, archive);
}
} catch (IOException exception) {
Rivencraft.LOGGER.error("Cannot archive data for Age {}", age.id(), exception);
return Result.STORAGE_FAILED;
}
registry.markDead(age.id());
return Result.RETIRED;
}
}
@@ -74,6 +74,7 @@ public final class AgeDimensionService {
DynamicDimensionLoadCallback.register((server, loader) -> {
ServerLevel overworld = server.overworld();
for (AgeDefinition age : AgeRegistrySavedData.get(overworld).definitions()) {
if (AgeRegistrySavedData.get(overworld).isDead(age.id())) continue;
if (!canLoadProfile(server, age)) continue;
ServerLevel level = loader.loadDynamicDimension(
age.dimensionId(),
@@ -96,6 +97,7 @@ public final class AgeDimensionService {
public static Optional<LinkTarget> resolve(ServerPlayer player, AgeReference reference,
OrderedWriting writing) {
AgeDefinition definition = AgeRegistrySavedData.resolveOrCreate(player.serverLevel(), reference, writing);
if (AgeRegistrySavedData.get(player.serverLevel()).isDead(definition.id())) return Optional.empty();
ServerLevel level = ensureLoaded(player.getServer(), definition);
if (level == null) {
return Optional.empty();
@@ -122,6 +124,7 @@ public final class AgeDimensionService {
}
public static ServerLevel ensureLoaded(MinecraftServer server, AgeDefinition definition) {
if (AgeRegistrySavedData.get(server.overworld()).isDead(definition.id())) return null;
ResourceKey<Level> key = dimensionKey(definition);
ServerLevel existing = server.getLevel(key);
if (existing != null) {
@@ -23,6 +23,24 @@ public final class AgePersistenceGameTests {
private AgePersistenceGameTests() {
}
@GameTest(template = "empty")
public static void retiredAgeIdentitySurvivesSaveAndCannotLoad(GameTestHelper helper) {
UUID id = UUID.randomUUID();
AgeReference reference = new AgeReference(id, "Retired " + id);
AgeDefinition age = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(), reference);
AgeRegistrySavedData registry = AgeRegistrySavedData.get(helper.getLevel());
helper.assertTrue(registry.markDead(id), "The first retirement must set the tombstone");
helper.assertTrue(!registry.markDead(id), "Retirement must be idempotent");
helper.assertTrue(AgeDimensionService.ensureLoaded(helper.getLevel().getServer(), age) == null,
"A retired Age must never load from an old book");
AgeRegistrySavedData restored = AgeRegistrySavedData.load(
registry.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess());
helper.assertTrue(restored.isDead(id) && restored.find(id).isPresent(),
"Both the retired flag and reserved Age identity must survive reload");
helper.succeed();
}
@GameTest(template = "empty")
public static void authoredPlanRetainsResolvedMeaningAndAgeIdentity(GameTestHelper helper) {
OrderedWriting writing = new OrderedWriting(List.of(
@@ -5,6 +5,9 @@ import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.List;
import java.util.HashSet;
import java.util.Set;
import net.minecraft.nbt.StringTag;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
@@ -21,6 +24,7 @@ import net.neoforged.fml.ModList;
public final class AgeRegistrySavedData extends SavedData {
private static final String DATA_NAME = "rivencraft_ages";
private static final String AGES_TAG = "Ages";
private static final String DEAD_AGES_TAG = "DeadAges";
private static final String ID_TAG = "Id";
private static final String NAME_TAG = "Name";
private static final String SEED_TAG = "Seed";
@@ -50,6 +54,7 @@ public final class AgeRegistrySavedData extends SavedData {
);
private final Map<UUID, AgeDefinition> ages = new HashMap<>();
private final Set<UUID> deadAges = new HashSet<>();
public static AgeRegistrySavedData get(ServerLevel level) {
return level.getServer().overworld().getDataStorage().computeIfAbsent(FACTORY, DATA_NAME);
@@ -110,6 +115,16 @@ public final class AgeRegistrySavedData extends SavedData {
return List.copyOf(ages.values());
}
public boolean isDead(UUID ageId) { return deadAges.contains(ageId); }
/** Keep the ID and definition reserved so an old book cannot regenerate a retired Age. */
public boolean markDead(UUID ageId) {
if (!ages.containsKey(ageId)) return false;
boolean changed = deadAges.add(ageId);
if (changed) setDirty();
return changed;
}
public AgeDefinition setSpawn(UUID ageId, BlockPos spawn) {
AgeDefinition existing = ages.get(ageId);
if (existing == null) {
@@ -161,6 +176,11 @@ public final class AgeRegistrySavedData extends SavedData {
static AgeRegistrySavedData load(CompoundTag tag, HolderLookup.Provider registries) {
AgeRegistrySavedData data = new AgeRegistrySavedData();
ListTag deadTags = tag.getList(DEAD_AGES_TAG, StringTag.TAG_STRING);
for (int index = 0; index < deadTags.size(); index++) {
try { data.deadAges.add(UUID.fromString(deadTags.getString(index))); }
catch (IllegalArgumentException ignored) { }
}
ListTag serializedAges = tag.getList(AGES_TAG, CompoundTag.TAG_COMPOUND);
for (int index = 0; index < serializedAges.size(); index++) {
CompoundTag entry = serializedAges.getCompound(index);
@@ -264,6 +284,9 @@ public final class AgeRegistrySavedData extends SavedData {
serializedAges.add(entry);
});
tag.put(AGES_TAG, serializedAges);
ListTag deadTags = new ListTag();
deadAges.stream().sorted().forEach(id -> deadTags.add(StringTag.valueOf(id.toString())));
tag.put(DEAD_AGES_TAG, deadTags);
return tag;
}
@@ -0,0 +1,127 @@
package net.dnorberg.rivencraft.book;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.dnorberg.rivencraft.registry.ModEntities;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.core.BlockPos;
import net.minecraft.core.GlobalPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.phys.AABB;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class HandheldBookTravelGameTests {
private HandheldBookTravelGameTests() {
}
@GameTest(template = "empty")
public static void successfulHandheldTravelLeavesOnlyActiveBookAtOrigin(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
ItemStack book = new ItemStack(ModItems.LINKING_BOOK.get());
player.setItemInHand(InteractionHand.MAIN_HAND, book);
player.getInventory().setItem(1, new ItemStack(Items.DIAMOND));
var source = player.position();
BlockPos destination = helper.absolutePos(new BlockPos(2, 1, 2));
helper.getLevel().setBlock(destination, Blocks.AIR.defaultBlockState(), 2);
helper.getLevel().setBlock(destination.above(), Blocks.AIR.defaultBlockState(), 2);
LinkTarget target = new LinkTarget(GlobalPos.of(helper.getLevel().dimension(), destination), 0, 0);
helper.assertValueEqual(LinkingService.travelHandheld(player, InteractionHand.MAIN_HAND, book, target,
new LinkProperties(LinkProperties.INTRA_LINKING), false), LinkingService.LinkResult.SUCCESS,
"A valid intra-dimensional handheld link must succeed");
helper.assertTrue(player.getMainHandItem().isEmpty(), "Only the active book must leave the hand");
helper.assertTrue(player.getInventory().getItem(1).is(Items.DIAMOND),
"An unrelated inventory item must remain with the player");
var drops = helper.getLevel().getEntitiesOfClass(DroppedBookEntity.class, new AABB(source, source).inflate(2.0),
entity -> entity.getItem().is(ModItems.LINKING_BOOK.get()));
helper.assertValueEqual(drops.size(), 1, "Exactly one recoverable book must remain at the departure point");
helper.assertTrue(drops.getFirst().getItem() == book,
"The model-rendered entity must retain the exact active book stack");
helper.assertTrue(drops.getFirst().copy() instanceof DroppedBookEntity,
"The pickup-animation copy must keep the custom renderer's entity type");
helper.succeed();
}
@GameTest(template = "empty")
public static void rejectedHandheldTravelKeepsBook(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
ItemStack book = new ItemStack(ModItems.LINKING_BOOK.get());
player.setItemInHand(InteractionHand.OFF_HAND, book);
LinkTarget target = new LinkTarget(GlobalPos.of(helper.getLevel().dimension(), player.blockPosition()), 0, 0);
helper.assertValueEqual(LinkingService.travelHandheld(player, InteractionHand.OFF_HAND, book, target,
LinkProperties.NONE, false), LinkingService.LinkResult.INTRA_LINKING_REQUIRED,
"A same-dimension link without Intra Linking must be rejected");
helper.assertTrue(player.getOffhandItem() == book, "The exact rejected book must be restored to its hand");
helper.assertTrue(helper.getLevel().getEntitiesOfClass(ItemEntity.class,
new AABB(player.position(), player.position()).inflate(2.0),
entity -> entity.getItem().is(ModItems.LINKING_BOOK.get())).isEmpty(),
"A rejected link must not leave a second copy at the origin");
helper.succeed();
}
@GameTest(template = "empty")
public static void disarmDropsOtherInventoryWithoutDuplicatingActiveBook(GameTestHelper helper) {
var player = helper.makeMockServerPlayerInLevel();
ItemStack book = new ItemStack(ModItems.LINKING_BOOK.get());
player.setItemInHand(InteractionHand.MAIN_HAND, book);
player.getInventory().setItem(1, new ItemStack(Items.DIAMOND));
var source = player.position();
BlockPos destination = helper.absolutePos(new BlockPos(2, 1, 2));
helper.getLevel().setBlock(destination, Blocks.AIR.defaultBlockState(), 2);
helper.getLevel().setBlock(destination.above(), Blocks.AIR.defaultBlockState(), 2);
LinkTarget target = new LinkTarget(GlobalPos.of(helper.getLevel().dimension(), destination), 0, 0);
helper.assertValueEqual(LinkingService.travelHandheld(player, InteractionHand.MAIN_HAND, book, target,
new LinkProperties(LinkProperties.INTRA_LINKING | LinkProperties.DISARM), false),
LinkingService.LinkResult.SUCCESS, "Disarm must not block handheld travel");
var drops = helper.getLevel().getEntitiesOfClass(ItemEntity.class, new AABB(source, source).inflate(2.0));
helper.assertValueEqual(drops.stream().filter(entity -> entity instanceof DroppedBookEntity).count(),
1L, "Disarm must not create a second copy of the active book");
helper.assertValueEqual(drops.stream().filter(entity -> entity.getItem().is(Items.DIAMOND)).count(),
1L, "Disarm must still drop unrelated inventory items");
helper.succeed();
}
@GameTest(template = "empty")
public static void originBookUsesSlowDecayWithoutVanillaDespawn(GameTestHelper helper) {
DroppedBookEntity book = new DroppedBookEntity(helper.getLevel(),
net.minecraft.world.phys.Vec3.ZERO, new ItemStack(ModItems.LINKING_BOOK.get()));
CompoundTag seeded = new CompoundTag();
book.saveWithoutId(seeded);
seeded.putInt("BookDecayTicks", 9_999);
book.readAdditionalSaveData(seeded);
book.tick();
CompoundTag saved = new CompoundTag();
book.saveWithoutId(saved);
helper.assertValueEqual(saved.getShort("Age"), (short)-32_768,
"The dropped book must not expire after vanilla's five minutes");
helper.assertValueEqual(saved.getShort("Health"), (short)4,
"A dropped book must lose one item-health point after 10,000 loaded ticks");
helper.assertValueEqual(saved.getInt("BookDecayTicks"), 0,
"Slow decay must reset its interval and persist across a restart");
DroppedBookEntity restored = new DroppedBookEntity(ModEntities.DROPPED_BOOK.get(), helper.getLevel());
restored.readAdditionalSaveData(saved);
CompoundTag resaved = new CompoundTag();
restored.saveWithoutId(resaved);
helper.assertTrue(restored.getItem().is(ModItems.LINKING_BOOK.get()),
"The custom book entity must retain its book on reload");
helper.assertValueEqual(resaved.getShort("Age"), (short)-32_768,
"The custom book must remain non-despawning after reload");
helper.assertValueEqual(resaved.getShort("Health"), (short)4,
"Decay damage must persist after reload");
helper.assertTrue(restored.copy().getItem().is(ModItems.LINKING_BOOK.get()),
"A pickup-animation copy must retain the book data after reload");
helper.succeed();
}
}
@@ -5,10 +5,13 @@ import java.util.Optional;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.minecraft.tags.BlockTags;
import net.minecraft.world.level.border.WorldBorder;
import net.minecraft.world.level.levelgen.Heightmap;
@@ -32,6 +35,36 @@ public final class LinkingService {
return travel(player, target, false, properties, false);
}
/** Removes only the active handheld book after a successful link, leaving it at the departure point. */
public static LinkResult travelHandheld(ServerPlayer player, InteractionHand hand, ItemStack activeBook,
LinkTarget target, LinkProperties properties, boolean requiresFooting) {
if (player.getItemInHand(hand) != activeBook || activeBook.isEmpty()) {
return LinkResult.NO_SAFE_DESTINATION;
}
ServerLevel sourceLevel = player.serverLevel();
Vec3 sourcePosition = player.position();
player.setItemInHand(hand, ItemStack.EMPTY);
boolean dropped = false;
try {
LinkResult result = requiresFooting
? travelWithFooting(player, target, properties)
: travel(player, target, properties);
if (result != LinkResult.SUCCESS) return result;
DroppedBookEntity book = new DroppedBookEntity(sourceLevel, sourcePosition, activeBook);
dropped = sourceLevel.addFreshEntity(book);
return result;
} finally {
// Failed validation or a rejected entity spawn must never eat the book.
if (!dropped) {
if (player.getItemInHand(hand).isEmpty()) player.setItemInHand(hand, activeBook);
else if (!player.getInventory().add(activeBook)) player.drop(activeBook, false);
}
player.containerMenu.broadcastChanges();
}
}
/** Portal travel uses the vanilla transfer if its destination is not yet loaded. */
public static LinkResult travelPortal(ServerPlayer player, LinkTarget target, LinkProperties properties) {
return travel(player, target, false, properties, true);
@@ -5,6 +5,7 @@ import net.dnorberg.rivencraft.registry.ModBlockEntities;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModEntities;
import net.minecraft.client.renderer.item.ItemProperties;
import net.minecraft.client.renderer.ItemBlockRenderTypes;
import net.minecraft.client.renderer.RenderType;
@@ -25,6 +26,7 @@ import net.dnorberg.rivencraft.client.screen.NotebookScreen;
import net.dnorberg.rivencraft.client.screen.WritingDeskScreen;
import net.dnorberg.rivencraft.client.screen.BookBinderScreen;
import net.dnorberg.rivencraft.client.screen.InkMixerScreen;
import net.dnorberg.rivencraft.client.screen.LinkModifierScreen;
import net.dnorberg.rivencraft.registry.ModMenus;
@EventBusSubscriber(modid = Rivencraft.MOD_ID, value = Dist.CLIENT)
@@ -34,6 +36,7 @@ public final class ClientEvents {
@SubscribeEvent
public static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) {
event.registerEntityRenderer(ModEntities.DROPPED_BOOK.get(), DroppedBookRenderer::new);
event.registerBlockEntityRenderer(ModBlockEntities.BOOK_STAND.get(), BookStandRenderer::new);
event.registerBlockEntityRenderer(ModBlockEntities.WRITING_DESK.get(), WritingDeskRenderer::new);
event.registerBlockEntityRenderer(ModBlockEntities.STAR_FISSURE.get(), StarFissureRenderer::new);
@@ -45,6 +48,7 @@ public final class ClientEvents {
event.register(ModMenus.WRITING_DESK.get(), WritingDeskScreen::new);
event.register(ModMenus.BOOK_BINDER.get(), BookBinderScreen::new);
event.register(ModMenus.INK_MIXER.get(), InkMixerScreen::new);
event.register(ModMenus.LINK_MODIFIER.get(), LinkModifierScreen::new);
}
@SubscribeEvent
@@ -0,0 +1,51 @@
package net.dnorberg.rivencraft.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import com.mojang.math.Axis;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.client.model.BookModel;
import net.minecraft.client.model.geom.ModelLayers;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.client.renderer.entity.EntityRenderer;
import net.minecraft.client.renderer.entity.EntityRendererProvider;
import net.minecraft.resources.ResourceLocation;
/** Renders the same open book model and textures as Rivencraft's book stands. */
public final class DroppedBookRenderer extends EntityRenderer<DroppedBookEntity> {
private static final ResourceLocation LINKING_TEXTURE = ResourceLocation.fromNamespaceAndPath(
"rivencraft", "textures/entity/linking_book.png");
private static final ResourceLocation DESCRIPTIVE_TEXTURE = ResourceLocation.fromNamespaceAndPath(
"rivencraft", "textures/entity/descriptive_book.png");
private final BookModel model;
public DroppedBookRenderer(EntityRendererProvider.Context context) {
super(context);
model = new BookModel(context.bakeLayer(ModelLayers.BOOK));
shadowRadius = 0.25F;
}
@Override
public void render(DroppedBookEntity entity, float yaw, float partialTick, PoseStack poseStack,
MultiBufferSource buffers, int packedLight) {
poseStack.pushPose();
poseStack.translate(0.0F, 0.0625F, 0.0F);
poseStack.mulPose(Axis.YP.rotationDegrees(yaw + 90.0F));
// The opened model's covers rotate toward the YZ plane. As in Legacy's
// dropped-book renderer, a Z-axis quarter-turn lays the pages skyward.
poseStack.mulPose(Axis.ZP.rotationDegrees(90.0F));
poseStack.scale(0.8F, 0.8F, 0.8F);
model.setupAnim(0.0F, 0.0F, 0.0F, 1.2F);
VertexConsumer vertices = buffers.getBuffer(RenderType.entitySolid(getTextureLocation(entity)));
model.render(poseStack, vertices, packedLight, net.minecraft.client.renderer.texture.OverlayTexture.NO_OVERLAY, -1);
poseStack.popPose();
super.render(entity, yaw, partialTick, poseStack, buffers, packedLight);
}
@Override
public ResourceLocation getTextureLocation(DroppedBookEntity entity) {
return entity.getItem().is(ModItems.DESCRIPTIVE_BOOK.get()) ? DESCRIPTIVE_TEXTURE : LINKING_TEXTURE;
}
}
@@ -27,6 +27,7 @@ import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.network.chat.Component;
import net.minecraft.core.component.DataComponents;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.util.FormattedCharSequence;
@@ -48,6 +49,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
private static final Component EXPLORE_ICON = Component.literal("👁");
private final AgeReference age;
private final Component displayName;
private final ResourceLocation generationProfile;
private final AgeTraits traits;
private final OrderedWriting writing;
@@ -68,6 +70,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
List<SymbolDisplayMetadata> writingSymbols) {
super(book.getHoverName());
age = book.get(ModDataComponents.AGE_REFERENCE.get());
displayName = book.get(DataComponents.CUSTOM_NAME);
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
this.traits = traits;
writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
@@ -82,6 +85,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
List<SymbolDisplayMetadata> writingSymbols) {
super(book.getHoverName());
age = book.get(ModDataComponents.AGE_REFERENCE.get());
displayName = book.get(DataComponents.CUSTOM_NAME);
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
this.traits = traits;
writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
@@ -245,7 +249,8 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
}
return;
}
int nameEnd = drawWrappedWithoutShadow(graphics, Component.literal(age.name()),
int nameEnd = drawWrappedWithoutShadow(graphics,
displayName == null ? Component.literal(age.name()) : displayName,
x, top + 40, 110);
int detailsY = drawWrappedWithoutShadow(graphics,
author == null || author.isBlank()
@@ -0,0 +1,105 @@
package net.dnorberg.rivencraft.client.screen;
import net.dnorberg.rivencraft.book.LinkProperties;
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
import net.dnorberg.rivencraft.network.SetLinkModifierTitlePayload;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.network.PacketDistributor;
public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifierMenu> {
private EditBox titleInput;
private Button disarm;
private Button momentum;
private Button intra;
private Button retire;
private ItemStack lastBook = ItemStack.EMPTY;
public LinkModifierScreen(LinkModifierMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title);
imageWidth = 176;
imageHeight = 194;
inventoryLabelX = 8;
inventoryLabelY = 99;
}
@Override protected void init() {
super.init();
titleInput = new EditBox(font, leftPos + 8, topPos + 54, 160, 14,
Component.translatable("screen.rivencraft.link_modifier.title"));
titleInput.setMaxLength(32);
titleInput.setResponder(value -> PacketDistributor.sendToServer(
new SetLinkModifierTitlePayload(menu.containerId, value)));
addRenderableWidget(titleInput);
disarm = addRenderableWidget(Button.builder(Component.empty(), button -> click(LinkModifierMenu.TOGGLE_DISARM))
.bounds(leftPos + 8, topPos + 74, 76, 20).build());
momentum = addRenderableWidget(Button.builder(Component.empty(), button -> click(LinkModifierMenu.TOGGLE_MOMENTUM))
.bounds(leftPos + 92, topPos + 74, 76, 20).build());
intra = addRenderableWidget(Button.builder(Component.empty(), button -> click(LinkModifierMenu.TOGGLE_INTRA))
.bounds(leftPos + 8, topPos + 18, 64, 20).build());
retire = addRenderableWidget(Button.builder(Component.empty(), button -> click(LinkModifierMenu.DELETE_AGE))
.bounds(leftPos + 104, topPos + 18, 64, 20).build());
refreshControls();
}
private void click(int id) {
if (minecraft != null && minecraft.gameMode != null) {
minecraft.gameMode.handleInventoryButtonClick(menu.containerId, id);
}
}
@Override protected void containerTick() { refreshControls(); }
private void refreshControls() {
ItemStack book = menu.book();
if (!ItemStack.isSameItemSameComponents(book, lastBook) && !titleInput.isFocused()) {
Component name = book.get(DataComponents.CUSTOM_NAME);
titleInput.setValue(name == null ? "" : name.getString());
lastBook = book.copy();
}
boolean present = !book.isEmpty();
titleInput.setEditable(present);
LinkProperties properties = book.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE);
disarm.active = present;
disarm.setMessage(Component.literal("Disarm " + (properties.has(LinkProperties.DISARM) ? "✓" : "×")));
momentum.active = present;
momentum.setMessage(Component.literal("Momentum " + (properties.has(LinkProperties.MAINTAIN_MOMENTUM) ? "✓" : "×")));
intra.visible = book.is(ModItems.LINKING_BOOK.get());
intra.setMessage(Component.literal("Intra " + (properties.has(LinkProperties.INTRA_LINKING) ? "✓" : "×")));
retire.visible = menu.canDelete(minecraft.player);
retire.setMessage(Component.translatable(menu.awaitingDeletionConfirmation()
? "screen.rivencraft.link_modifier.confirm" : "screen.rivencraft.link_modifier.retire"));
}
@Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
graphics.fill(leftPos, topPos, leftPos + imageWidth, topPos + imageHeight, 0xFFD6C7A7);
graphics.renderOutline(leftPos, topPos, imageWidth, imageHeight, 0xFF493C34);
graphics.fill(leftPos + 78, topPos + 28, leftPos + 98, topPos + 48, 0xFF83785F);
graphics.renderOutline(leftPos + 77, topPos + 27, 22, 22, 0xFF493C34);
for (int row = 0; row < 3; row++) for (int col = 0; col < 9; col++)
slotBackground(graphics, 8 + col * 18, 112 + row * 18);
for (int col = 0; col < 9; col++) slotBackground(graphics, 8 + col * 18, 170);
}
private void slotBackground(GuiGraphics graphics, int x, int y) {
graphics.fill(leftPos + x - 1, topPos + y - 1, leftPos + x + 17, topPos + y + 17, 0xFF81745C);
}
@Override protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
graphics.drawString(font, title, 8, 6, 0xFF332722, false);
graphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0xFF332722, false);
}
@Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
super.render(graphics, mouseX, mouseY, partialTick);
renderTooltip(graphics, mouseX, mouseY);
}
}
@@ -182,9 +182,9 @@ public final class NotebookScreen extends AbstractContainerScreen<NotebookMenu>
private int displayedPageSlots() {
if (menu.getCarried().is(ModItems.PAGE.get())) {
return NotebookMenu.NOTEBOOK_SLOTS;
return menu.pageCapacity();
}
for (int index = NotebookMenu.NOTEBOOK_SLOTS - 1; index >= 0; index--) {
for (int index = menu.pageCapacity() - 1; index >= 0; index--) {
if (!menu.getSlot(index).getItem().isEmpty()) {
return index + 1;
}
@@ -12,6 +12,7 @@ import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.minecraft.client.gui.GuiGraphics;
@@ -234,7 +235,7 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
private List<ItemStack> pages(int collectionIndex) {
ItemStack collection = menu.getSlot(collectionIndex).getItem();
if (collection.is(ModItems.PAGE.get())) return List.of(collection);
if (collection.is(ModItems.NOTEBOOK.get())) {
if (CollationFolderItem.isCollection(collection)) {
return collection.getOrDefault(ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
}
return List.of();
@@ -301,9 +302,9 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
private boolean canMovePage(DisplayPage source, DisplayPage target) {
return !source.stack().isEmpty()
&& (menu.getSlot(source.collection()).getItem().is(ModItems.NOTEBOOK.get())
&& (CollationFolderItem.isCollection(menu.getSlot(source.collection()).getItem())
|| menu.getSlot(source.collection()).getItem().is(ModItems.PAGE.get()))
&& (menu.getSlot(target.collection()).getItem().is(ModItems.NOTEBOOK.get())
&& (CollationFolderItem.isCollection(menu.getSlot(target.collection()).getItem())
|| menu.getSlot(target.collection()).getItem().is(ModItems.PAGE.get()))
&& (source.collection() != target.collection() || source.index() != target.index());
}
@@ -317,10 +318,10 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
if (!slot.mayPlace(held)) return -1;
ItemStack current = slot.getItem();
if (current.isEmpty()) return index;
if (index < WritingDeskBlockEntity.COLLECTIONS && current.is(ModItems.NOTEBOOK.get())) {
if (index < WritingDeskBlockEntity.COLLECTIONS && CollationFolderItem.isCollection(current)) {
List<ItemStack> pages = current.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
if (pages.size() < NotebookContents.MAX_PAGES || pages.stream().anyMatch(ItemStack::isEmpty))
if (pages.size() < CollationFolderItem.capacity(current) || pages.stream().anyMatch(ItemStack::isEmpty))
return index;
} else if (index > WritingDeskMenu.COPY_RESULT && ItemStack.isSameItemSameComponents(current, held)
&& current.getCount() < slot.getMaxStackSize(held)) return index;
@@ -112,8 +112,9 @@ public final class RivencraftCommands {
source.sendSuccess(() -> Component.literal("Rivencraft Ages: " + ages.size()), false);
for (AgeDefinition age : ages) {
boolean loaded = source.getServer().getLevel(AgeDimensionService.dimensionKey(age)) != null;
boolean retired = AgeRegistrySavedData.get(source.getLevel()).isDead(age.id());
source.sendSuccess(() -> Component.literal(
(loaded ? "[loaded] " : "[unloaded] ")
(retired ? "[retired] " : loaded ? "[loaded] " : "[unloaded] ")
+ age.name() + " — " + age.dimensionId()
+ (age.seasonMode() == null ? "" : " (season: "
+ age.seasonMode().name().toLowerCase(java.util.Locale.ROOT) + ")")
@@ -75,7 +75,7 @@ public final class RivencraftConfig {
"Controls automatic Return Link creation for Descriptive Book travel.",
"ON_CREATION creates one link when an Age is first reached; ALWAYS creates one on every arrival; DISABLED creates none."
)
.defineEnum("return_link_mode", ReturnLinkMode.ON_CREATION);
.defineEnum("return_link_mode", ReturnLinkMode.DISABLED);
builder.pop();
builder.push("libraries");
LIBRARY_LOOT_MODE = builder.comment(
@@ -0,0 +1,62 @@
package net.dnorberg.rivencraft.entity;
import net.dnorberg.rivencraft.registry.ModEntities;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.item.ItemEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
/** A recoverable, model-rendered book left behind by handheld linking. */
public final class DroppedBookEntity extends ItemEntity {
private static final String DECAY_TICKS_TAG = "BookDecayTicks";
private static final int SLOW_DECAY_INTERVAL = 10_000;
private int decayTicks;
public DroppedBookEntity(EntityType<DroppedBookEntity> type, Level level) {
super(type, level);
}
public DroppedBookEntity(Level level, Vec3 position, ItemStack stack) {
this(ModEntities.DROPPED_BOOK.get(), level);
setPos(position.x, position.y + 0.25, position.z);
setDeltaMovement(Vec3.ZERO);
setItem(stack);
setUnlimitedLifetime();
setPickUpDelay(20);
}
/** Vanilla's pickup particle calls ItemEntity.copy() and renders the returned entity. */
@Override
public DroppedBookEntity copy() {
DroppedBookEntity copy = new DroppedBookEntity(ModEntities.DROPPED_BOOK.get(), level());
copy.copyPosition(this);
copy.setItem(getItem().copy());
copy.setUnlimitedLifetime();
return copy;
}
@Override
public void tick() {
super.tick();
if (level().isClientSide || isRemoved()) return;
if (++decayTicks >= SLOW_DECAY_INTERVAL) {
decayTicks = 0;
hurt(damageSources().starve(), 1.0F);
}
if (!isRemoved() && isInWaterOrRain()) hurt(damageSources().drown(), 1.0F);
}
@Override
public void addAdditionalSaveData(CompoundTag tag) {
super.addAdditionalSaveData(tag);
tag.putInt(DECAY_TICKS_TAG, decayTicks);
}
@Override
public void readAdditionalSaveData(CompoundTag tag) {
super.readAdditionalSaveData(tag);
decayTicks = Math.max(0, Math.min(tag.getInt(DECAY_TICKS_TAG), SLOW_DECAY_INTERVAL - 1));
}
}
@@ -0,0 +1,122 @@
package net.dnorberg.rivencraft.gametest;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.inventory.ClickType;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class Milestone7AdvancementGameTests {
private Milestone7AdvancementGameTests() {}
@GameTest(template = "empty")
public static void progressionAndRecipeUnlocksLoad(GameTestHelper helper) {
var registry = helper.getLevel().getServer().getAdvancements();
for (String path : List.of("root", "page", "linking_book", "link_panel",
"copy_page", "bind_age", "enter_age", "open_portal")) {
helper.assertTrue(registry.get(id("progression/" + path)) != null,
"Progression advancement must load: " + path);
}
for (String path : List.of("book_binder", "book_receptacle", "crystal_block",
"ink_mixer", "ink_vial", "notebook", "writing_desk")) {
helper.assertTrue(registry.get(id("recipes/misc/" + path)) != null,
"Recipe-book unlock must load: " + path);
}
var player = helper.makeMockServerPlayerInLevel();
for (String path : List.of("book_binder", "ink_mixer", "notebook", "writing_desk")) {
player.getAdvancements().award(registry.get(id("recipes/misc/" + path)), "has_material");
helper.assertTrue(player.getRecipeBook().contains(id(path)),
"Discovering the material must unlock the " + path + " recipe");
}
helper.succeed();
}
@GameTest(template = "empty")
public static void actionAwardsAreServerOwnedAndPerPlayer(GameTestHelper helper) {
var first = helper.makeMockServerPlayerInLevel();
var second = helper.makeMockServerPlayerInLevel();
var advancement = helper.getLevel().getServer().getAdvancements().get(id("progression/copy_page"));
helper.assertFalse(first.getAdvancements().getOrStartProgress(advancement).isDone(),
"Opening a menu or preview must not award a copied Page");
helper.assertFalse(second.getAdvancements().getOrStartProgress(advancement).isDone(),
"Another player must not inherit the advancement");
ProgressionAdvancements.awardAgeTravel(first);
var travel = helper.getLevel().getServer().getAdvancements().get(id("progression/enter_age"));
helper.assertFalse(first.getAdvancements().getOrStartProgress(travel).isDone(),
"Overworld travel or a preview must not count as entering an Age");
ProgressionAdvancements.award(first, ProgressionAdvancements.COPY_PAGE);
helper.assertTrue(first.getAdvancements().getOrStartProgress(advancement).isDone(),
"The server must award the player who completed the action");
helper.assertFalse(second.getAdvancements().getOrStartProgress(advancement).isDone(),
"Advancement progress must remain per-player in multiplayer");
helper.succeed();
}
@GameTest(template = "empty")
public static void mixerPreviewDoesNotAwardButTakingPanelDoes(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.INK_MIXER.get().defaultBlockState(), 3);
var mixer = (InkMixerBlockEntity) helper.getLevel().getBlockEntity(pos);
mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER));
mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
var player = nearbyPlayer(helper, pos);
var menu = new InkMixerMenu(1, player.getInventory(), mixer);
helper.assertFalse(done(player, ProgressionAdvancements.LINK_PANEL),
"Opening the Ink Mixer must not award its milestone");
menu.clicked(InkMixerMenu.RESULT, 0, ClickType.PICKUP, player);
helper.assertTrue(done(player, ProgressionAdvancements.LINK_PANEL),
"Taking the completed Link Panel must award its milestone");
helper.succeed();
}
@GameTest(template = "empty")
public static void deskPreviewDoesNotAwardButCopyingDoes(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.WRITING_DESK.get().defaultBlockState(), 3);
var desk = (WritingDeskBlockEntity) helper.getLevel().getBlockEntity(pos);
desk.setItem(0, PageItem.createSymbolPage(new SymbolReference(id("terrain/standard"))));
desk.setItem(WritingDeskBlockEntity.PAPER, new ItemStack(Items.PAPER));
desk.setItem(WritingDeskBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get()));
var player = nearbyPlayer(helper, pos);
var menu = new WritingDeskMenu(1, player.getInventory(), desk);
helper.assertFalse(done(player, ProgressionAdvancements.COPY_PAGE),
"Opening the Writing Desk must not award a copied Page");
menu.clicked(WritingDeskMenu.COPY_RESULT, 0, ClickType.PICKUP, player);
helper.assertTrue(done(player, ProgressionAdvancements.COPY_PAGE),
"Taking a copied Page must award its milestone");
helper.succeed();
}
private static ServerPlayer nearbyPlayer(GameTestHelper helper, BlockPos pos) {
var player = helper.makeMockServerPlayerInLevel();
player.moveTo(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
return player;
}
private static boolean done(ServerPlayer player, String path) {
var advancement = player.getServer().getAdvancements().get(id("progression/" + path));
return player.getAdvancements().getOrStartProgress(advancement).isDone();
}
private static ResourceLocation id(String path) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
}
@@ -0,0 +1,23 @@
package net.dnorberg.rivencraft.item;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.minecraft.world.item.ItemStack;
/** Legacy's compact page collection, using the existing Notebook page editor. */
public final class CollationFolderItem extends NotebookItem {
public static final int MAX_PAGES = 27;
public CollationFolderItem(Properties properties) {
super(properties);
}
public static boolean isCollection(ItemStack stack) {
return stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.COLLATION_FOLDER.get());
}
public static int capacity(ItemStack stack) {
return stack.is(ModItems.COLLATION_FOLDER.get()) ? MAX_PAGES
: stack.is(ModItems.NOTEBOOK.get()) ? NotebookContents.MAX_PAGES : 0;
}
}
@@ -1,5 +1,6 @@
package net.dnorberg.rivencraft.item;
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
import java.util.List;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.AgeVisitSavedData;
@@ -17,6 +18,7 @@ import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
@@ -40,14 +42,18 @@ public final class DescriptiveBookItem extends Item {
}
public static boolean useWrittenBook(ServerPlayer player, ItemStack stack) {
return useWrittenBook(player, stack, false);
return useWrittenBook(player, stack, false, null);
}
public static boolean useHandheldBook(ServerPlayer player, InteractionHand hand) {
return useWrittenBook(player, player.getItemInHand(hand), false, hand);
}
public static boolean usePortalBook(ServerPlayer player, ItemStack stack) {
return useWrittenBook(player, stack, true);
return useWrittenBook(player, stack, true, null);
}
private static boolean useWrittenBook(ServerPlayer player, ItemStack stack, boolean portal) {
private static boolean useWrittenBook(ServerPlayer player, ItemStack stack, boolean portal, InteractionHand hand) {
AgeReference age = stack.get(ModDataComponents.AGE_REFERENCE.get());
if (age == null) {
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unwritten"), true);
@@ -67,7 +73,8 @@ public final class DescriptiveBookItem extends Item {
.forDescriptiveBook();
LinkingService.LinkResult result = portal
? LinkingService.travelDescriptivePortal(player, ageTarget, properties)
: LinkingService.travelWithFooting(player, ageTarget, properties);
: hand == null ? LinkingService.travelWithFooting(player, ageTarget, properties)
: LinkingService.travelHandheld(player, hand, stack, ageTarget, properties, true);
if (result != LinkingService.LinkResult.SUCCESS) {
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.unsafe"), true);
return false;
@@ -86,6 +93,7 @@ public final class DescriptiveBookItem extends Item {
}
}
LinkVisualEffects.play(sourceLevel, sourcePosition, player, properties, portal);
ProgressionAdvancements.awardAgeTravel(player);
player.displayClientMessage(Component.translatable(
createReturnLink ? "message.rivencraft.descriptive_book.linked_first" : "message.rivencraft.descriptive_book.linked",
age.name()
@@ -1,5 +1,6 @@
package net.dnorberg.rivencraft.item;
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
import java.util.List;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
@@ -42,14 +43,19 @@ public final class LinkingBookItem extends Item {
}
public static InteractionResultHolder<ItemStack> useBoundBook(ServerPlayer player, ItemStack stack) {
return useBoundBook(player, stack, false);
return useBoundBook(player, stack, false, null);
}
public static InteractionResultHolder<ItemStack> useHandheldBook(ServerPlayer player, InteractionHand hand) {
return useBoundBook(player, player.getItemInHand(hand), false, hand);
}
public static InteractionResultHolder<ItemStack> usePortalBook(ServerPlayer player, ItemStack stack) {
return useBoundBook(player, stack, true);
return useBoundBook(player, stack, true, null);
}
private static InteractionResultHolder<ItemStack> useBoundBook(ServerPlayer player, ItemStack stack, boolean portal) {
private static InteractionResultHolder<ItemStack> useBoundBook(ServerPlayer player, ItemStack stack, boolean portal,
InteractionHand hand) {
LinkTarget target = stack.get(ModDataComponents.LINK_TARGET.get());
if (target == null) {
player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.unbound"), true);
@@ -61,9 +67,11 @@ public final class LinkingBookItem extends Item {
LinkProperties properties = stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE);
LinkingService.LinkResult result = portal
? LinkingService.travelPortal(player, target, properties)
: LinkingService.travel(player, target, properties);
: hand == null ? LinkingService.travel(player, target, properties)
: LinkingService.travelHandheld(player, hand, stack, target, properties, false);
if (result == LinkingService.LinkResult.SUCCESS) {
LinkVisualEffects.play(sourceLevel, sourcePosition, player, properties, portal);
ProgressionAdvancements.awardAgeTravel(player);
player.displayClientMessage(Component.translatable("message.rivencraft.linking_book.linked"), true);
return InteractionResultHolder.success(stack);
}
@@ -16,7 +16,7 @@ import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
public final class NotebookItem extends Item {
public class NotebookItem extends Item {
public NotebookItem(Properties properties) {
super(properties);
}
@@ -39,7 +39,9 @@ public final class NotebookItem extends Item {
NotebookContents contents = stack.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
tooltip.add(Component.translatable("item.rivencraft.notebook.pages",
contents.pageCount(), NotebookContents.MAX_PAGES).withStyle(ChatFormatting.GRAY));
contents.pageCount(), stack.is(net.dnorberg.rivencraft.registry.ModItems.COLLATION_FOLDER.get())
? CollationFolderItem.MAX_PAGES : NotebookContents.MAX_PAGES)
.withStyle(ChatFormatting.GRAY));
if (contents.unresolvedCount() > 0) {
tooltip.add(Component.translatable("item.rivencraft.notebook.unresolved",
contents.unresolvedCount()).withStyle(ChatFormatting.RED));
@@ -0,0 +1,62 @@
package net.dnorberg.rivencraft.item;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResultHolder;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
/** Opens into a 16-page Collation Folder, as Legacy's booster item did. */
public final class SealedNotebookItem extends Item {
private static final int PAGE_COUNT = 16;
public SealedNotebookItem(Properties properties) {
super(properties);
}
@Override
public InteractionResultHolder<ItemStack> use(Level level, Player player, InteractionHand hand) {
ItemStack sealed = player.getItemInHand(hand);
if (level.isClientSide) return InteractionResultHolder.sidedSuccess(sealed, true);
if (!(player instanceof ServerPlayer serverPlayer)) return InteractionResultHolder.pass(sealed);
ItemStack folder = open(serverPlayer);
if (folder.isEmpty()) return InteractionResultHolder.fail(sealed);
if (!player.hasInfiniteMaterials()) sealed.shrink(1);
if (sealed.isEmpty()) {
player.setItemInHand(hand, folder);
} else if (!player.getInventory().add(folder)) {
player.drop(folder, false);
}
return InteractionResultHolder.success(player.getItemInHand(hand));
}
static ItemStack open(ServerPlayer player) {
// Current symbol definitions have no Legacy rank. Draw from the authored
// symbol pool, excluding virtual block/biome pages and corrupted pages.
List<ResourceLocation> candidates = SymbolCatalog.symbols().entrySet().stream()
.filter(entry -> entry.getKey().getNamespace().equals("rivencraft")
&& !entry.getKey().getPath().startsWith("block/")
&& !entry.getKey().getPath().startsWith("biome/")
&& !entry.getValue().category().getPath().equals("corrupted"))
.map(java.util.Map.Entry::getKey).sorted().toList();
if (candidates.isEmpty()) return ItemStack.EMPTY;
List<ItemStack> pages = new ArrayList<>(PAGE_COUNT);
for (int i = 0; i < PAGE_COUNT; i++) {
ResourceLocation id = candidates.get(player.getRandom().nextInt(candidates.size()));
pages.add(PageItem.createSymbolPage(new SymbolReference(id)));
}
ItemStack folder = new ItemStack(ModItems.COLLATION_FOLDER.get());
folder.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), new NotebookContents(pages));
return folder;
}
}
@@ -1,14 +1,17 @@
package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
import java.util.Optional;
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.registry.ModMenus;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Container;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory;
@@ -170,7 +173,7 @@ public final class BookBinderMenu extends AbstractContainerMenu {
private boolean clickStrip(int index) {
ItemStack carried = getCarried();
if (carried.is(ModItems.NOTEBOOK.get())) {
if (CollationFolderItem.isCollection(carried)) {
ItemStack emptied = binder.importNotebookFromStack(carried, Math.min(index == 0 ? 0 : index - 1,
binder.nextPageIndex()));
if (emptied.isEmpty()) return false;
@@ -210,7 +213,9 @@ public final class BookBinderMenu extends AbstractContainerMenu {
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty()) {
return ItemStack.EMPTY;
}
binder.consumeAssembly();
if (!binder.consumeAssembly()) return ItemStack.EMPTY;
if (player instanceof ServerPlayer serverPlayer)
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.BIND_AGE);
refreshResult();
return book;
}
@@ -223,7 +228,7 @@ public final class BookBinderMenu extends AbstractContainerMenu {
moved = moveItemStackTo(source, PLAYER_START, slots.size(), true);
} else if (source.is(Items.LEATHER) || BookBinderBlockEntity.validCover(source)) {
moved = moveItemStackTo(source, BookBinderBlockEntity.COVER, BookBinderBlockEntity.COVER + 1, false);
} else if (source.is(ModItems.NOTEBOOK.get())) {
} else if (CollationFolderItem.isCollection(source)) {
ItemStack emptied = binder.importNotebookFromStack(source, binder.nextPageIndex());
if (emptied.isEmpty()) return ItemStack.EMPTY;
sourceSlot.setByPlayer(emptied);
@@ -284,7 +289,11 @@ public final class BookBinderMenu extends AbstractContainerMenu {
return clientSide ? hasItem() : stillValid(player) && binder.canAssemble();
}
@Override public void onTake(Player player, ItemStack stack) {
if (!clientSide && binder.consumeAssembly()) refreshResult();
if (!clientSide && binder.consumeAssembly()) {
if (player instanceof ServerPlayer serverPlayer)
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.BIND_AGE);
refreshResult();
}
super.onTake(player, stack);
}
}
@@ -1,10 +1,12 @@
package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Container;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory;
@@ -109,6 +111,8 @@ public final class InkMixerMenu extends AbstractContainerMenu {
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty())
return ItemStack.EMPTY;
if (!mixer.consumeLinkPanelMaterials()) return ItemStack.EMPTY;
if (player instanceof ServerPlayer serverPlayer)
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.LINK_PANEL);
refreshResult();
return output;
}
@@ -148,7 +152,11 @@ public final class InkMixerMenu extends AbstractContainerMenu {
return clientSide ? hasItem() : stillValid(player) && mixer.canCreateLinkPanel();
}
@Override public void onTake(Player player, ItemStack stack) {
if (!clientSide && mixer.consumeLinkPanelMaterials()) refreshResult();
if (!clientSide && mixer.consumeLinkPanelMaterials()) {
if (player instanceof ServerPlayer serverPlayer)
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.LINK_PANEL);
refreshResult();
}
super.onTake(player, stack);
}
}
@@ -0,0 +1,145 @@
package net.dnorberg.rivencraft.menu;
import java.util.UUID;
import net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity;
import net.dnorberg.rivencraft.book.AgeDefinition;
import net.dnorberg.rivencraft.book.AgeDeletionService;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.dnorberg.rivencraft.book.LinkProperties;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Container;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.inventory.AbstractContainerMenu;
import net.minecraft.world.inventory.DataSlot;
import net.minecraft.world.inventory.Slot;
import net.minecraft.world.item.ItemStack;
/** Server-owned book edits and a two-stage, OP-only Age retirement control. */
public final class LinkModifierMenu extends AbstractContainerMenu {
public static final int TOGGLE_DISARM = 1;
public static final int TOGGLE_MOMENTUM = 2;
public static final int TOGGLE_INTRA = 3;
public static final int DELETE_AGE = 4;
private static final int PLAYER_START = 1;
private static final int MAX_TITLE_LENGTH = 32;
private final LinkModifierBlockEntity modifier;
private final Container bookSlot;
private final boolean clientSide;
private int confirmation;
private UUID pendingAge;
public LinkModifierMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
instanceof LinkModifierBlockEntity found ? found : null);
}
public LinkModifierMenu(int id, Inventory inventory, LinkModifierBlockEntity modifier) {
super(ModMenus.LINK_MODIFIER.get(), id);
this.modifier = modifier;
this.bookSlot = modifier == null ? new SimpleContainer(1) : modifier;
this.clientSide = inventory.player.level().isClientSide;
addDataSlot(new DataSlot() {
@Override public int get() { return confirmation; }
@Override public void set(int value) { confirmation = value; }
});
addSlot(new Slot(bookSlot, 0, 80, 30) {
@Override public boolean mayPlace(ItemStack stack) { return bookSlot.canPlaceItem(0, stack); }
@Override public int getMaxStackSize() { return 1; }
@Override public void setChanged() {
super.setChanged();
pendingAge = null;
confirmation = 0;
}
});
for (int row = 0; row < 3; row++) for (int col = 0; col < 9; col++)
addSlot(new Slot(inventory, col + row * 9 + 9, 8 + col * 18, 112 + row * 18));
for (int col = 0; col < 9; col++)
addSlot(new Slot(inventory, col, 8 + col * 18, 170));
}
public ItemStack book() { return bookSlot.getItem(0); }
public boolean awaitingDeletionConfirmation() { return confirmation == 1; }
public boolean canDelete(Player player) {
return player.hasPermissions(2) && book().is(ModItems.DESCRIPTIVE_BOOK.get())
&& book().has(ModDataComponents.AGE_REFERENCE.get());
}
public boolean setTitle(Player player, String title) {
if (clientSide || !stillValid(player) || title == null || title.length() > MAX_TITLE_LENGTH
|| book().isEmpty()) return false;
String clean = title.strip();
if (clean.isEmpty()) book().remove(DataComponents.CUSTOM_NAME);
else book().set(DataComponents.CUSTOM_NAME, Component.literal(clean));
bookSlot.setChanged();
broadcastChanges();
return true;
}
@Override public boolean clickMenuButton(Player player, int id) {
if (clientSide || !stillValid(player) || book().isEmpty()) return false;
if (id == DELETE_AGE) return retireAge(player);
int flag = switch (id) {
case TOGGLE_DISARM -> LinkProperties.DISARM;
case TOGGLE_MOMENTUM -> LinkProperties.MAINTAIN_MOMENTUM;
case TOGGLE_INTRA -> LinkProperties.INTRA_LINKING;
default -> 0;
};
if (flag == 0 || (flag == LinkProperties.INTRA_LINKING
&& book().is(ModItems.DESCRIPTIVE_BOOK.get()))) return false;
int flags = book().getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE).flags();
book().set(ModDataComponents.LINK_PROPERTIES.get(), new LinkProperties(flags ^ flag));
bookSlot.setChanged();
pendingAge = null;
confirmation = 0;
broadcastChanges();
return true;
}
private boolean retireAge(Player player) {
if (!(player instanceof ServerPlayer serverPlayer) || !canDelete(player)) return false;
AgeReference reference = book().get(ModDataComponents.AGE_REFERENCE.get());
if (reference == null) return false;
AgeDefinition age = AgeRegistrySavedData.get(serverPlayer.serverLevel()).find(reference.id()).orElse(null);
if (age == null) return false;
if (confirmation != 1 || !reference.id().equals(pendingAge)) {
pendingAge = reference.id();
confirmation = 1;
broadcastChanges();
return true;
}
confirmation = 0;
pendingAge = null;
AgeDeletionService.Result result = AgeDeletionService.retire(serverPlayer.getServer(), age);
player.displayClientMessage(Component.translatable(
"message.rivencraft.link_modifier.delete." + result.name().toLowerCase()), false);
broadcastChanges();
return result == AgeDeletionService.Result.RETIRED;
}
@Override public boolean stillValid(Player player) {
return modifier != null && modifier.stillValid(player);
}
@Override public ItemStack quickMoveStack(Player player, int index) {
Slot slot = slots.get(index);
if (!slot.hasItem()) return ItemStack.EMPTY;
ItemStack source = slot.getItem();
ItemStack copy = source.copy();
if (index == 0) {
if (!moveItemStackTo(source, PLAYER_START, slots.size(), true)) return ItemStack.EMPTY;
} else if (!moveItemStackTo(source, 0, 1, false)) return ItemStack.EMPTY;
if (source.isEmpty()) slot.set(ItemStack.EMPTY);
else slot.setChanged();
return copy;
}
}
@@ -7,6 +7,7 @@ import net.dnorberg.rivencraft.registry.ModMenus;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.minecraft.core.NonNullList;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.world.Container;
@@ -28,6 +29,7 @@ public final class NotebookMenu extends AbstractContainerMenu {
private final InteractionHand hand;
private final int openedHotbarSlot;
private final NotebookContainer contents;
private final int pageCapacity;
public NotebookMenu(int containerId, Inventory inventory, RegistryFriendlyByteBuf data) {
this(containerId, inventory, data.readBoolean() ? InteractionHand.OFF_HAND : InteractionHand.MAIN_HAND);
@@ -38,11 +40,12 @@ public final class NotebookMenu extends AbstractContainerMenu {
this.playerInventory = inventory;
this.hand = hand;
this.openedHotbarSlot = inventory.selected;
this.pageCapacity = CollationFolderItem.capacity(inventory.player.getItemInHand(hand));
this.contents = new NotebookContainer(inventory, hand);
for (int page = 0; page < NOTEBOOK_SLOTS; page++) {
// The client draws these pages on a filtered, scrollable surface instead.
addSlot(new PageSlot(contents, page, -1000, -1000));
addSlot(new PageSlot(contents, page, -1000, -1000, page < pageCapacity));
}
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
@@ -61,11 +64,13 @@ public final class NotebookMenu extends AbstractContainerMenu {
: new Slot(playerInventory, inventoryIndex, x, y);
}
public int pageCapacity() { return pageCapacity; }
@Override
public boolean stillValid(Player player) {
return player.isAlive()
&& (hand == InteractionHand.OFF_HAND || player.getInventory().selected == openedHotbarSlot)
&& player.getItemInHand(hand).is(ModItems.NOTEBOOK.get());
&& CollationFolderItem.isCollection(player.getItemInHand(hand));
}
@Override
@@ -108,7 +113,7 @@ public final class NotebookMenu extends AbstractContainerMenu {
} else {
return false;
}
if (source < 0 || source >= NOTEBOOK_SLOTS || target < 0 || target >= NOTEBOOK_SLOTS
if (source < 0 || source >= pageCapacity || target < 0 || target >= pageCapacity
|| contents.getItem(source).isEmpty()) {
return false;
}
@@ -132,14 +137,18 @@ public final class NotebookMenu extends AbstractContainerMenu {
}
private static final class PageSlot extends Slot {
private PageSlot(Container container, int slot, int x, int y) {
private final boolean enabled;
private PageSlot(Container container, int slot, int x, int y, boolean enabled) {
super(container, slot, x, y);
this.enabled = enabled;
}
@Override public boolean mayPlace(ItemStack stack) {
return stack.is(ModItems.PAGE.get())
return enabled && stack.is(ModItems.PAGE.get())
&& PageItem.content(stack).kind() != PageContent.Kind.LINK_PANEL;
}
@Override public boolean mayPickup(Player player) { return enabled; }
@Override public int getMaxStackSize() { return 1; }
}
@@ -184,11 +193,12 @@ public final class NotebookMenu extends AbstractContainerMenu {
public void setChanged() {
super.setChanged();
ItemStack notebook = inventory.player.getItemInHand(hand);
if (loading || !notebook.is(ModItems.NOTEBOOK.get())) {
if (loading || !CollationFolderItem.isCollection(notebook)) {
return;
}
NonNullList<ItemStack> saved = NonNullList.withSize(NOTEBOOK_SLOTS, ItemStack.EMPTY);
for (int index = 0; index < NOTEBOOK_SLOTS; index++) {
int capacity = CollationFolderItem.capacity(notebook);
NonNullList<ItemStack> saved = NonNullList.withSize(capacity, ItemStack.EMPTY);
for (int index = 0; index < capacity; index++) {
ItemStack page = getItem(index);
saved.set(index, page.isEmpty() ? ItemStack.EMPTY : page.copyWithCount(1));
}
@@ -1,11 +1,13 @@
package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.advancement.ProgressionAdvancements;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
@@ -106,7 +108,7 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
public int pageCapacity(int collectionIndex) {
if (collectionIndex < 0 || collectionIndex >= WritingDeskBlockEntity.COLLECTIONS) return 0;
ItemStack collection = desk.getItem(collectionIndex);
return collection.is(ModItems.NOTEBOOK.get()) ? NotebookContents.MAX_PAGES
return CollationFolderItem.isCollection(collection) ? CollationFolderItem.capacity(collection)
: collection.is(ModItems.PAGE.get()) ? 1 : 0;
}
public List<SymbolDisplayMetadata> catalog() { return catalog; }
@@ -182,7 +184,8 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
ItemStack carried = getCarried();
if (clientSide || !storablePage(carried)) return false;
ItemStack collection = desk.getItem(collectionIndex);
if (collection.is(ModItems.NOTEBOOK.get())) {
if (CollationFolderItem.isCollection(collection)) {
if (pageIndex < 0 || pageIndex >= CollationFolderItem.capacity(collection)) return false;
var pages = new java.util.ArrayList<>(collection.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages());
while (pages.size() <= pageIndex) pages.add(ItemStack.EMPTY);
@@ -206,8 +209,10 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
if (!getCarried().isEmpty() || sourceCollection == targetCollection && sourcePage == targetPage) return false;
ItemStack sourceBook = desk.getItem(sourceCollection);
ItemStack targetBook = desk.getItem(targetCollection);
boolean sourceNotebook = sourceBook.is(ModItems.NOTEBOOK.get());
boolean targetNotebook = targetBook.is(ModItems.NOTEBOOK.get());
boolean sourceNotebook = CollationFolderItem.isCollection(sourceBook);
boolean targetNotebook = CollationFolderItem.isCollection(targetBook);
if (sourceNotebook && sourcePage >= CollationFolderItem.capacity(sourceBook)
|| targetNotebook && targetPage >= CollationFolderItem.capacity(targetBook)) return false;
if ((!sourceNotebook && (!sourceBook.is(ModItems.PAGE.get()) || sourcePage != 0))
|| (!targetNotebook && (!targetBook.is(ModItems.PAGE.get()) || targetPage != 0))) return false;
var sourcePages = sourceNotebook ? new java.util.ArrayList<>(sourceBook.getOrDefault(
@@ -246,7 +251,8 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
private boolean extractPage(int sourceCollection, int sourcePage, int targetSlot) {
if (!getCarried().isEmpty() || targetSlot < -1 || targetSlot >= slots.size()) return false;
ItemStack sourceBook = desk.getItem(sourceCollection);
boolean sourceNotebook = sourceBook.is(ModItems.NOTEBOOK.get());
boolean sourceNotebook = CollationFolderItem.isCollection(sourceBook);
if (sourceNotebook && sourcePage >= CollationFolderItem.capacity(sourceBook)) return false;
if (!sourceNotebook && (!sourceBook.is(ModItems.PAGE.get()) || sourcePage != 0)) return false;
var sourcePages = sourceNotebook ? new java.util.ArrayList<>(sourceBook.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages()) : null;
@@ -262,12 +268,12 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
Slot target = slots.get(targetSlot);
if (!target.isActive() || !target.mayPlace(moving)) return false;
ItemStack current = target.getItem();
if (targetSlot < WritingDeskBlockEntity.COLLECTIONS && current.is(ModItems.NOTEBOOK.get())) {
if (targetSlot < WritingDeskBlockEntity.COLLECTIONS && CollationFolderItem.isCollection(current)) {
var targetPages = targetSlot == sourceCollection ? sourcePages
: new java.util.ArrayList<>(current.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages());
int position = -1;
for (int i = 0; i < NotebookContents.MAX_PAGES; i++) {
for (int i = 0; i < CollationFolderItem.capacity(current); i++) {
if (i >= targetPages.size() || targetPages.get(i).isEmpty()) {
position = i;
break;
@@ -317,7 +323,9 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
if (!moveItemStackTo(movingCopy, PLAYER_START, slots.size(), true) || !movingCopy.isEmpty()) {
return ItemStack.EMPTY;
}
blockEntity.consumeCopyMaterials();
if (!blockEntity.consumeCopyMaterials()) return ItemStack.EMPTY;
if (player instanceof ServerPlayer serverPlayer)
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.COPY_PAGE);
refreshOutput();
return copy;
}
@@ -339,7 +347,7 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
private boolean moveIntoDesk(ItemStack moving) {
if (moving.is(ModItems.INK_VIAL.get())) return moveItemStackTo(moving,
WritingDeskBlockEntity.INK, WritingDeskBlockEntity.INK + 1, false);
if (moving.is(ModItems.NOTEBOOK.get())) return moveItemStackTo(moving, firstVisibleTab, firstVisibleTab + VISIBLE_TABS, false);
if (CollationFolderItem.isCollection(moving)) return moveItemStackTo(moving, firstVisibleTab, firstVisibleTab + VISIBLE_TABS, false);
if (moving.is(Items.PAPER))
return moveItemStackTo(moving, WritingDeskBlockEntity.PAPER,
WritingDeskBlockEntity.PAPER + 1, false);
@@ -371,7 +379,8 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
|| pageIndex < 0 || pageIndex >= NotebookContents.MAX_PAGES) return ItemStack.EMPTY;
ItemStack collection = desk.getItem(collectionIndex);
if (collection.is(ModItems.PAGE.get())) return pageIndex == 0 ? collection : ItemStack.EMPTY;
if (collection.is(ModItems.NOTEBOOK.get())) {
if (CollationFolderItem.isCollection(collection)) {
if (pageIndex >= CollationFolderItem.capacity(collection)) return ItemStack.EMPTY;
List<ItemStack> pages = collection.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
return pageIndex < pages.size() ? pages.get(pageIndex) : ItemStack.EMPTY;
@@ -434,7 +443,11 @@ public final class WritingDeskMenu extends AbstractContainerMenu {
return clientSide ? hasItem() : WritingDeskMenu.this.stillValid(player) && canCopy();
}
@Override public void onTake(Player player, ItemStack stack) {
if (!clientSide && blockEntity.consumeCopyMaterials()) refreshOutput();
if (!clientSide && blockEntity.consumeCopyMaterials()) {
if (player instanceof ServerPlayer serverPlayer)
ProgressionAdvancements.award(serverPlayer, ProgressionAdvancements.COPY_PAGE);
refreshOutput();
}
super.onTake(player, stack);
}
}
@@ -18,6 +18,7 @@ import net.dnorberg.rivencraft.event.AgeCelestialAngleSync;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.menu.BookBinderMenu;
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.OrderedWriting;
@@ -59,7 +60,7 @@ public final class ModNetworking {
}
public static void register(RegisterPayloadHandlersEvent event) {
PayloadRegistrar registrar = event.registrar("15");
PayloadRegistrar registrar = event.registrar("16");
registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC,
ModNetworking::requestDescriptiveBookScreen);
registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC,
@@ -85,6 +86,8 @@ public final class ModNetworking {
ClientWritingDeskCatalog::handle);
registrar.playToServer(SetBookBinderTitlePayload.TYPE, SetBookBinderTitlePayload.STREAM_CODEC,
ModNetworking::setBookBinderTitle);
registrar.playToServer(SetLinkModifierTitlePayload.TYPE, SetLinkModifierTitlePayload.STREAM_CODEC,
ModNetworking::setLinkModifierTitle);
}
private static void setBookBinderTitle(SetBookBinderTitlePayload payload, IPayloadContext context) {
@@ -95,6 +98,14 @@ public final class ModNetworking {
}
}
private static void setLinkModifierTitle(SetLinkModifierTitlePayload payload, IPayloadContext context) {
if (context.player() instanceof ServerPlayer player
&& player.containerMenu instanceof LinkModifierMenu menu
&& menu.containerId == payload.containerId()) {
menu.setTitle(player, payload.title());
}
}
private static void requestDescriptiveBookScreen(RequestDescriptiveBookScreenPayload payload, IPayloadContext context) {
if (!(context.player() instanceof ServerPlayer player)) {
return;
@@ -211,7 +222,7 @@ public final class ModNetworking {
ItemStack stack = player.getItemInHand(hand(payload.offhand()));
if (stack.is(ModItems.LINKING_BOOK.get()) && stack.has(ModDataComponents.LINK_TARGET.get())) {
LinkingBookItem.useBoundBook(player, stack);
LinkingBookItem.useHandheldBook(player, hand(payload.offhand()));
}
}
@@ -245,7 +256,8 @@ public final class ModNetworking {
}
if (stack.is(ModItems.DESCRIPTIVE_BOOK.get())) {
DescriptiveBookItem.useWrittenBook(player, stack);
if (payload.fromStand()) DescriptiveBookItem.useWrittenBook(player, stack);
else DescriptiveBookItem.useHandheldBook(player, hand(payload.offhand()));
}
}
@@ -0,0 +1,19 @@
package net.dnorberg.rivencraft.network;
import net.dnorberg.rivencraft.Rivencraft;
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.ResourceLocation;
public record SetLinkModifierTitlePayload(int containerId, String title) implements CustomPacketPayload {
public static final Type<SetLinkModifierTitlePayload> TYPE = new Type<>(
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "set_link_modifier_title"));
public static final StreamCodec<RegistryFriendlyByteBuf, SetLinkModifierTitlePayload> STREAM_CODEC =
StreamCodec.composite(ByteBufCodecs.VAR_INT, SetLinkModifierTitlePayload::containerId,
ByteBufCodecs.STRING_UTF8, SetLinkModifierTitlePayload::title,
SetLinkModifierTitlePayload::new);
@Override public Type<? extends CustomPacketPayload> type() { return TYPE; }
}
@@ -5,6 +5,7 @@ import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity;
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity;
import net.dnorberg.rivencraft.block.entity.LinkModifierBlockEntity;
import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity;
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
@@ -31,6 +32,9 @@ public final class ModBlockEntities {
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<InkMixerBlockEntity>> INK_MIXER =
BLOCK_ENTITIES.register("ink_mixer", () -> BlockEntityType.Builder
.of(InkMixerBlockEntity::new, ModBlocks.INK_MIXER.get()).build(null));
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<LinkModifierBlockEntity>> LINK_MODIFIER =
BLOCK_ENTITIES.register("link_modifier", () -> BlockEntityType.Builder
.of(LinkModifierBlockEntity::new, ModBlocks.LINK_MODIFIER.get()).build(null));
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<StarFissureBlockEntity>> STAR_FISSURE =
BLOCK_ENTITIES.register("star_fissure", () -> BlockEntityType.Builder
.of(StarFissureBlockEntity::new, ModBlocks.STAR_FISSURE.get()).build(null));
@@ -5,6 +5,7 @@ import net.dnorberg.rivencraft.block.BookStandBlock;
import net.dnorberg.rivencraft.block.BookBinderBlock;
import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.dnorberg.rivencraft.block.InkMixerBlock;
import net.dnorberg.rivencraft.block.LinkModifierBlock;
import net.dnorberg.rivencraft.block.BuddingCrystalBlock;
import net.dnorberg.rivencraft.block.StarFissureBlock;
import net.dnorberg.rivencraft.block.DecayBlock;
@@ -40,6 +41,9 @@ public final class ModBlocks {
"ink_mixer", InkMixerBlock::new,
BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion()
);
public static final DeferredBlock<LinkModifierBlock> LINK_MODIFIER = BLOCKS.registerBlock(
"link_modifier", LinkModifierBlock::new,
BlockBehaviour.Properties.ofFullCopy(Blocks.CRAFTING_TABLE).noOcclusion());
public static final DeferredBlock<Block> CRYSTAL_BLOCK = BLOCKS.registerBlock("crystal_block",
Block::new, BlockBehaviour.Properties.ofFullCopy(Blocks.AMETHYST_BLOCK).mapColor(MapColor.COLOR_BLUE));
public static final DeferredBlock<BookReceptacleBlock> BOOK_RECEPTACLE = BLOCKS.registerBlock("book_receptacle",
@@ -27,9 +27,12 @@ public final class ModCreativeTabs {
output.accept(ModItems.DESCRIPTIVE_BOOK.get());
output.accept(ModItems.LINKING_BOOK.get());
output.accept(ModItems.NOTEBOOK.get());
output.accept(ModItems.COLLATION_FOLDER.get());
output.accept(ModItems.SEALED_NOTEBOOK.get());
output.accept(ModItems.INK_VIAL.get());
output.accept(ModItems.WRITING_DESK.get());
output.accept(ModItems.INK_MIXER.get());
output.accept(ModItems.LINK_MODIFIER.get());
output.accept(ModItems.BOOK_BINDER.get());
output.accept(ModItems.CRYSTAL_BLOCK.get());
output.accept(ModItems.BOOK_RECEPTACLE.get());
@@ -0,0 +1,30 @@
package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.MobCategory;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredRegister;
public final class ModEntities {
private static final DeferredRegister<EntityType<?>> ENTITIES =
DeferredRegister.create(Registries.ENTITY_TYPE, Rivencraft.MOD_ID);
public static final DeferredHolder<EntityType<?>, EntityType<DroppedBookEntity>> DROPPED_BOOK =
ENTITIES.register("dropped_book", () -> EntityType.Builder
.<DroppedBookEntity>of(DroppedBookEntity::new, MobCategory.MISC)
.sized(0.65F, 0.4F)
.clientTrackingRange(10)
.updateInterval(20)
.build(Rivencraft.MOD_ID + ":dropped_book"));
private ModEntities() {
}
public static void register(IEventBus bus) {
ENTITIES.register(bus);
}
}
@@ -5,6 +5,8 @@ import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.item.NotebookItem;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.dnorberg.rivencraft.item.SealedNotebookItem;
import net.dnorberg.rivencraft.item.InkVialItem;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.BlockItem;
@@ -35,6 +37,10 @@ public final class ModItems {
NotebookItem::new,
new Item.Properties().stacksTo(1)
);
public static final DeferredItem<CollationFolderItem> COLLATION_FOLDER = ITEMS.registerItem(
"collation_folder", CollationFolderItem::new, new Item.Properties().stacksTo(1));
public static final DeferredItem<SealedNotebookItem> SEALED_NOTEBOOK = ITEMS.registerItem(
"sealed_notebook", SealedNotebookItem::new, new Item.Properties().stacksTo(16));
public static final DeferredItem<InkVialItem> INK_VIAL = ITEMS.registerItem(
"ink_vial",
InkVialItem::new,
@@ -43,6 +49,7 @@ public final class ModItems {
public static final DeferredItem<BlockItem> WRITING_DESK = ITEMS.registerSimpleBlockItem(ModBlocks.WRITING_DESK);
public static final DeferredItem<BlockItem> BOOK_BINDER = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_BINDER);
public static final DeferredItem<BlockItem> INK_MIXER = ITEMS.registerSimpleBlockItem(ModBlocks.INK_MIXER);
public static final DeferredItem<BlockItem> LINK_MODIFIER = ITEMS.registerSimpleBlockItem(ModBlocks.LINK_MODIFIER);
public static final DeferredItem<BlockItem> CRYSTAL_BLOCK = ITEMS.registerSimpleBlockItem(ModBlocks.CRYSTAL_BLOCK);
public static final DeferredItem<BlockItem> BOOK_RECEPTACLE = ITEMS.registerSimpleBlockItem(ModBlocks.BOOK_RECEPTACLE);
public static final DeferredItem<BlockItem> BUDDING_CRYSTAL = ITEMS.registerSimpleBlockItem(ModBlocks.BUDDING_CRYSTAL);
@@ -5,6 +5,7 @@ import net.dnorberg.rivencraft.menu.NotebookMenu;
import net.dnorberg.rivencraft.menu.WritingDeskMenu;
import net.dnorberg.rivencraft.menu.BookBinderMenu;
import net.dnorberg.rivencraft.menu.InkMixerMenu;
import net.dnorberg.rivencraft.menu.LinkModifierMenu;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.inventory.MenuType;
import net.neoforged.bus.api.IEventBus;
@@ -24,6 +25,8 @@ public final class ModMenus {
MENUS.register("book_binder", () -> IMenuTypeExtension.create(BookBinderMenu::new));
public static final DeferredHolder<MenuType<?>, MenuType<InkMixerMenu>> INK_MIXER =
MENUS.register("ink_mixer", () -> IMenuTypeExtension.create(InkMixerMenu::new));
public static final DeferredHolder<MenuType<?>, MenuType<LinkModifierMenu>> LINK_MODIFIER =
MENUS.register("link_modifier", () -> IMenuTypeExtension.create(LinkModifierMenu::new));
private ModMenus() {
}
@@ -9,6 +9,7 @@ import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.item.CollationFolderItem;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
@@ -24,7 +25,7 @@ public final class WritingDeskCatalog {
for (int slot = 0; slot < WritingDeskBlockEntity.COLLECTIONS; slot++) {
ItemStack collection = desk.getItem(slot);
if (collection.is(ModItems.PAGE.get())) add(unique, collection);
if (collection.is(ModItems.NOTEBOOK.get())) {
if (CollationFolderItem.isCollection(collection)) {
List<ItemStack> pages = collection.getOrDefault(
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY).pages();
pages.forEach(page -> add(unique, page));
@@ -0,0 +1,8 @@
{
"variants": {
"facing=north": { "model": "rivencraft:block/link_modifier" },
"facing=east": { "model": "rivencraft:block/link_modifier", "y": 90 },
"facing=south": { "model": "rivencraft:block/link_modifier", "y": 180 },
"facing=west": { "model": "rivencraft:block/link_modifier", "y": 270 }
}
}
@@ -17,6 +17,18 @@
"item.rivencraft.link_property.intra_linking": "Intra Linking",
"item.rivencraft.page.unresolved": "Unresolved Symbol Page",
"item.rivencraft.notebook": "Notebook",
"item.rivencraft.collation_folder": "Collation Folder",
"item.rivencraft.sealed_notebook": "Sealed Notebook",
"block.rivencraft.link_modifier": "Link Modifier",
"container.rivencraft.link_modifier": "Link Modifier",
"screen.rivencraft.link_modifier.title": "Book title",
"screen.rivencraft.link_modifier.retire": "Retire Age",
"screen.rivencraft.link_modifier.confirm": "Confirm!",
"message.rivencraft.link_modifier.delete.retired": "Age retired. Its terrain was archived outside the active world.",
"message.rivencraft.link_modifier.delete.already_dead": "This Age is already retired.",
"message.rivencraft.link_modifier.delete.occupied": "Cannot retire an Age while players are inside it.",
"message.rivencraft.link_modifier.delete.unload_failed": "Could not unload this Age; its data is unchanged.",
"message.rivencraft.link_modifier.delete.storage_failed": "Could not archive this Age's data; it was not retired.",
"item.rivencraft.notebook.pages": "Pages: %s/%s",
"item.rivencraft.notebook.unresolved": "Unresolved pages: %s",
"item.rivencraft.ink_vial": "Ink Vial",
@@ -369,5 +381,21 @@
"symbol.rivencraft.corrupted.medium": "Corrupted Page",
"symbol.rivencraft.corrupted.medium.description": "The writing shifts beyond comprehension.",
"symbol.rivencraft.corrupted.small": "Corrupted Page",
"symbol.rivencraft.corrupted.small.description": "The writing shifts beyond comprehension."
"symbol.rivencraft.corrupted.small.description": "The writing shifts beyond comprehension.",
"advancement.rivencraft.root.title": "The Ending Has Not Yet Been Written",
"advancement.rivencraft.root.description": "Begin the Art of writing and linking Ages.",
"advancement.rivencraft.page.title": "A Strange Page",
"advancement.rivencraft.page.description": "Find a Page marked with the language of Ages.",
"advancement.rivencraft.linking_book.title": "A Way Back",
"advancement.rivencraft.linking_book.description": "Obtain a Linking Book and remember where you came from.",
"advancement.rivencraft.link_panel.title": "D'ni Ink",
"advancement.rivencraft.link_panel.description": "Create a Link Panel at the Ink Mixer.",
"advancement.rivencraft.copy_page.title": "The Art of Writing",
"advancement.rivencraft.copy_page.description": "Copy a Page at the Writing Desk.",
"advancement.rivencraft.bind_age.title": "A Book of Ages",
"advancement.rivencraft.bind_age.description": "Bind a completed Descriptive Book.",
"advancement.rivencraft.enter_age.title": "The Journey Begins",
"advancement.rivencraft.enter_age.description": "Safely link into an Age you have written.",
"advancement.rivencraft.open_portal.title": "A Window Between Ages",
"advancement.rivencraft.open_portal.description": "Activate a Crystal Portal with a bound book."
}
@@ -0,0 +1,120 @@
{
"parent": "block/block",
"textures": {
"particle": "rivencraft:block/link_modifier_base",
"0": "rivencraft:block/link_modifier_base",
"1": "rivencraft:block/link_modifier_details"
},
"elements": [
{
"from": [ 0.0, 0.0, 0.0 ],
"to": [ 16.0, 12.0, 16.0 ],
"faces": {
"north": { "texture": "#0", "uv": [ 0.0, 4.0, 4.0, 7.0 ] },
"east": { "texture": "#0", "uv": [ 4.0, 4.0, 8.0, 7.0 ] },
"south": { "texture": "#0", "uv": [ 8.0, 4.0, 12.0, 7.0 ] },
"west": { "texture": "#0", "uv": [ 12.0, 4.0, 16.0, 7.0 ] },
"up": { "texture": "#0", "uv": [ 4.0, 0.0, 8.0, 4.0 ] },
"down": { "texture": "#0", "uv": [ 8.0, 0.0, 12.0, 4.0 ] }
}
},
{
"from": [ 1.5, 12.0, 11.0 ],
"to": [ 3.5, 13.0, 13.0 ],
"faces": {
"north": { "texture": "#1", "uv": [ 0.0, 15.0, 1.0, 15.5 ] },
"east": { "texture": "#1", "uv": [ 0.0, 14.0, 1.0, 14.5 ] },
"south": { "texture": "#1", "uv": [ 0.0, 13.0, 1.0, 13.5 ] },
"west": { "texture": "#1", "uv": [ 1.0, 13.0, 1.0, 13.5 ] },
"up": { "texture": "#1", "uv": [ 2.0, 14.0, 3.0, 15.0 ] },
"down": { "texture": "#1", "uv": [ 1.0, 13.0, 2.0, 14.0 ] }
}
},
{
"from": [ 3.7, 12.0, 11.0 ],
"to": [ 5.7, 13.0, 13.0 ],
"faces": {
"north": { "texture": "#1", "uv": [ 0.0, 15.0, 1.0, 15.5 ] },
"east": { "texture": "#1", "uv": [ 0.0, 14.0, 1.0, 14.5 ] },
"south": { "texture": "#1", "uv": [ 0.0, 13.0, 1.0, 13.5 ] },
"west": { "texture": "#1", "uv": [ 1.0, 13.0, 1.0, 13.5 ] },
"up": { "texture": "#1", "uv": [ 2.0, 14.0, 3.0, 15.0 ] },
"down": { "texture": "#1", "uv": [ 1.0, 13.0, 2.0, 14.0 ] }
}
},
{
"from": [ 5.9, 12.0, 11.0 ],
"to": [ 7.9, 13.0, 13.0 ],
"faces": {
"north": { "texture": "#1", "uv": [ 0.0, 15.0, 1.0, 15.5 ] },
"east": { "texture": "#1", "uv": [ 0.0, 14.0, 1.0, 14.5 ] },
"south": { "texture": "#1", "uv": [ 0.0, 13.0, 1.0, 13.5 ] },
"west": { "texture": "#1", "uv": [ 1.0, 13.0, 1.0, 13.5 ] },
"up": { "texture": "#1", "uv": [ 2.0, 14.0, 3.0, 15.0 ] },
"down": { "texture": "#1", "uv": [ 1.0, 13.0, 2.0, 14.0 ] }
}
},
{
"from": [ 8.1, 12.0, 11.0 ],
"to": [ 10.1, 13.0, 13.0 ],
"faces": {
"north": { "texture": "#1", "uv": [ 0.0, 15.0, 1.0, 15.5 ] },
"east": { "texture": "#1", "uv": [ 0.0, 14.0, 1.0, 14.5 ] },
"south": { "texture": "#1", "uv": [ 0.0, 13.0, 1.0, 13.5 ] },
"west": { "texture": "#1", "uv": [ 1.0, 13.0, 1.0, 13.5 ] },
"up": { "texture": "#1", "uv": [ 2.0, 14.0, 3.0, 15.0 ] },
"down": { "texture": "#1", "uv": [ 1.0, 13.0, 2.0, 14.0 ] }
}
},
{
"from": [ 10.3, 12.0, 11.0 ],
"to": [ 12.3, 13.0, 13.0 ],
"faces": {
"north": { "texture": "#1", "uv": [ 0.0, 15.0, 1.0, 15.5 ] },
"east": { "texture": "#1", "uv": [ 0.0, 14.0, 1.0, 14.5 ] },
"south": { "texture": "#1", "uv": [ 0.0, 13.0, 1.0, 13.5 ] },
"west": { "texture": "#1", "uv": [ 1.0, 13.0, 1.0, 13.5 ] },
"up": { "texture": "#1", "uv": [ 2.0, 14.0, 3.0, 15.0 ] },
"down": { "texture": "#1", "uv": [ 1.0, 13.0, 2.0, 14.0 ] }
}
},
{
"from": [ 12.5, 12.0, 11.0 ],
"to": [ 14.5, 13.0, 13.0 ],
"faces": {
"north": { "texture": "#1", "uv": [ 0.0, 15.0, 1.0, 15.5 ] },
"east": { "texture": "#1", "uv": [ 0.0, 14.0, 1.0, 14.5 ] },
"south": { "texture": "#1", "uv": [ 0.0, 13.0, 1.0, 13.5 ] },
"west": { "texture": "#1", "uv": [ 1.0, 13.0, 1.0, 13.5 ] },
"up": { "texture": "#1", "uv": [ 2.0, 14.0, 3.0, 15.0 ] },
"down": { "texture": "#1", "uv": [ 1.0, 13.0, 2.0, 14.0 ] }
}
},
{
"from": [ 2.0, 9.4, 1.0 ],
"to": [ 14.0, 12.4, 8.0 ],
"rotation": { "origin": [ 8.0, 8.0, 8.0 ], "axis": "x", "angle": 22.5 },
"faces": {
"north": { "texture": "#1", "uv": [ 0.0, 4.0, 6.0, 5.5 ] },
"east": { "texture": "#1", "uv": [ 6.0, 4.0, 9.5, 5.5 ] },
"south": { "texture": "#1", "uv": [ 0.0, 6.0, 6.0, 7.5 ] },
"west": { "texture": "#1", "uv": [ 6.0, 6.0, 9.5, 7.5 ] },
"up": { "texture": "#1", "uv": [ 0.0, 0.0, 6.0, 3.5 ] },
"down": { "texture": "#1", "uv": [ 6.0, 0.0, 12.0, 3.5 ] }
}
},
{
"from": [ 4.0, 11.5, 3.5 ],
"to": [ 12.0, 15.5, 5.5 ],
"rotation": { "origin": [ 8.0, 8.0, 8.0 ], "axis": "x", "angle": 22.5 },
"faces": {
"north": { "texture": "#1", "uv": [ 1.0, 9.0, 5.0, 11.0 ] },
"east": { "texture": "#1", "uv": [ 0.0, 8.0, 1.0, 11.0 ] },
"south": { "texture": "#1", "uv": [ 6.0, 9.0, 10.0, 11.0 ] },
"west": { "texture": "#1", "uv": [ 5.0, 9.0, 6.0, 11.0 ] },
"up": { "texture": "#1", "uv": [ 1.0, 8.0, 5.0, 9.0 ] },
"down": { "texture": "#1", "uv": [ 5.0, 8.0, 9.0, 9.0 ] }
}
}
]
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/collation_folder" }
}
@@ -0,0 +1 @@
{ "parent": "rivencraft:block/link_modifier" }
@@ -0,0 +1,4 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/sealed_notebook" }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

@@ -0,0 +1,19 @@
{
"parent": "rivencraft:progression/link_panel",
"display": {
"icon": {
"id": "rivencraft:descriptive_book"
},
"title": {
"translate": "advancement.rivencraft.bind_age.title"
},
"description": {
"translate": "advancement.rivencraft.bind_age.description"
}
},
"criteria": {
"completed": {
"trigger": "minecraft:impossible"
}
}
}
@@ -0,0 +1,19 @@
{
"parent": "rivencraft:progression/page",
"display": {
"icon": {
"id": "rivencraft:writing_desk"
},
"title": {
"translate": "advancement.rivencraft.copy_page.title"
},
"description": {
"translate": "advancement.rivencraft.copy_page.description"
}
},
"criteria": {
"completed": {
"trigger": "minecraft:impossible"
}
}
}
@@ -0,0 +1,20 @@
{
"parent": "rivencraft:progression/bind_age",
"display": {
"icon": {
"id": "rivencraft:descriptive_book"
},
"title": {
"translate": "advancement.rivencraft.enter_age.title"
},
"description": {
"translate": "advancement.rivencraft.enter_age.description"
},
"frame": "goal"
},
"criteria": {
"completed": {
"trigger": "minecraft:impossible"
}
}
}
@@ -0,0 +1,19 @@
{
"parent": "rivencraft:progression/root",
"display": {
"icon": {
"id": "rivencraft:ink_mixer"
},
"title": {
"translate": "advancement.rivencraft.link_panel.title"
},
"description": {
"translate": "advancement.rivencraft.link_panel.description"
}
},
"criteria": {
"completed": {
"trigger": "minecraft:impossible"
}
}
}
@@ -0,0 +1,26 @@
{
"parent": "rivencraft:progression/root",
"display": {
"icon": {
"id": "rivencraft:linking_book"
},
"title": {
"translate": "advancement.rivencraft.linking_book.title"
},
"description": {
"translate": "advancement.rivencraft.linking_book.description"
}
},
"criteria": {
"completed": {
"trigger": "minecraft:inventory_changed",
"conditions": {
"items": [
{
"items": "rivencraft:linking_book"
}
]
}
}
}
}
@@ -0,0 +1,20 @@
{
"parent": "rivencraft:progression/linking_book",
"display": {
"icon": {
"id": "rivencraft:book_receptacle"
},
"title": {
"translate": "advancement.rivencraft.open_portal.title"
},
"description": {
"translate": "advancement.rivencraft.open_portal.description"
},
"frame": "goal"
},
"criteria": {
"completed": {
"trigger": "minecraft:impossible"
}
}
}
@@ -0,0 +1,26 @@
{
"parent": "rivencraft:progression/root",
"display": {
"icon": {
"id": "rivencraft:page"
},
"title": {
"translate": "advancement.rivencraft.page.title"
},
"description": {
"translate": "advancement.rivencraft.page.description"
}
},
"criteria": {
"completed": {
"trigger": "minecraft:inventory_changed",
"conditions": {
"items": [
{
"items": "rivencraft:page"
}
]
}
}
}
}
@@ -0,0 +1,21 @@
{
"display": {
"icon": {
"id": "rivencraft:descriptive_book"
},
"title": {
"translate": "advancement.rivencraft.root.title"
},
"description": {
"translate": "advancement.rivencraft.root.description"
},
"background": "minecraft:textures/gui/advancements/backgrounds/stone.png",
"show_toast": false,
"announce_to_chat": false
},
"criteria": {
"completed": {
"trigger": "minecraft:tick"
}
}
}
@@ -0,0 +1,32 @@
{
"parent": "minecraft:recipes/root",
"criteria": {
"has_material": {
"trigger": "minecraft:inventory_changed",
"conditions": {
"items": [
{
"items": "minecraft:iron_ingot"
}
]
}
},
"has_the_recipe": {
"trigger": "minecraft:recipe_unlocked",
"conditions": {
"recipe": "rivencraft:book_binder"
}
}
},
"requirements": [
[
"has_material",
"has_the_recipe"
]
],
"rewards": {
"recipes": [
"rivencraft:book_binder"
]
}
}
@@ -0,0 +1,32 @@
{
"parent": "minecraft:recipes/root",
"criteria": {
"has_material": {
"trigger": "minecraft:inventory_changed",
"conditions": {
"items": [
{
"items": "minecraft:glass_bottle"
}
]
}
},
"has_the_recipe": {
"trigger": "minecraft:recipe_unlocked",
"conditions": {
"recipe": "rivencraft:ink_mixer"
}
}
},
"requirements": [
[
"has_material",
"has_the_recipe"
]
],
"rewards": {
"recipes": [
"rivencraft:ink_mixer"
]
}
}
@@ -0,0 +1,32 @@
{
"parent": "minecraft:recipes/root",
"criteria": {
"has_material": {
"trigger": "minecraft:inventory_changed",
"conditions": {
"items": [
{
"items": "minecraft:leather"
}
]
}
},
"has_the_recipe": {
"trigger": "minecraft:recipe_unlocked",
"conditions": {
"recipe": "rivencraft:notebook"
}
}
},
"requirements": [
[
"has_material",
"has_the_recipe"
]
],
"rewards": {
"recipes": [
"rivencraft:notebook"
]
}
}
@@ -0,0 +1,32 @@
{
"parent": "minecraft:recipes/root",
"criteria": {
"has_material": {
"trigger": "minecraft:inventory_changed",
"conditions": {
"items": [
{
"items": "minecraft:feather"
}
]
}
},
"has_the_recipe": {
"trigger": "minecraft:recipe_unlocked",
"conditions": {
"recipe": "rivencraft:writing_desk"
}
}
},
"requirements": [
[
"has_material",
"has_the_recipe"
]
],
"rewards": {
"recipes": [
"rivencraft:writing_desk"
]
}
}
@@ -0,0 +1,4 @@
{
"type": "minecraft:block",
"pools": [{ "rolls": 1, "entries": [{ "type": "minecraft:item", "name": "rivencraft:link_modifier" }] }]
}
@@ -9,7 +9,7 @@
{ "type": "minecraft:item", "name": "minecraft:leather", "weight": 5,
"functions": [{ "function": "minecraft:set_count", "count": { "type": "minecraft:uniform", "min": 1, "max": 3 } }] },
{ "type": "minecraft:item", "name": "rivencraft:ink_vial", "weight": 4 },
{ "type": "minecraft:item", "name": "minecraft:book", "weight": 3 },
{ "type": "minecraft:item", "name": "rivencraft:sealed_notebook", "weight": 20 },
{ "type": "minecraft:item", "name": "minecraft:experience_bottle", "weight": 1 }
]
},
@@ -17,7 +17,7 @@
"rolls": { "type": "minecraft:uniform", "min": 1, "max": 2 },
"entries": [
{ "type": "minecraft:item", "name": "rivencraft:ink_vial", "weight": 3 },
{ "type": "minecraft:item", "name": "minecraft:book", "weight": 2 },
{ "type": "minecraft:item", "name": "rivencraft:sealed_notebook", "weight": 2 },
{ "type": "minecraft:item", "name": "minecraft:experience_bottle", "weight": 1 }
]
}