Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ae41da23e | ||
|
|
9cb63e6994 | ||
|
|
6337b5aa10 | ||
|
|
b9faa28dd5 |
No files matched your search
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 497 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 73 KiB |
@@ -40,6 +40,27 @@ profiles, not claims that Legacy had identically named terrain symbols.
|
||||
Every family retains a safe cobblestone arrival platform. An omitted terrain
|
||||
page still uses the deterministic seed-selected profile.
|
||||
|
||||
### Cave terrain and Legacy
|
||||
|
||||
Legacy's closest terrain generator is its Nether terrain symbol: it owns an
|
||||
Age-seeded octave-noise generator, accepts terrain/sea materials, and sets a
|
||||
sea level of 32. Legacy's separate Caves symbol instead registers a terrain
|
||||
alteration that carves additional caves; it is not a terrain generator.
|
||||
Minecraft 1.21.1 no longer has Legacy's 1.12.2 height and generation pipeline,
|
||||
so Rivencraft Cave terrain uses `minecraft:caves` noise and the modern Cave,
|
||||
Nether, and tagged modded Nether biome decorations. It cannot reproduce the
|
||||
exact Legacy noise or its older vertical layout. The Caves feature page
|
||||
remains a separate additive carver for other suitable terrain.
|
||||
|
||||
Cave Ages search for safe spawn footing from Y=64 downward rather than using
|
||||
the cave roof; if necessary, the usual cloud platform is placed at Y=63.
|
||||
Weather is clear and Serene Seasons is disabled in Cave terrain. Authored
|
||||
Weather, Season, and Celestial pages (including time modifiers) are retained
|
||||
in the book but have no effect on the Cave Age; each adds 100 base instability.
|
||||
The same policy applies to randomly selected Cave terrain and persists through
|
||||
save/reload. Cave biome selections remain limited to Cave and Nether biomes;
|
||||
surface-biome completion is not used for Cave terrain.
|
||||
|
||||
The separate **Floating Islands** feature page adds rare high-altitude island
|
||||
clusters above Standard, Amplified, Sky Islands, Flat, or Void terrain; it does
|
||||
not replace the terrain page. The feature is seeded by the Age and saved with the written
|
||||
|
||||
@@ -85,8 +85,9 @@ With optional JEI installed, search for the Ink Mixer category. It should show
|
||||
the base paper/ink/vial operation and separate entries for Gunpowder, Mushroom
|
||||
Stew, Experience Bottle, Ender Pearl, Feather, and Fire Charge,
|
||||
with their property chances. Black Dye and Clay Ball should not appear: the
|
||||
former had no working Legacy effect, while Generate Platform is redundant
|
||||
with default Age arrival. Without JEI, Rivencraft should still
|
||||
former had no working Legacy effect, while Clay Ball remains unsupported by
|
||||
the mixer. The creative Link Modifier can enable a single arrival platform for
|
||||
Linking Books; they do not create one by default. Without JEI, Rivencraft should still
|
||||
start and the mixer should work normally.
|
||||
|
||||
Put paper and an Ink Vial in the mixer. Hold Gunpowder on the cursor and
|
||||
|
||||
@@ -27,4 +27,4 @@ Implementation requirements: keep the mixer server-authoritative; persist mixtur
|
||||
|
||||
The built-in vanilla-item rows above are supported except black dye and clay balls. Legacy ignores black dye's empty effect key, so Rivencraft does not consume or advertise it in JEI. Clay Balls are omitted because Age arrival already builds a safe platform by default and a separate Generate Platform link modifier would be redundant. Right-clicking the Ink Mixer basin with a carried item adds one; left-clicking adds the carried stack. The mixture is persistent and its next panel is deterministic. The visual basin tooltip shows current probabilities. Only taking the virtual Link Panel consumes paper/ink and clears the mixture. The Link Panel's property component is copied to a Linking Book by the leather recipe or a Descriptive Book by the Binder. JEI's optional Ink Mixer category lists the base panel and each supported additive interaction with its per-property chance; Rivencraft does not require JEI to run.
|
||||
|
||||
Disarm and Maintain Momentum are applied by the server after confirming a safe destination. Disarm spawns each inventory stack as an item entity at the departure point, including for Descriptive Books. Intra Linking is required only for a Linking Book whose destination is in the player's current dimension; ordinary cross-dimensional Linking Books still work. The Binder treats an Intra Linking panel as an ordinary Link Panel and omits that flag from the resulting Descriptive Book, while retaining Disarm and Maintain Momentum. Automatic Return Links remain enabled under the existing configuration, as requested. The old Generate Platform bit remains readable on pre-0.4.7.1 test items but is neither rolled nor applied. Optional dust-tag mappings remain outside this built-in subset until their cross-mod tags and gameplay meaning are agreed.
|
||||
Disarm and Maintain Momentum are applied by the server after confirming a safe destination. Disarm spawns each inventory stack as an item entity at the departure point, including for Descriptive Books. Intra Linking is required only for a Linking Book whose destination is in the player's current dimension; ordinary cross-dimensional Linking Books still work. The Binder treats an Intra Linking panel as an ordinary Link Panel and omits that flag from the resulting Descriptive Book, while retaining Disarm and Maintain Momentum. Automatic Return Links remain enabled under the existing configuration, as requested. The Ink Mixer still does not roll Generate Platform, but since 0.6.7.1 the creative Link Modifier can explicitly enable it for Linking Books; their default remains off. Optional dust-tag mappings remain outside this built-in subset until their cross-mod tags and gameplay meaning are agreed.
|
||||
@@ -20,7 +20,7 @@ Milestone 5 issue #29 adds typed meaning to the existing data-pack symbol catalo
|
||||
|
||||
`poem` is optional, client-safe artwork metadata: exactly four alphabetic words, ordered top, right, bottom, left. The client layers their glyphs from the shared Legacy component sheet at runtime. GUI thumbnails use the full transparent artwork over their page backgrounds; item icons use the same artwork reduced from 16 to 13 pixels across, over the page texture. Unregistered words use a stable generated component combination; when a symbol has no poem, the six original built-in IDs retain their Legacy poems and other IDs produce four stable fallback words. This affects appearance only, never Age generation. Generated images are cached up to 256 entries and discarded on resource reload.
|
||||
|
||||
The Writing Desk's 88 collection tabs use Legacy's number components, chiefly the bottom row of `symbolcomponents.png`, rather than word sigils. Legacy's `ModWords.constructNumber` combines four unit marks (1–4) with four five-step marks (5–20) to form 1–24 and uses a separate zero mark. Rivencraft extends these marks positionally in base 25 for tabs 25–88 (for example, 25 is 1 followed by 0). Hovering still reveals the decimal number for accessibility. The former arbitrary-word glyph generator remains useful for rendering text: on page-free Descriptive Books, the generated Age description now appears as a sequence of layered word glyphs instead of Minecraft's enchantment font. The Age name stays readable.
|
||||
The Writing Desk retains 88 collection tabs, numbered from zero as in Legacy. Legacy's `ModWords.constructNumber` defines 0 as component 1 and 1–24 by overlaying a unit mark (1–4) and a five-step mark (5–20) from the bottom row of `symbolcomponents.png` in the *same square*. Legacy's Writing Desk has 25 tabs (0–24) and scrolls its four-tab window **one tab at a time**. Thus the supplied Legacy screenshots labeled 1–4, 5–8, and so on show overlapping windows, not 88 distinct numerals. Rivencraft retains its previously requested four-at-a-time navigation and 88 tabs. Its additional tab numbers use positional base 25: each place is an overlaid Legacy digit, with the places side by side (25 = 1,0). The two glyphs are sized and positioned to fit the tab. Hovering reveals the decimal tab index for accessibility. The generated-word mechanism renders page-free Descriptive Book text as layered glyphs instead of Minecraft's enchantment font. The Age name stays readable.
|
||||
|
||||
| Role | Value type and allowed values |
|
||||
| --- | --- |
|
||||
|
||||
@@ -36,7 +36,7 @@ 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.6.11
|
||||
mod_version=0.6.7.3
|
||||
immersive_portals_version=6.0.7
|
||||
terrablender_version=4.1.0.8
|
||||
serene_seasons_version_id=pHEgQQUE
|
||||
|
||||
@@ -11,7 +11,6 @@ import net.dnorberg.rivencraft.registry.ModItems;
|
||||
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.util.RandomSource;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
@@ -54,16 +53,16 @@ public final class CrystalPortalBlock extends BaseEntityBlock {
|
||||
@Override protected boolean canBeReplaced(BlockState state, Fluid fluid) { return false; }
|
||||
|
||||
@Override protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity) {
|
||||
if (!(level instanceof ServerLevel server) || !(entity instanceof ServerPlayer player)) return;
|
||||
if (!(level instanceof ServerLevel server)) return;
|
||||
long now = server.getServer().getTickCount();
|
||||
if (PortalTravelCooldown.blocked(player, now)) return;
|
||||
if (PortalTravelCooldown.blocked(entity, now)) return;
|
||||
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||
|| portal.receptacle() == null || !level.hasChunkAt(portal.receptacle())
|
||||
|| !(level.getBlockEntity(portal.receptacle()) instanceof BookReceptacleBlockEntity holder)) return;
|
||||
ItemStack book = holder.getBook();
|
||||
if (book.isEmpty()) return;
|
||||
PortalTravelCooldown.mark(player, now);
|
||||
PortalTravelQueue.enqueue(player, server, pos, portal.receptacle(), book, now);
|
||||
PortalTravelCooldown.mark(entity, now);
|
||||
PortalTravelQueue.enqueue(entity, server, pos, portal.receptacle(), book, now);
|
||||
}
|
||||
|
||||
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
|
||||
|
||||
@@ -118,4 +118,10 @@ public record AgeDefinition(
|
||||
createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode,
|
||||
randomFeatures, instability, instabilityPlan);
|
||||
}
|
||||
|
||||
/** A frozen authored profile takes precedence over the profile ID on old books. */
|
||||
public boolean caveTerrain() {
|
||||
return AgeBiomeSelection.isCave(plan == null
|
||||
? AgeRuntimeProfiles.resolve(generationProfile) : plan.runtimeProfile());
|
||||
}
|
||||
}
|
||||
@@ -101,9 +101,14 @@ public final class AgeDimensionService {
|
||||
|
||||
public static Optional<LinkTarget> resolve(ServerPlayer player, AgeReference reference,
|
||||
OrderedWriting writing, boolean generatePlatform) {
|
||||
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);
|
||||
return resolve(player.serverLevel(), reference, writing, generatePlatform);
|
||||
}
|
||||
|
||||
public static Optional<LinkTarget> resolve(ServerLevel source, AgeReference reference,
|
||||
OrderedWriting writing, boolean generatePlatform) {
|
||||
AgeDefinition definition = AgeRegistrySavedData.resolveOrCreate(source, reference, writing);
|
||||
if (AgeRegistrySavedData.get(source).isDead(definition.id())) return Optional.empty();
|
||||
ServerLevel level = ensureLoaded(source.getServer(), definition);
|
||||
if (level == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
@@ -179,6 +184,10 @@ public final class AgeDimensionService {
|
||||
}
|
||||
|
||||
private static void applyCelestialClock(ServerLevel level, AgeDefinition age) {
|
||||
if (age.caveTerrain()) {
|
||||
level.setDayTimePerTick(1.0F);
|
||||
return;
|
||||
}
|
||||
if (age.traits().hasStoppedCelestialCycle()) {
|
||||
// NeoForge rejects an exact zero rate. The smallest positive float
|
||||
// never advances the integer clock during a practical world
|
||||
@@ -207,6 +216,7 @@ public final class AgeDimensionService {
|
||||
}
|
||||
|
||||
private static void applyInitialCelestialPhase(ServerLevel level, AgeDefinition age) {
|
||||
if (age.caveTerrain()) return;
|
||||
long phaseTime = (long)(age.traits().celestialPhase() * Level.TICKS_PER_DAY);
|
||||
if (AgeEnvironmentCompiler.hasWrittenPhase(age.plan())) {
|
||||
// Legacy's Nadir is midnight, while vanilla day time zero is sunrise.
|
||||
@@ -227,7 +237,7 @@ public final class AgeDimensionService {
|
||||
|
||||
private static BlockPos createSafeSpawn(ServerLevel level, AgeDefinition age, boolean generatePlatform) {
|
||||
BlockPos floorCenter;
|
||||
if (age.generationProfile().equals(AgeGenerationProfiles.CAVE)) {
|
||||
if (age.caveTerrain()) {
|
||||
// Cave Ages are enclosed. Searching from the world ceiling would
|
||||
// select or expose their roof, so begin with the floor beneath a
|
||||
// player standing at Y=64 and search downward from there.
|
||||
|
||||
@@ -10,13 +10,23 @@ public final class AgeEnvironmentCompiler {
|
||||
|
||||
public static AgeTraits apply(AgeGenerationPlan plan, AgeTraits seeded) {
|
||||
if (plan == null) return seeded;
|
||||
AgeTraits applied;
|
||||
if (plan.version() >= 4) {
|
||||
AgeTraits base = applyLegacy(plan, seeded);
|
||||
CelestialWriting ordered = orderedCelestial(plan, seeded);
|
||||
return new AgeTraits(base.biomeDistribution(), base.lighting(), base.weather(),
|
||||
applied = new AgeTraits(base.biomeDistribution(), base.lighting(), base.weather(),
|
||||
ordered.period(), base.celestialAngle(), ordered.phase());
|
||||
} else {
|
||||
applied = applyLegacy(plan, seeded);
|
||||
}
|
||||
return applyLegacy(plan, seeded);
|
||||
return forTerrain(applied, plan.runtimeProfile());
|
||||
}
|
||||
|
||||
/** Cave terrain has no exposed weather or meaningful sky clock, even with authored pages. */
|
||||
public static AgeTraits forTerrain(AgeTraits traits, AgeRuntimeProfile profile) {
|
||||
if (!AgeBiomeSelection.isCave(profile)) return traits;
|
||||
return new AgeTraits(traits.biomeDistribution(), traits.lighting(), AgeTraits.Weather.OFF,
|
||||
1.0D, 0.0F, 0.0F);
|
||||
}
|
||||
|
||||
/** Version-3 plans keep their frozen, stage-sorted environment interpretation. */
|
||||
|
||||
@@ -15,6 +15,7 @@ import net.minecraft.resources.ResourceLocation;
|
||||
public record AgeInstability(int version, int score, List<Contribution> breakdown) {
|
||||
public static final int CURRENT_VERSION = 2;
|
||||
private static final int BRIGHT_LIGHTING_COST = 500;
|
||||
private static final int INEFFECTIVE_CAVE_PAGE_COST = 100;
|
||||
private static final int EXCESS_LEGACY_STRUCTURE_COST = 100;
|
||||
private static final int FORCED_STRUCTURE_BIOME_COST = 100;
|
||||
private static final Map<ResourceLocation, String> COUNTED_STRUCTURES = Map.ofEntries(
|
||||
@@ -88,7 +89,19 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
private static void addAuditedSymbolCosts(AgeGenerationPlan plan, RegistryAccess registries,
|
||||
List<Contribution> contributions) {
|
||||
Map<ResourceLocation, Integer> structureCounts = new HashMap<>();
|
||||
boolean cave = AgeBiomeSelection.isCave(plan.runtimeProfile());
|
||||
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
|
||||
if (cave) {
|
||||
var role = symbol.semantics().role();
|
||||
if (switch (role) {
|
||||
case WEATHER, SEASON, CELESTIAL, PERIOD_MODIFIER, PHASE_MODIFIER, ANGLE_MODIFIER -> true;
|
||||
default -> false;
|
||||
}) {
|
||||
contributions.add(new Contribution("cave_ignored_" + role.id(), symbol.index(),
|
||||
INEFFECTIVE_CAVE_PAGE_COST,
|
||||
"Sky or weather page has no effect in Cave terrain"));
|
||||
}
|
||||
}
|
||||
if (symbol.semantics().role()
|
||||
== net.dnorberg.rivencraft.writing.SymbolSemantics.Role.ENVIRONMENT
|
||||
&& symbol.semantics().value()
|
||||
|
||||
@@ -176,7 +176,9 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
ResourceLocation seedProfile = AgeRuntimeProfiles.selectForWriting(seed, writing, registries);
|
||||
AgeTraits traits = AgeTraits.select(seed);
|
||||
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed, seedProfile, traits, registries);
|
||||
AgeTraits effectiveTraits = AgeEnvironmentCompiler.apply(plan, traits);
|
||||
AgeTraits effectiveTraits = plan == null
|
||||
? AgeEnvironmentCompiler.forTerrain(traits, AgeRuntimeProfiles.resolve(seedProfile))
|
||||
: AgeEnvironmentCompiler.apply(plan, traits);
|
||||
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits, registries);
|
||||
AgeInstabilityPlan instabilityPlan = AgeInstabilityPlan.select(instability, seed);
|
||||
AgeDefinition created = new AgeDefinition(
|
||||
@@ -190,7 +192,8 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
null,
|
||||
effectiveTraits,
|
||||
plan,
|
||||
AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons")),
|
||||
AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons"),
|
||||
plan == null ? AgeRuntimeProfiles.resolve(seedProfile) : plan.runtimeProfile()),
|
||||
plan == null ? AgeFeatureSelection.randomChoices(seed,
|
||||
AgeRuntimeProfiles.resolve(seedProfile), registries) : List.of(),
|
||||
instability,
|
||||
|
||||
@@ -19,6 +19,12 @@ public enum AgeSeasonMode {
|
||||
|
||||
/** An omitted page is selected once when an Age is created with Serene installed. */
|
||||
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled) {
|
||||
return select(plan, ageSeed, sereneInstalled, plan == null ? null : plan.runtimeProfile());
|
||||
}
|
||||
|
||||
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled,
|
||||
AgeRuntimeProfile profile) {
|
||||
if (profile != null && AgeBiomeSelection.isCave(profile)) return NO_SEASONS;
|
||||
if (hasAuthoredPage(plan)) return from(plan);
|
||||
return sereneInstalled ? LegacyRankChoice.choose(new Random(ageSeed ^ RANDOM_SALT), List.of(
|
||||
new LegacyRankChoice.Option<>(SEASONS, 1),
|
||||
@@ -31,6 +37,7 @@ public enum AgeSeasonMode {
|
||||
|
||||
/** Existing Ages without a stored choice retain their old cycling behavior. */
|
||||
public static AgeSeasonMode from(AgeDefinition age) {
|
||||
if (age.caveTerrain()) return NO_SEASONS;
|
||||
return age.seasonMode() == null ? from(age.plan()) : age.seasonMode();
|
||||
}
|
||||
|
||||
@@ -49,6 +56,7 @@ public enum AgeSeasonMode {
|
||||
|
||||
public static AgeSeasonMode from(AgeGenerationPlan plan) {
|
||||
if (plan == null) return SEASONS;
|
||||
if (AgeBiomeSelection.isCave(plan.runtimeProfile())) return NO_SEASONS;
|
||||
return plan.activeSymbols().stream()
|
||||
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SEASON)
|
||||
.map(symbol -> valueOf(((SymbolSemantics.ChoiceValue) symbol.semantics().value())
|
||||
@@ -58,6 +66,7 @@ public enum AgeSeasonMode {
|
||||
|
||||
public static void validate(AgeGenerationPlan plan) {
|
||||
if (plan == null) return;
|
||||
if (AgeBiomeSelection.isCave(plan.runtimeProfile())) return;
|
||||
if (hasAuthoredPage(plan) && from(plan) != NO_SEASONS
|
||||
&& !ModList.get().isLoaded("sereneseasons")) {
|
||||
throw new IllegalArgumentException("This seasonal page requires Serene Seasons to create the Age");
|
||||
|
||||
@@ -26,7 +26,7 @@ public final class AgeWeatherService {
|
||||
return;
|
||||
}
|
||||
|
||||
AgeTraits.Weather weather = age.traits().weather();
|
||||
AgeTraits.Weather weather = age.caveTerrain() ? AgeTraits.Weather.OFF : age.traits().weather();
|
||||
if (weather == AgeTraits.Weather.NORMAL) {
|
||||
STATES.remove(level);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.level.biome.Biomes;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
/** Focused Cave terrain writing and persisted-generation coverage for issue #14. */
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class CaveTerrainGameTests {
|
||||
private CaveTerrainGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void caveSkyAndWeatherPagesCostInstabilityButDoNotApply(GameTestHelper helper) {
|
||||
OrderedWriting cave = writing("terrain/cave", "biome/lush_caves", "biome/nether_wastes",
|
||||
"biome/tiled", "weather/storm", "season/eternal_winter", "period/zero",
|
||||
"celestial/normal_time");
|
||||
AgeTraits seeded = new AgeTraits(AgeTraits.BiomeDistribution.SINGLE, AgeTraits.Lighting.NORMAL,
|
||||
AgeTraits.Weather.ALWAYS, 0.5D, 0.0F, 0.25F);
|
||||
var registries = helper.getLevel().registryAccess();
|
||||
AgeGenerationPlan plan = AgeGenerationPlan.compile(cave, 42L, AgeGenerationProfiles.CAVE,
|
||||
seeded, registries);
|
||||
AgeTraits applied = AgeEnvironmentCompiler.apply(plan, seeded);
|
||||
helper.assertValueEqual(applied.weather(), AgeTraits.Weather.OFF, "Cave weather must be off");
|
||||
helper.assertValueEqual(applied.celestialPeriod(), 1.0D, "Cave time pages must not alter the clock");
|
||||
helper.assertValueEqual(applied.celestialPhase(), 0.0F, "Cave time pages must not set a phase");
|
||||
helper.assertValueEqual(AgeSeasonMode.select(plan, 42L, true), AgeSeasonMode.NO_SEASONS,
|
||||
"Cave seasons must be disabled even when Serene Seasons is present");
|
||||
helper.assertValueEqual(plan.biomeSelection().biomes(),
|
||||
List.of(Biomes.LUSH_CAVES.location(), Biomes.NETHER_WASTES.location()),
|
||||
"Cave writing must retain exactly its cave and Nether biomes");
|
||||
AgeInstability instability = AgeInstability.compute(plan, applied, registries);
|
||||
helper.assertValueEqual(instability.breakdown().stream()
|
||||
.filter(part -> part.reason().startsWith("cave_ignored_")).count(), 4L,
|
||||
"Each ineffective weather, season, period, and celestial page must add instability");
|
||||
helper.assertValueEqual(AgeInstability.compute(AgeGenerationPlan.load(plan.save()), applied, registries),
|
||||
instability, "Cave page penalties must survive plan reload");
|
||||
|
||||
AgeGenerationPlan surfaceCaves = AgeGenerationPlan.compile(writing("terrain/standard", "feature/caves"),
|
||||
42L, AgeGenerationProfiles.STANDARD, seeded, registries);
|
||||
helper.assertTrue(!AgeBiomeSelection.isCave(surfaceCaves.runtimeProfile())
|
||||
&& AgeFeatureSelection.resolved(surfaceCaves).stream()
|
||||
.anyMatch(choice -> AgeFeatureSelection.caves(choice.id())),
|
||||
"The separate Caves feature page must still add caves to surface terrain");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty", timeoutTicks = 600)
|
||||
public static void authoredCaveBiomeAndSpawnSurviveSavedDataReload(GameTestHelper helper) {
|
||||
var player = helper.makeMockServerPlayerInLevel();
|
||||
AgeReference reference = new AgeReference(UUID.randomUUID(), "Cave issue 14 " + UUID.randomUUID());
|
||||
AgeDimensionService.resolve(player, reference,
|
||||
writing("terrain/cave", "biome/nether_wastes", "biome/single")).orElseThrow();
|
||||
var registry = AgeRegistrySavedData.get(helper.getLevel());
|
||||
AgeDefinition age = registry.find(reference.id()).orElseThrow();
|
||||
helper.assertTrue(age.caveTerrain(), "The Age must retain Cave terrain");
|
||||
helper.assertTrue(age.spawn() != null && age.spawn().getY() <= 64,
|
||||
"Cave spawn search must start at Y=64, not atop the roof");
|
||||
helper.assertValueEqual(age.traits().weather(), AgeTraits.Weather.OFF,
|
||||
"A new Cave Age must persist disabled weather");
|
||||
helper.assertValueEqual(age.seasonMode(), AgeSeasonMode.NO_SEASONS,
|
||||
"A new Cave Age must persist disabled seasons");
|
||||
AgeDefinition reloaded = AgeRegistrySavedData.load(
|
||||
registry.save(new CompoundTag(), helper.getLevel().registryAccess()),
|
||||
helper.getLevel().registryAccess()).find(reference.id()).orElseThrow();
|
||||
helper.assertValueEqual(reloaded, age, "Saved Cave biome, spawn, and environment must survive reload");
|
||||
helper.assertValueEqual(reloaded.plan().biomeSelection().biomes(),
|
||||
List.of(Biomes.NETHER_WASTES.location()),
|
||||
"The generated Cave Age must preserve its authored-only Nether pool");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static OrderedWriting writing(String... pages) {
|
||||
var entries = new java.util.ArrayList<WritingEntry>();
|
||||
entries.add(WritingEntry.linkPanel());
|
||||
for (String page : pages) entries.add(WritingEntry.symbol(new SymbolReference(
|
||||
ResourceLocation.parse("rivencraft:" + page))));
|
||||
return new OrderedWriting(entries);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import net.dnorberg.rivencraft.mixin.MobDropChanceAccessor;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.EquipmentSlot;
|
||||
import net.minecraft.world.entity.Mob;
|
||||
import net.minecraft.world.entity.animal.horse.AbstractHorse;
|
||||
import net.minecraft.world.entity.item.ItemEntity;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Legacy-style Disarm: leave carried items and eligible equipment at the departure point. */
|
||||
public final class LinkDisarmService {
|
||||
private LinkDisarmService() {}
|
||||
|
||||
public static boolean mayTravel(Entity entity) {
|
||||
// Legacy refuses loose items and dropped Link Books on Disarm links.
|
||||
return !(entity instanceof ItemEntity);
|
||||
}
|
||||
|
||||
public static void disarm(Entity entity) {
|
||||
if (entity instanceof ServerPlayer player) {
|
||||
eject(player, player.getInventory());
|
||||
player.containerMenu.broadcastChanges();
|
||||
}
|
||||
if (entity instanceof Container container) eject(entity, container);
|
||||
if (entity instanceof AbstractHorse horse) eject(entity, horse.getInventory());
|
||||
if (entity instanceof Mob mob) dropEquipment(mob);
|
||||
}
|
||||
|
||||
private static void eject(Entity carrier, Container inventory) {
|
||||
for (int slot = 0; slot < inventory.getContainerSize(); slot++) {
|
||||
ItemStack stack = inventory.getItem(slot);
|
||||
if (stack.isEmpty()) continue;
|
||||
inventory.setItem(slot, ItemStack.EMPTY);
|
||||
carrier.spawnAtLocation(stack);
|
||||
}
|
||||
inventory.setChanged();
|
||||
}
|
||||
|
||||
private static void dropEquipment(Mob mob) {
|
||||
for (EquipmentSlot slot : EquipmentSlot.values()) {
|
||||
ItemStack stack = mob.getItemBySlot(slot);
|
||||
if (stack.isEmpty()) continue;
|
||||
float chance = ((MobDropChanceAccessor) mob).rivencraft$getEquipmentDropChance(slot);
|
||||
mob.setItemSlot(slot, ItemStack.EMPTY);
|
||||
if (chance <= 0 || mob.getRandom().nextFloat() >= chance) continue;
|
||||
if (chance <= 1 && stack.isDamageableItem()) {
|
||||
int minimum = Math.max(stack.getMaxDamage() - 25, 1);
|
||||
int damage = stack.getMaxDamage() - mob.getRandom().nextInt(
|
||||
1 + mob.getRandom().nextInt(minimum));
|
||||
stack.setDamageValue(Math.clamp(damage, 1, minimum));
|
||||
}
|
||||
mob.spawnAtLocation(stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
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.world.entity.EquipmentSlot;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.entity.animal.horse.Donkey;
|
||||
import net.minecraft.world.entity.item.ItemEntity;
|
||||
import net.minecraft.world.entity.monster.Zombie;
|
||||
import net.minecraft.world.entity.vehicle.MinecartChest;
|
||||
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.minecraft.world.phys.Vec3;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class LinkModifierBehaviorGameTests {
|
||||
private LinkModifierBehaviorGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void maintainMomentumRotatesToDestinationYaw(GameTestHelper helper) {
|
||||
Vec3 result = LinkingService.rotatedMomentum(new Vec3(3, -0.5, 4), 90);
|
||||
helper.assertTrue(Math.abs(result.x) < 0.0001 && Math.abs(result.y + 0.3) < 0.0001
|
||||
&& Math.abs(result.z - 5) < 0.0001,
|
||||
"Legacy momentum preserves horizontal speed, rotates to destination yaw, and adds a small lift");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void disarmRefusesLooseItems(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
ItemEntity item = new ItemEntity(level, 1, 1, 1, new ItemStack(Items.DIAMOND));
|
||||
level.addFreshEntity(item);
|
||||
LinkTarget target = new LinkTarget(GlobalPos.of(level.dimension(), helper.absolutePos(new BlockPos(2, 1, 2))),
|
||||
0, 0);
|
||||
helper.assertValueEqual(LinkingService.travelEntityPortal(item, target,
|
||||
new LinkProperties(LinkProperties.DISARM | LinkProperties.INTRA_LINKING), false),
|
||||
LinkingService.LinkResult.DISARM_REJECTED,
|
||||
"Legacy Disarm must refuse a loose item before teleportation");
|
||||
helper.assertTrue(item.isAlive() && item.getItem().is(Items.DIAMOND),
|
||||
"Rejected item must remain at the source");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void ordinaryLinkMovesLooseItemWithoutMakingPlatform(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
BlockPos landing = helper.absolutePos(new BlockPos(2, 2, 2));
|
||||
level.setBlock(landing.below(), Blocks.AIR.defaultBlockState(), 2);
|
||||
level.setBlock(landing, Blocks.AIR.defaultBlockState(), 2);
|
||||
level.setBlock(landing.above(), Blocks.AIR.defaultBlockState(), 2);
|
||||
ItemEntity item = new ItemEntity(level, 1, 1, 1, new ItemStack(Items.DIAMOND));
|
||||
level.addFreshEntity(item);
|
||||
LinkTarget target = new LinkTarget(GlobalPos.of(level.dimension(), landing), 0, 0);
|
||||
helper.assertValueEqual(LinkingService.travelEntityPortal(item, target,
|
||||
new LinkProperties(LinkProperties.INTRA_LINKING), false),
|
||||
LinkingService.LinkResult.SUCCESS, "Ordinary portal links may carry loose items");
|
||||
helper.assertTrue(item.blockPosition().distManhattan(landing) <= 1,
|
||||
"Item must arrive at the linked location");
|
||||
helper.assertTrue(level.getBlockState(landing.below()).isAir(),
|
||||
"A Linking Book must not generate a landing platform by default");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void explicitGeneratePlatformAddsOnlyArrivalFloor(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
BlockPos landing = helper.absolutePos(new BlockPos(2, 2, 2));
|
||||
level.setBlock(landing.below(), Blocks.AIR.defaultBlockState(), 2);
|
||||
level.setBlock(landing, Blocks.AIR.defaultBlockState(), 2);
|
||||
level.setBlock(landing.above(), Blocks.AIR.defaultBlockState(), 2);
|
||||
level.setBlock(landing.above(2), Blocks.AIR.defaultBlockState(), 2);
|
||||
ItemEntity item = new ItemEntity(level, 1, 1, 1, new ItemStack(Items.DIAMOND));
|
||||
level.addFreshEntity(item);
|
||||
LinkTarget target = new LinkTarget(GlobalPos.of(level.dimension(), landing), 0, 0);
|
||||
helper.assertValueEqual(LinkingService.travelEntityPortal(item, target,
|
||||
new LinkProperties(LinkProperties.INTRA_LINKING | LinkProperties.GENERATE_PLATFORM), false),
|
||||
LinkingService.LinkResult.SUCCESS, "An explicit platform link must travel");
|
||||
helper.assertTrue(level.getBlockState(landing.below()).is(
|
||||
net.dnorberg.rivencraft.registry.ModBlocks.CLOUD_PLATFORM.get()),
|
||||
"An enabled Linking Book platform must add one cloud block below the arrival");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void disarmEmptiesCarrierAndMobEquipment(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
Vec3 source = Vec3.atCenterOf(helper.absolutePos(new BlockPos(1, 1, 1)));
|
||||
MinecartChest cart = EntityType.CHEST_MINECART.create(level);
|
||||
Donkey donkey = EntityType.DONKEY.create(level);
|
||||
Zombie zombie = EntityType.ZOMBIE.create(level);
|
||||
helper.assertTrue(cart != null && donkey != null && zombie != null, "Carriers must instantiate");
|
||||
cart.setPos(source);
|
||||
donkey.setPos(source);
|
||||
zombie.setPos(source);
|
||||
level.addFreshEntity(cart);
|
||||
level.addFreshEntity(donkey);
|
||||
level.addFreshEntity(zombie);
|
||||
cart.setItem(0, new ItemStack(Items.EMERALD));
|
||||
donkey.getInventory().setItem(0, new ItemStack(Items.SADDLE));
|
||||
zombie.setItemSlot(EquipmentSlot.MAINHAND, new ItemStack(Items.IRON_SWORD));
|
||||
zombie.setDropChance(EquipmentSlot.MAINHAND, 2.0F);
|
||||
|
||||
LinkDisarmService.disarm(cart);
|
||||
LinkDisarmService.disarm(donkey);
|
||||
LinkDisarmService.disarm(zombie);
|
||||
|
||||
helper.assertTrue(cart.getItem(0).isEmpty() && donkey.getInventory().getItem(0).isEmpty()
|
||||
&& zombie.getItemBySlot(EquipmentSlot.MAINHAND).isEmpty(),
|
||||
"Disarm must remove inventory contents and worn equipment");
|
||||
var drops = level.getEntitiesOfClass(ItemEntity.class, new AABB(source, source).inflate(3));
|
||||
helper.assertTrue(drops.stream().anyMatch(item -> item.getItem().is(Items.EMERALD))
|
||||
&& drops.stream().anyMatch(item -> item.getItem().is(Items.SADDLE))
|
||||
&& drops.stream().anyMatch(item -> item.getItem().is(Items.IRON_SWORD)),
|
||||
"Carrier items and guaranteed-drop mob equipment must remain at departure");
|
||||
helper.succeed();
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import net.minecraft.network.chat.Component;
|
||||
/** Flags written into a Link Panel and carried by books assembled from it. */
|
||||
public record LinkProperties(int flags) {
|
||||
public static final int DISARM = 1;
|
||||
/** Retained to read older test books; a newly edited book uses DISABLE_PLATFORM instead. */
|
||||
/** Linking Books do not create a platform unless this Legacy flag is enabled. */
|
||||
public static final int GENERATE_PLATFORM = 2;
|
||||
public static final int MAINTAIN_MOMENTUM = 4;
|
||||
public static final int INTRA_LINKING = 8;
|
||||
@@ -48,6 +48,7 @@ public record LinkProperties(int flags) {
|
||||
if (has(INTRA_LINKING)) lines.add(propertyName("intra_linking"));
|
||||
if (has(INTRA_LINKING_ONLY)) lines.add(propertyName("intra_linking_only"));
|
||||
if (has(RELATIVE_LINK)) lines.add(propertyName("relative_link"));
|
||||
if (has(GENERATE_PLATFORM)) lines.add(propertyName("generate_platform"));
|
||||
if (has(DISABLE_PLATFORM)) lines.add(propertyName("disable_platform"));
|
||||
}
|
||||
|
||||
@@ -63,6 +64,7 @@ public record LinkProperties(int flags) {
|
||||
case INTRA_LINKING -> propertyName("intra_linking");
|
||||
case INTRA_LINKING_ONLY -> propertyName("intra_linking_only");
|
||||
case RELATIVE_LINK -> propertyName("relative_link");
|
||||
case GENERATE_PLATFORM -> propertyName("generate_platform");
|
||||
case DISABLE_PLATFORM -> propertyName("disable_platform");
|
||||
default -> throw new IllegalArgumentException("Unknown link property flag: " + flag);
|
||||
};
|
||||
|
||||
@@ -6,16 +6,20 @@ import java.util.stream.IntStream;
|
||||
import java.util.stream.Stream;
|
||||
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
|
||||
import net.dnorberg.rivencraft.entity.DroppedBookEntity;
|
||||
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
|
||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||
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.entity.Entity;
|
||||
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;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import net.minecraft.world.level.portal.DimensionTransition;
|
||||
import net.neoforged.fml.ModList;
|
||||
|
||||
/** Server-side validation and execution for book travel. */
|
||||
@@ -96,9 +100,9 @@ public final class LinkingService {
|
||||
|
||||
Vec3 position = destination.get();
|
||||
Vec3 momentum = player.getDeltaMovement();
|
||||
if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player);
|
||||
if (properties.has(LinkProperties.DISARM)) LinkDisarmService.disarm(player);
|
||||
teleport(player, destinationLevel, position, target, true);
|
||||
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum);
|
||||
applyMomentum(player, momentum, target, properties);
|
||||
player.resetFallDistance();
|
||||
return LinkResult.SUCCESS;
|
||||
}
|
||||
@@ -114,9 +118,9 @@ public final class LinkingService {
|
||||
landingOrigin(player, destinationLevel, target, properties), true);
|
||||
if (destination.isEmpty()) return LinkResult.NO_SAFE_DESTINATION;
|
||||
Vec3 momentum = player.getDeltaMovement();
|
||||
if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player);
|
||||
if (properties.has(LinkProperties.DISARM)) LinkDisarmService.disarm(player);
|
||||
teleport(player, destinationLevel, destination.get(), target, instant);
|
||||
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum);
|
||||
applyMomentum(player, momentum, target, properties);
|
||||
player.resetFallDistance();
|
||||
return LinkResult.SUCCESS;
|
||||
}
|
||||
@@ -142,36 +146,80 @@ public final class LinkingService {
|
||||
|
||||
Vec3 position = destination.get();
|
||||
Vec3 momentum = player.getDeltaMovement();
|
||||
if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player);
|
||||
if (properties.has(LinkProperties.DISARM)) LinkDisarmService.disarm(player);
|
||||
teleport(player, destinationLevel, position, target, instant);
|
||||
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum);
|
||||
if (properties.has(LinkProperties.GENERATE_PLATFORM))
|
||||
generateArrivalPlatform(destinationLevel, position);
|
||||
applyMomentum(player, momentum, target, properties);
|
||||
player.resetFallDistance();
|
||||
return LinkResult.SUCCESS;
|
||||
}
|
||||
|
||||
/** Non-player portal travel follows the same link rules; Disarm may remove carried cargo. */
|
||||
public static LinkResult travelEntityPortal(Entity entity, LinkTarget target,
|
||||
LinkProperties properties, boolean requiresFooting) {
|
||||
if (!(entity.level() instanceof ServerLevel source)) return LinkResult.MISSING_DIMENSION;
|
||||
ServerLevel destinationLevel = source.getServer().getLevel(target.location().dimension());
|
||||
if (destinationLevel == null) return LinkResult.MISSING_DIMENSION;
|
||||
if (properties.has(LinkProperties.INTRA_LINKING_ONLY) && destinationLevel != source)
|
||||
return LinkResult.INTRA_ONLY;
|
||||
if (destinationLevel == source && !properties.has(LinkProperties.INTRA_LINKING)
|
||||
&& !properties.has(LinkProperties.INTRA_LINKING_ONLY)) return LinkResult.INTRA_LINKING_REQUIRED;
|
||||
if (properties.has(LinkProperties.DISARM) && !LinkDisarmService.mayTravel(entity))
|
||||
return LinkResult.DISARM_REJECTED;
|
||||
if (!entity.canChangeDimensions(source, destinationLevel)) return LinkResult.NO_SAFE_DESTINATION;
|
||||
BlockPos origin = landingOrigin(entity, destinationLevel, target, properties);
|
||||
Optional<Vec3> destination = resolveDestination(destinationLevel, origin, requiresFooting);
|
||||
if (destination.isEmpty()) return LinkResult.NO_SAFE_DESTINATION;
|
||||
|
||||
Vec3 momentum = entity.getDeltaMovement();
|
||||
if (properties.has(LinkProperties.DISARM)) LinkDisarmService.disarm(entity);
|
||||
Entity arrived = entity.changeDimension(new DimensionTransition(destinationLevel, destination.get(),
|
||||
properties.has(LinkProperties.MAINTAIN_MOMENTUM)
|
||||
? rotatedMomentum(momentum, target.yaw()) : Vec3.ZERO,
|
||||
target.yaw(), target.pitch(), DimensionTransition.DO_NOTHING));
|
||||
if (arrived == null) return LinkResult.NO_SAFE_DESTINATION;
|
||||
if (!requiresFooting && properties.has(LinkProperties.GENERATE_PLATFORM))
|
||||
generateArrivalPlatform(destinationLevel, destination.get());
|
||||
arrived.setXRot(target.pitch());
|
||||
arrived.resetFallDistance();
|
||||
PortalTravelCooldown.mark(arrived, source.getServer().getTickCount());
|
||||
return LinkResult.SUCCESS;
|
||||
}
|
||||
|
||||
static Vec3 rotatedMomentum(Vec3 incoming, float destinationYaw) {
|
||||
// Legacy rotates the horizontal speed to the destination yaw and adds a small upward lift.
|
||||
double horizontalSpeed = Math.hypot(incoming.x, incoming.z);
|
||||
double radians = Math.toRadians(destinationYaw);
|
||||
return new Vec3(horizontalSpeed * Math.cos(radians), incoming.y + 0.2,
|
||||
horizontalSpeed * Math.sin(radians));
|
||||
}
|
||||
|
||||
private static void applyMomentum(ServerPlayer player, Vec3 incoming, LinkTarget target,
|
||||
LinkProperties properties) {
|
||||
player.setDeltaMovement(properties.has(LinkProperties.MAINTAIN_MOMENTUM)
|
||||
? rotatedMomentum(incoming, target.yaw()) : Vec3.ZERO);
|
||||
}
|
||||
|
||||
private static void generateArrivalPlatform(ServerLevel level, Vec3 arrival) {
|
||||
BlockPos feet = BlockPos.containing(arrival);
|
||||
BlockPos floor = feet.below();
|
||||
// Legacy only fills a missing floor when there is clear space above the arrival.
|
||||
if (level.isEmptyBlock(floor) && level.isEmptyBlock(feet.above(2)))
|
||||
level.setBlock(floor, ModBlocks.CLOUD_PLATFORM.get().defaultBlockState(), 3);
|
||||
}
|
||||
|
||||
/** Legacy relative links retain the traveller's offset from the source spawn. */
|
||||
private static BlockPos landingOrigin(ServerPlayer player, ServerLevel destinationLevel,
|
||||
private static BlockPos landingOrigin(Entity player, ServerLevel destinationLevel,
|
||||
LinkTarget target, LinkProperties properties) {
|
||||
if (!properties.has(LinkProperties.RELATIVE_LINK)) return target.location().pos();
|
||||
BlockPos sourceSpawn = player.serverLevel().getSharedSpawnPos();
|
||||
BlockPos sourceSpawn = ((ServerLevel) player.level()).getSharedSpawnPos();
|
||||
BlockPos destinationSpawn = destinationLevel.getSharedSpawnPos();
|
||||
BlockPos departure = player.blockPosition();
|
||||
return destinationSpawn.offset(departure.getX() - sourceSpawn.getX(),
|
||||
departure.getY() - sourceSpawn.getY(), departure.getZ() - sourceSpawn.getZ());
|
||||
}
|
||||
|
||||
/** Drop each inventory stack as an item entity at the departure point. */
|
||||
private static void dropInventoryAtSource(ServerPlayer player) {
|
||||
var inventory = player.getInventory();
|
||||
for (int slot = 0; slot < inventory.getContainerSize(); slot++) {
|
||||
var stack = inventory.getItem(slot);
|
||||
if (stack.isEmpty()) continue;
|
||||
inventory.setItem(slot, net.minecraft.world.item.ItemStack.EMPTY);
|
||||
player.drop(stack, false);
|
||||
}
|
||||
player.containerMenu.broadcastChanges();
|
||||
}
|
||||
|
||||
private static void teleport(
|
||||
ServerPlayer player,
|
||||
ServerLevel destinationLevel,
|
||||
@@ -285,6 +333,7 @@ public final class LinkingService {
|
||||
MISSING_DIMENSION,
|
||||
INTRA_LINKING_REQUIRED,
|
||||
INTRA_ONLY,
|
||||
DISARM_REJECTED,
|
||||
NO_SAFE_DESTINATION
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,7 @@ public final class SymbolPageTextures {
|
||||
graphics.setColor(1F, 1F, 1F, 1F);
|
||||
}
|
||||
|
||||
/** Legacy's single-number marks; numbers above 24 use positional base 25. */
|
||||
/** Overlay each Legacy digit's components; place base-25 digits side by side. */
|
||||
public static void drawNumeral(GuiGraphics graphics, int number, int x, int y, int size) {
|
||||
int[] digits = SymbolNumerals.digitsFor(number);
|
||||
int digitSize = digits.length == 1 ? size : Math.max(8, size * 3 / 4);
|
||||
|
||||
@@ -6,6 +6,7 @@ import net.dnorberg.rivencraft.menu.LinkModifierMenu;
|
||||
import net.dnorberg.rivencraft.network.SetLinkModifierTitlePayload;
|
||||
import net.dnorberg.rivencraft.network.SetLinkModifierSeedPayload;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.EditBox;
|
||||
import net.minecraft.client.gui.components.Tooltip;
|
||||
@@ -137,9 +138,12 @@ public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifi
|
||||
@Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) {
|
||||
graphics.blit(BACKGROUND, leftPos, topPos, 0, 0, imageWidth, imageHeight, 256, 256);
|
||||
LinkProperties properties = menu.book().getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||
for (int index = 0; index < FLAGS.length; index++)
|
||||
for (int index = 0; index < FLAGS.length; index++) {
|
||||
boolean selected = FLAGS[index] == LinkProperties.DISABLE_PLATFORM
|
||||
? platformEnabled(properties) : properties.has(FLAGS[index]);
|
||||
square(graphics, 5 + (index / 3) * 20, 10 + (index % 3) * 20,
|
||||
properties.has(FLAGS[index]), false);
|
||||
selected, false);
|
||||
}
|
||||
if (menu.canDelete(minecraft.player)) {
|
||||
square(graphics, 120, 32, menu.awaitingDeletionConfirmation(), false);
|
||||
if (menu.awaitingDeletionConfirmation()) square(graphics, 140, 32, false, true);
|
||||
@@ -166,8 +170,8 @@ public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifi
|
||||
if (inside(mouseX, mouseY, 5 + (index / 3) * 20, 10 + (index % 3) * 20, 18, 18)) {
|
||||
Component label = FLAGS[index] == LinkProperties.DISABLE_PLATFORM
|
||||
? Component.literal("Generate Platform: "
|
||||
+ (menu.book().getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE)
|
||||
.has(LinkProperties.DISABLE_PLATFORM) ? "Off" : "On"))
|
||||
+ (platformEnabled(menu.book().getOrDefault(ModDataComponents.LINK_PROPERTIES.get(),
|
||||
LinkProperties.NONE)) ? "On" : "Off"))
|
||||
: LinkProperties.name(FLAGS[index]);
|
||||
graphics.renderTooltip(font, label, mouseX, mouseY);
|
||||
return;
|
||||
@@ -180,4 +184,10 @@ public final class LinkModifierScreen extends AbstractContainerScreen<LinkModifi
|
||||
? "screen.rivencraft.link_modifier.confirm" : "screen.rivencraft.link_modifier.retire"),
|
||||
mouseX, mouseY);
|
||||
}
|
||||
|
||||
private boolean platformEnabled(LinkProperties properties) {
|
||||
return menu.book().is(ModItems.LINKING_BOOK.get())
|
||||
? properties.has(LinkProperties.GENERATE_PLATFORM)
|
||||
: !properties.has(LinkProperties.DISABLE_PLATFORM);
|
||||
}
|
||||
}
|
||||
@@ -151,8 +151,8 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
|
||||
if (tab == menu.activeCollection() && !allMode) graphics.setColor(0.5F, 0.5F, 1F, 1F);
|
||||
graphics.blit(LEGACY_DESK, x, tabY, 0, 166, 58, 37);
|
||||
graphics.setColor(1F, 1F, 1F, 1F);
|
||||
// Encode the visible one-based tab number with Legacy's numeric word glyph.
|
||||
SymbolPageTextures.drawNumeral(graphics, tab + 1, x + 8, tabY + 3, 19);
|
||||
// Legacy numbers its Writing Desk collections from zero.
|
||||
SymbolPageTextures.drawNumeral(graphics, tab, x + (tab < 25 ? 8 : 4), tabY + 3, 19);
|
||||
ItemStack collection = menu.getSlot(tab).getItem();
|
||||
if (!collection.isEmpty()) {
|
||||
String name = collection.getHoverName().getString();
|
||||
@@ -227,7 +227,7 @@ public final class WritingDeskScreen extends AbstractContainerScreen<WritingDesk
|
||||
else if (mouseX < leftPos + 34 && localY >= 29 && localY < 177) {
|
||||
int tab = menu.firstVisibleTab() + (localY - 29) / 37;
|
||||
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.writing_desk.tab_symbol",
|
||||
tab + 1), mouseX, mouseY);
|
||||
tab), mouseX, mouseY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,14 @@ import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.book.LinkProperties;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
|
||||
import net.dnorberg.rivencraft.writing.InkModifierIngredients;
|
||||
import net.dnorberg.rivencraft.writing.PageContent;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.MutableComponent;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
@@ -63,6 +65,11 @@ public final class RivencraftJeiPlugin implements IModPlugin {
|
||||
case UNRESOLVED_SYMBOL -> "unresolved:" + content.symbol().orElseThrow().id();
|
||||
};
|
||||
});
|
||||
registration.registerSubtypeInterpreter(ModItems.SYMBOL_PORTFOLIO.get(),
|
||||
(IIngredientSubtypeInterpreter<ItemStack>) (stack, context) -> {
|
||||
Component name = stack.get(DataComponents.CUSTOM_NAME);
|
||||
return name == null ? "empty" : name.getString();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -72,6 +79,7 @@ public final class RivencraftJeiPlugin implements IModPlugin {
|
||||
var connection = Minecraft.getInstance().getConnection();
|
||||
if (connection != null) {
|
||||
pages.addAll(PageItem.creativeSymbolPages(connection.registryAccess()));
|
||||
pages.addAll(ModCreativeTabs.creativeSymbolPortfolios(connection.registryAccess()));
|
||||
} else {
|
||||
PageItem.bundledCreativeSymbols().stream().map(PageItem::createBundledCreativePage)
|
||||
.forEach(pages::add);
|
||||
@@ -84,6 +92,8 @@ public final class RivencraftJeiPlugin implements IModPlugin {
|
||||
// JEI discovers the base Page item as a blank stack; only authored pages belong in its list.
|
||||
runtime.getIngredientVisibility().hideIngredients(VanillaTypes.ITEM_STACK,
|
||||
List.of(new ItemStack(ModItems.PAGE.get())), List.of(UidContext.Ingredient));
|
||||
runtime.getIngredientVisibility().hideIngredients(VanillaTypes.ITEM_STACK,
|
||||
List.of(new ItemStack(ModItems.SYMBOL_PORTFOLIO.get())), List.of(UidContext.Ingredient));
|
||||
var connection = Minecraft.getInstance().getConnection();
|
||||
if (connection == null) return;
|
||||
Set<ResourceLocation> known = runtime.getIngredientManager().getAllItemStacks().stream()
|
||||
@@ -98,6 +108,19 @@ public final class RivencraftJeiPlugin implements IModPlugin {
|
||||
if (!missing.isEmpty()) {
|
||||
runtime.getIngredientManager().addIngredientsAtRuntime(VanillaTypes.ITEM_STACK, missing);
|
||||
}
|
||||
Set<String> knownPortfolios = runtime.getIngredientManager().getAllItemStacks().stream()
|
||||
.filter(stack -> stack.is(ModItems.SYMBOL_PORTFOLIO.get()))
|
||||
.map(stack -> stack.get(DataComponents.CUSTOM_NAME))
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.map(Component::getString)
|
||||
.collect(Collectors.toSet());
|
||||
List<ItemStack> missingPortfolios = ModCreativeTabs.creativeSymbolPortfolios(connection.registryAccess())
|
||||
.stream()
|
||||
.filter(stack -> !knownPortfolios.contains(stack.getHoverName().getString()))
|
||||
.toList();
|
||||
if (!missingPortfolios.isEmpty()) {
|
||||
runtime.getIngredientManager().addIngredientsAtRuntime(VanillaTypes.ITEM_STACK, missingPortfolios);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package net.dnorberg.rivencraft.gametest;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.CollationFolderItem;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.item.SymbolPortfolioItem;
|
||||
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.dnorberg.rivencraft.writing.PageContent;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class SymbolPortfolioGameTests {
|
||||
private SymbolPortfolioGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void portfoliosCoverEverySymbolCategoryWithoutTruncation(GameTestHelper helper) {
|
||||
var registries = helper.getLevel().registryAccess();
|
||||
List<ItemStack> creative = ModCreativeTabs.creativeSymbolPortfolios(registries);
|
||||
helper.assertTrue(!creative.isEmpty(), "Creative tab must include prefilled portfolios");
|
||||
for (ResourceLocation category : PageItem.bundledCreativeCategories()) {
|
||||
List<ItemStack> expected = PageItem.creativeSymbolPages(registries, category);
|
||||
List<ItemStack> volumes = SymbolPortfolioItem.createVolumes(category, expected);
|
||||
helper.assertTrue(!volumes.isEmpty(), "Missing portfolio for " + category);
|
||||
List<ItemStack> actual = new ArrayList<>();
|
||||
for (ItemStack volume : volumes) {
|
||||
helper.assertTrue(volume.is(ModItems.SYMBOL_PORTFOLIO.get())
|
||||
&& CollationFolderItem.isCollection(volume)
|
||||
&& creative.stream().anyMatch(candidate ->
|
||||
candidate.getHoverName().getString().equals(volume.getHoverName().getString())),
|
||||
"Portfolio must be an editable creative collection for " + category);
|
||||
NotebookContents contents = volume.get(ModDataComponents.NOTEBOOK_CONTENTS.get());
|
||||
helper.assertTrue(contents != null && contents.pageCount() > 0
|
||||
&& contents.pageCount() <= NotebookContents.MAX_PAGES,
|
||||
"Each portfolio volume must contain valid pages");
|
||||
helper.assertTrue(contents.pages().stream().allMatch(page ->
|
||||
PageItem.content(page).kind() == PageContent.Kind.SYMBOL),
|
||||
"Portfolios must contain symbol pages, not Link Panels");
|
||||
actual.addAll(contents.pages());
|
||||
}
|
||||
helper.assertTrue(actual.size() == expected.size(), "Portfolio omitted pages in " + category);
|
||||
for (int index = 0; index < expected.size(); index++) {
|
||||
helper.assertTrue(ItemStack.isSameItemSameComponents(actual.get(index), expected.get(index)),
|
||||
"Portfolio pages must match the category tab in " + category);
|
||||
}
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
}
|
||||
@@ -151,11 +151,23 @@ public final class WritingDataGameTests {
|
||||
"Zero must use Legacy's dedicated number mark");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(1), new int[] {56}),
|
||||
"One must use Legacy's first bottom-row mark");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(5), new int[] {60}),
|
||||
"Five must use Legacy's first five-step mark");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(10), new int[] {61}),
|
||||
"Ten must use Legacy's second five-step mark");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(14), new int[] {61, 59}),
|
||||
"Fourteen must overlay the ten and four marks");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(20), new int[] {63}),
|
||||
"Twenty must use Legacy's final five-step mark");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.componentsForDigit(24), new int[] {63, 59}),
|
||||
"Twenty-four must combine Legacy's five and unit marks");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(25), new int[] {1, 0}),
|
||||
"Twenty-five must start a second base-25 place");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(88), new int[] {3, 13}),
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(49), new int[] {1, 24}),
|
||||
"Forty-nine must use twenty-four in its ones place");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(50), new int[] {2, 0}),
|
||||
"Fifty must carry into the second base-25 place");
|
||||
helper.assertTrue(Arrays.equals(SymbolNumerals.digitsFor(87), new int[] {3, 12}),
|
||||
"Last writing-desk tab must retain its numeric value");
|
||||
|
||||
String display = "\"name\":\"symbol.example.test\","
|
||||
|
||||
@@ -13,11 +13,13 @@ public final class CollationFolderItem extends NotebookItem {
|
||||
}
|
||||
|
||||
public static boolean isCollection(ItemStack stack) {
|
||||
return stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.COLLATION_FOLDER.get());
|
||||
return stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.COLLATION_FOLDER.get())
|
||||
|| stack.is(ModItems.SYMBOL_PORTFOLIO.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;
|
||||
: stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.SYMBOL_PORTFOLIO.get())
|
||||
? NotebookContents.MAX_PAGES : 0;
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,7 @@ public class NotebookItem extends Item {
|
||||
NotebookContents contents = stack.getOrDefault(
|
||||
ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY);
|
||||
tooltip.add(Component.translatable("item.rivencraft.notebook.pages",
|
||||
contents.pageCount(), stack.is(net.dnorberg.rivencraft.registry.ModItems.COLLATION_FOLDER.get())
|
||||
? CollationFolderItem.MAX_PAGES : NotebookContents.MAX_PAGES)
|
||||
contents.pageCount(), CollationFolderItem.capacity(stack))
|
||||
.withStyle(ChatFormatting.GRAY));
|
||||
if (contents.unresolvedCount() > 0) {
|
||||
tooltip.add(Component.translatable("item.rivencraft.notebook.unresolved",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Non-craftable, prefilled Legacy-style collections of one symbol-page category. */
|
||||
public final class SymbolPortfolioItem extends NotebookItem {
|
||||
public SymbolPortfolioItem(Properties properties) {
|
||||
super(properties);
|
||||
}
|
||||
|
||||
public static List<ItemStack> createVolumes(ResourceLocation category, List<ItemStack> pages) {
|
||||
if (pages.isEmpty()) return List.of();
|
||||
int capacity = NotebookContents.MAX_PAGES;
|
||||
int volumeCount = (pages.size() + capacity - 1) / capacity;
|
||||
List<ItemStack> volumes = new ArrayList<>(volumeCount);
|
||||
for (int volume = 0; volume < volumeCount; volume++) {
|
||||
ItemStack portfolio = new ItemStack(ModItems.SYMBOL_PORTFOLIO.get());
|
||||
int first = volume * capacity;
|
||||
int last = Math.min(first + capacity, pages.size());
|
||||
portfolio.set(ModDataComponents.NOTEBOOK_CONTENTS.get(),
|
||||
new NotebookContents(pages.subList(first, last)));
|
||||
Component categoryName = Component.translatableWithFallback(
|
||||
"itemGroup.rivencraft.pages." + category.getPath(), category.getPath());
|
||||
portfolio.set(DataComponents.CUSTOM_NAME, volumeCount == 1
|
||||
? Component.translatable("item.rivencraft.symbol_portfolio.named", categoryName)
|
||||
: Component.translatable("item.rivencraft.symbol_portfolio.volume",
|
||||
categoryName, volume + 1, volumeCount));
|
||||
volumes.add(portfolio);
|
||||
}
|
||||
return List.copyOf(volumes);
|
||||
}
|
||||
}
|
||||
@@ -142,7 +142,8 @@ public final class LinkModifierMenu extends AbstractContainerMenu {
|
||||
case TOGGLE_INTRA -> LinkProperties.INTRA_LINKING;
|
||||
case TOGGLE_INTRA_ONLY -> LinkProperties.INTRA_LINKING_ONLY;
|
||||
case TOGGLE_RELATIVE -> LinkProperties.RELATIVE_LINK;
|
||||
case TOGGLE_PLATFORM -> LinkProperties.DISABLE_PLATFORM;
|
||||
case TOGGLE_PLATFORM -> book().is(ModItems.LINKING_BOOK.get())
|
||||
? LinkProperties.GENERATE_PLATFORM : LinkProperties.DISABLE_PLATFORM;
|
||||
default -> 0;
|
||||
};
|
||||
if (flag == 0 || ((flag == LinkProperties.INTRA_LINKING || flag == LinkProperties.INTRA_LINKING_ONLY)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package net.dnorberg.rivencraft.mixin;
|
||||
|
||||
import net.minecraft.world.entity.EquipmentSlot;
|
||||
import net.minecraft.world.entity.Mob;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Invoker;
|
||||
|
||||
@Mixin(Mob.class)
|
||||
public interface MobDropChanceAccessor {
|
||||
@Invoker("getEquipmentDropChance")
|
||||
float rivencraft$getEquipmentDropChance(EquipmentSlot slot);
|
||||
}
|
||||
@@ -2,12 +2,13 @@ package net.dnorberg.rivencraft.portal;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
|
||||
/** Session-only portal cooldowns: tick counters must never be saved with players. */
|
||||
public final class PortalTravelCooldown {
|
||||
public static final long TICKS = 40;
|
||||
private static final Map<ServerPlayer, Long> LAST_USE = new WeakHashMap<>();
|
||||
private static final Map<Entity, Long> LAST_USE = new WeakHashMap<>();
|
||||
|
||||
private PortalTravelCooldown() {}
|
||||
|
||||
@@ -15,16 +16,16 @@ public final class PortalTravelCooldown {
|
||||
return previous >= 0 && now >= previous && now - previous < TICKS;
|
||||
}
|
||||
|
||||
public static boolean blocked(ServerPlayer player, long now) {
|
||||
return blocked(now, LAST_USE.getOrDefault(player, -1L));
|
||||
public static boolean blocked(Entity entity, long now) {
|
||||
return blocked(now, LAST_USE.getOrDefault(entity, -1L));
|
||||
}
|
||||
|
||||
public static void mark(ServerPlayer player, long now) {
|
||||
LAST_USE.put(player, now);
|
||||
public static void mark(Entity entity, long now) {
|
||||
LAST_USE.put(entity, now);
|
||||
}
|
||||
|
||||
public static void forget(ServerPlayer player) {
|
||||
LAST_USE.remove(player);
|
||||
public static void forget(Entity entity) {
|
||||
LAST_USE.remove(entity);
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
|
||||
@@ -7,34 +7,43 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
||||
import net.dnorberg.rivencraft.book.AgeDimensionService;
|
||||
import net.dnorberg.rivencraft.book.AgeReference;
|
||||
import net.dnorberg.rivencraft.book.LinkDisarmService;
|
||||
import net.dnorberg.rivencraft.book.LinkProperties;
|
||||
import net.dnorberg.rivencraft.book.LinkTarget;
|
||||
import net.dnorberg.rivencraft.book.LinkingService;
|
||||
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
|
||||
import net.dnorberg.rivencraft.item.LinkingBookItem;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/** Defers block-collision travel until the movement packet that entered the portal has finished. */
|
||||
public final class PortalTravelQueue {
|
||||
private static final Map<ServerPlayer, Pending> PENDING = new IdentityHashMap<>();
|
||||
private static final Map<Entity, Pending> PENDING = new IdentityHashMap<>();
|
||||
|
||||
private record Pending(ServerLevel source, BlockPos portal, BlockPos receptacle,
|
||||
ItemStack book, long queuedTick) {}
|
||||
private record Travel(ServerPlayer player, Pending pending) {}
|
||||
private record Travel(Entity entity, Pending pending) {}
|
||||
|
||||
private PortalTravelQueue() {}
|
||||
|
||||
public static void enqueue(ServerPlayer player, ServerLevel source, BlockPos portal,
|
||||
public static void enqueue(Entity entity, ServerLevel source, BlockPos portal,
|
||||
BlockPos receptacle, ItemStack book, long now) {
|
||||
PENDING.putIfAbsent(player, new Pending(source, portal.immutable(), receptacle.immutable(),
|
||||
PENDING.putIfAbsent(entity, new Pending(source, portal.immutable(), receptacle.immutable(),
|
||||
book.copyWithCount(1), now));
|
||||
}
|
||||
|
||||
public static void process(MinecraftServer server) {
|
||||
List<Travel> ready = new ArrayList<>();
|
||||
Iterator<Map.Entry<ServerPlayer, Pending>> iterator = PENDING.entrySet().iterator();
|
||||
Iterator<Map.Entry<Entity, Pending>> iterator = PENDING.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
var entry = iterator.next();
|
||||
if (server.getTickCount() <= entry.getValue().queuedTick()) continue;
|
||||
@@ -44,21 +53,45 @@ public final class PortalTravelQueue {
|
||||
// Travel can itself cause block-collision callbacks, so do not keep an
|
||||
// iterator over the pending map while changing the player's dimension.
|
||||
for (Travel travel : ready) {
|
||||
ServerPlayer player = travel.player();
|
||||
Entity entity = travel.entity();
|
||||
Pending pending = travel.pending();
|
||||
if (server.getPlayerList().getPlayer(player.getUUID()) != player
|
||||
|| !player.isAlive() || player.serverLevel() != pending.source()
|
||||
if ((entity instanceof ServerPlayer player
|
||||
&& server.getPlayerList().getPlayer(player.getUUID()) != player)
|
||||
|| entity.isRemoved() || !entity.isAlive() || entity.level() != pending.source()
|
||||
|| !(pending.source().getBlockEntity(pending.portal()) instanceof CrystalPortalBlockEntity portal)
|
||||
|| !pending.receptacle().equals(portal.receptacle())
|
||||
|| !(pending.source().getBlockEntity(pending.receptacle()) instanceof BookReceptacleBlockEntity holder)
|
||||
|| !ItemStack.isSameItemSameComponents(holder.getBook(), pending.book())) continue;
|
||||
if (pending.book().is(ModItems.LINKING_BOOK.get()))
|
||||
LinkingBookItem.usePortalBook(player, pending.book());
|
||||
else if (pending.book().is(ModItems.DESCRIPTIVE_BOOK.get()))
|
||||
DescriptiveBookItem.usePortalBook(player, pending.book());
|
||||
if (entity instanceof ServerPlayer player) {
|
||||
if (pending.book().is(ModItems.LINKING_BOOK.get()))
|
||||
LinkingBookItem.usePortalBook(player, pending.book());
|
||||
else if (pending.book().is(ModItems.DESCRIPTIVE_BOOK.get()))
|
||||
DescriptiveBookItem.usePortalBook(player, pending.book());
|
||||
} else {
|
||||
travelEntity(entity, pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void travelEntity(Entity entity, Pending pending) {
|
||||
ItemStack book = pending.book();
|
||||
LinkProperties properties = book.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||
if (properties.has(LinkProperties.DISARM) && !LinkDisarmService.mayTravel(entity)) return;
|
||||
LinkTarget target;
|
||||
boolean descriptive = book.is(ModItems.DESCRIPTIVE_BOOK.get());
|
||||
if (descriptive) {
|
||||
AgeReference reference = book.get(ModDataComponents.AGE_REFERENCE.get());
|
||||
if (reference == null) return;
|
||||
properties = properties.forDescriptiveBook();
|
||||
target = AgeDimensionService.resolve(pending.source(), reference, OrderedWriting.EMPTY,
|
||||
!properties.has(LinkProperties.DISABLE_PLATFORM)).orElse(null);
|
||||
} else if (book.is(ModItems.LINKING_BOOK.get())) {
|
||||
target = book.get(ModDataComponents.LINK_TARGET.get());
|
||||
} else return;
|
||||
if (target == null) return;
|
||||
LinkingService.travelEntityPortal(entity, target, properties, descriptive);
|
||||
}
|
||||
|
||||
public static void forget(ServerPlayer player) { PENDING.remove(player); }
|
||||
public static void clear() { PENDING.clear(); }
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.item.SymbolPortfolioItem;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
@@ -28,6 +31,7 @@ public final class ModCreativeTabs {
|
||||
output.accept(ModItems.LINKING_BOOK.get());
|
||||
output.accept(ModItems.NOTEBOOK.get());
|
||||
output.accept(ModItems.COLLATION_FOLDER.get());
|
||||
creativeSymbolPortfolios(parameters.holders()).forEach(output::accept);
|
||||
output.accept(ModItems.SEALED_NOTEBOOK.get());
|
||||
output.accept(ModItems.INK_VIAL.get());
|
||||
output.accept(ModItems.WRITING_DESK.get());
|
||||
@@ -59,14 +63,28 @@ public final class ModCreativeTabs {
|
||||
public static final Map<ResourceLocation, DeferredHolder<CreativeModeTab, CreativeModeTab>> SYMBOL_PAGES =
|
||||
registerSymbolPageTabs();
|
||||
|
||||
private static Map<ResourceLocation, DeferredHolder<CreativeModeTab, CreativeModeTab>> registerSymbolPageTabs() {
|
||||
/** Prefilled portfolios mirror the symbol-page tabs, including data-pack and modded pages. */
|
||||
public static List<ItemStack> creativeSymbolPortfolios(HolderLookup.Provider registries) {
|
||||
List<ItemStack> portfolios = new ArrayList<>();
|
||||
for (ResourceLocation category : orderedSymbolCategories()) {
|
||||
portfolios.addAll(SymbolPortfolioItem.createVolumes(category,
|
||||
PageItem.creativeSymbolPages(registries, category)));
|
||||
}
|
||||
return List.copyOf(portfolios);
|
||||
}
|
||||
|
||||
private static List<ResourceLocation> orderedSymbolCategories() {
|
||||
List<ResourceLocation> categories = new ArrayList<>(PageItem.bundledCreativeCategories());
|
||||
categories.sort(Comparator.comparingInt((ResourceLocation id) -> {
|
||||
int index = CATEGORY_ORDER.indexOf(id.getPath());
|
||||
return index < 0 ? CATEGORY_ORDER.size() : index;
|
||||
}).thenComparing(ResourceLocation::toString));
|
||||
return List.copyOf(categories);
|
||||
}
|
||||
|
||||
private static Map<ResourceLocation, DeferredHolder<CreativeModeTab, CreativeModeTab>> registerSymbolPageTabs() {
|
||||
Map<ResourceLocation, DeferredHolder<CreativeModeTab, CreativeModeTab>> tabs = new LinkedHashMap<>();
|
||||
for (ResourceLocation category : categories) {
|
||||
for (ResourceLocation category : orderedSymbolCategories()) {
|
||||
String key = "rivencraft_pages_" + category.getNamespace() + "_" + category.getPath().replace('/', '_');
|
||||
tabs.put(category, CREATIVE_TABS.register(key, () -> CreativeModeTab.builder()
|
||||
.title(Component.translatableWithFallback("itemGroup.rivencraft.pages." + category.getPath(),
|
||||
|
||||
@@ -6,6 +6,7 @@ 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.SymbolPortfolioItem;
|
||||
import net.dnorberg.rivencraft.item.SealedNotebookItem;
|
||||
import net.dnorberg.rivencraft.item.InkVialItem;
|
||||
import net.minecraft.world.item.Item;
|
||||
@@ -39,6 +40,8 @@ public final class ModItems {
|
||||
);
|
||||
public static final DeferredItem<CollationFolderItem> COLLATION_FOLDER = ITEMS.registerItem(
|
||||
"collation_folder", CollationFolderItem::new, new Item.Properties().stacksTo(1));
|
||||
public static final DeferredItem<SymbolPortfolioItem> SYMBOL_PORTFOLIO = ITEMS.registerItem(
|
||||
"symbol_portfolio", SymbolPortfolioItem::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(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package net.dnorberg.rivencraft.writing;
|
||||
|
||||
/**
|
||||
* Number components from Mystcraft Legacy's ModWords#constructNumber.
|
||||
* Legacy registers 0–25; larger Rivencraft tab numbers use positional base 25.
|
||||
* Positional base-25 numerals using Mystcraft Legacy's 0–24 digit components.
|
||||
* Components within one digit are layered in the same square, not laid side by side.
|
||||
*/
|
||||
public final class SymbolNumerals {
|
||||
private SymbolNumerals() {}
|
||||
@@ -19,7 +19,7 @@ public final class SymbolNumerals {
|
||||
}
|
||||
|
||||
public static int[] componentsForDigit(int digit) {
|
||||
if (digit < 0 || digit >= 25) throw new IllegalArgumentException("Numeral digit must be in [0, 24]");
|
||||
if (digit < 0 || digit >= 25) throw new IllegalArgumentException("Base-25 digit must be in [0, 24]");
|
||||
if (digit == 0) return new int[] {1};
|
||||
int fifths = digit / 5;
|
||||
int remainder = digit % 5;
|
||||
|
||||
@@ -18,9 +18,13 @@
|
||||
"item.rivencraft.link_property.intra_linking_only": "Intra-Linking Only",
|
||||
"item.rivencraft.link_property.relative_link": "Relative Link",
|
||||
"item.rivencraft.link_property.disable_platform": "Generate Platform: Off",
|
||||
"item.rivencraft.link_property.generate_platform": "Generate Platform: On",
|
||||
"item.rivencraft.page.unresolved": "Unresolved Symbol Page",
|
||||
"item.rivencraft.notebook": "Notebook",
|
||||
"item.rivencraft.collation_folder": "Collation Folder",
|
||||
"item.rivencraft.symbol_portfolio": "Symbol Portfolio",
|
||||
"item.rivencraft.symbol_portfolio.named": "%s Portfolio",
|
||||
"item.rivencraft.symbol_portfolio.volume": "%s Portfolio %s/%s",
|
||||
"item.rivencraft.sealed_notebook": "Sealed Notebook",
|
||||
"block.rivencraft.link_modifier": "Link Modifier",
|
||||
"container.rivencraft.link_modifier": "Link Modifier",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"parent": "minecraft:item/generated",
|
||||
"textures": { "layer0": "rivencraft:item/symbol_portfolio" }
|
||||
}
|
||||
|
After Width: | Height: | Size: 328 B |
@@ -7,6 +7,7 @@
|
||||
"AgeDimensionSeedMixin",
|
||||
"AgeDungeonPlacementMixin",
|
||||
"AgeStructureBiomeMixin",
|
||||
"MobDropChanceAccessor",
|
||||
"StructurePlacementAccessor"
|
||||
],
|
||||
"client": [
|
||||
|
||||