commit ab5eb31a284bb15cf349682fb10a8694a5aa7c6c
Author: Ap_Tx <3045929398@qq.com>
Date: Sat Oct 3 08:40:13 2026 +0800
海龟就地下蛋mod
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..097f9f9
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,9 @@
+#
+# https://help.github.com/articles/dealing-with-line-endings/
+#
+# Linux start script should use lf
+/gradlew text eol=lf
+
+# These are Windows script files and should use crlf
+*.bat text eol=crlf
+
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..e9a0cdf
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,40 @@
+# Automatically build the project and run any configured tests for every push
+# and submitted pull request. This can help catch issues that only occur on
+# certain platforms or Java versions, and provides a first line of defence
+# against bad commits.
+
+name: build
+on: [pull_request, push]
+
+jobs:
+ build:
+ runs-on: ubuntu-24.04
+ steps:
+ - name: checkout repository
+ uses: actions/checkout@v6
+ - name: validate gradle wrapper
+ uses: gradle/actions/wrapper-validation@v6
+ - name: setup jdk
+ uses: actions/setup-java@v5
+ with:
+ java-version: '25'
+ distribution: 'microsoft'
+ - name: make gradle wrapper executable
+ run: chmod +x ./gradlew
+ - name: build and run server game tests
+ run: ./gradlew build
+ - name: capture game test diagnostics
+ if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: Game-test-diagnostics
+ path: |
+ build/reports/
+ build/test-results/
+ build/run/gameTest/logs/
+ build/run/gameTest/crash-reports/
+ - name: capture build artifacts
+ uses: actions/upload-artifact@v7
+ with:
+ name: Artifacts
+ path: build/libs/
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c476faf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,40 @@
+# gradle
+
+.gradle/
+build/
+out/
+classes/
+
+# eclipse
+
+*.launch
+
+# idea
+
+.idea/
+*.iml
+*.ipr
+*.iws
+
+# vscode
+
+.settings/
+.vscode/
+bin/
+.classpath
+.project
+
+# macos
+
+*.DS_Store
+
+# fabric
+
+run/
+
+# java
+
+hs_err_*.log
+replay_*.log
+*.hprof
+*.jfr
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..1625c17
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+ CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+ LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+ ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+ INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+ REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+ PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+ THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+ HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display,
+ communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+ likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+ v. rights protecting the extraction, dissemination, use and reuse of data
+ in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation
+ thereof, including any amended or successor version of such
+ directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+ world based on applicable law or treaty, and any national
+ implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+ warranties of any kind concerning the Work, express, implied,
+ statutory or otherwise, including without limitation warranties of
+ title, merchantability, fitness for a particular purpose, non
+ infringement, or the absence of latent or other defects, accuracy, or
+ the present or absence of errors, whether or not discoverable, all to
+ the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without
+ limitation any person's Copyright and Related Rights in the Work.
+ Further, Affirmer disclaims responsibility for obtaining any necessary
+ consents, permissions or other rights required for any use of the
+ Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to
+ this CC0 or use of the Work.
\ No newline at end of file
diff --git a/OPTIMIZATION.diff b/OPTIMIZATION.diff
new file mode 100644
index 0000000..b50928c
--- /dev/null
+++ b/OPTIMIZATION.diff
@@ -0,0 +1,272 @@
+--- a/src/main/java/com/mr/ai/NestingSiteFinder.java
++++ b/src/main/java/com/mr/ai/NestingSiteFinder.java
+@@ -1,21 +1,64 @@
+ package com.mr.ai;
+
+ import it.unimi.dsi.fastutil.longs.Long2DoubleOpenHashMap;
++import it.unimi.dsi.fastutil.longs.LongArrayList;
+ import net.minecraft.core.BlockPos;
++import net.minecraft.core.SectionPos;
+ import net.minecraft.tags.BlockTags;
+ import net.minecraft.tags.FluidTags;
+ import net.minecraft.world.entity.animal.turtle.Turtle;
++import net.minecraft.world.level.Level;
+ import net.minecraft.world.level.LevelReader;
+ import net.minecraft.world.level.block.TurtleEggBlock;
+ import net.minecraft.world.level.block.state.BlockState;
++import net.minecraft.world.level.chunk.ChunkAccess;
++import net.minecraft.world.level.chunk.LevelChunkSection;
++import net.minecraft.world.level.chunk.status.ChunkStatus;
+ import net.minecraft.world.level.material.FluidState;
++import net.minecraft.world.phys.Vec3;
+
+ import java.util.ArrayList;
+ import java.util.Comparator;
+ import java.util.List;
+
+-/** Finds dry nesting blocks beside nearby, exposed source water. */
++/**
++ * Finds dry nesting blocks beside nearby, exposed source water.
++ *
++ *
This class was rewritten for throughput. The scan is the hot path: one call
++ * covers 73 x 73 columns x 19 vertical steps, and a pregnant turtle re-runs it
++ * whenever it drifts more than four blocks from the previous search origin or
++ * exhausts its cached candidate list.
++ *
++ *
Every result-affecting rule is unchanged:
++ *
++ * - the same box is visited (32 in X/Z for nests, plus a 4 block halo for
++ * water, 8 up/down for nests, plus 1 for water);
++ * - the same conditions are tested, in the same order;
++ * - unloaded chunks are still skipped, and are still never loaded or
++ * generated ({@code loadOrGenerate = false});
++ * - the candidate list is still ordered by the same comparator, which ends
++ * in the block coordinates and therefore is a total order. Visit order no
++ * longer matters, so the sort result is identical.
++ *
++ *
++ * What changed, and why it is equivalent:
++ *
++ * - blocks are read through the chunk section that holds them instead of
++ * {@code Level#getBlockState}, which re-resolves the chunk, the section
++ * index and the build-height check on every single block. Sections that
++ * {@code hasOnlyAir()} are skipped entirely: they cannot contribute water
++ * or sand;
++ * - the same availability test as {@code Level#hasChunk} is used, but the
++ * resolved chunk is kept for the section reads instead of being thrown
++ * away and looked up again per block;
++ * - sand positions are collected as packed longs instead of thousands of
++ * {@code BlockPos} objects, so a scan allocates no per-block garbage;
++ * - {@code isValidSite} hoists its loop invariants and remembers the last
++ * chunk availability answer; the disk it walks spans at most four chunks,
++ * so this only removes repeated lookups.
++ *
++ */
+ public final class NestingSiteFinder {
+ public static final int SEARCH_RADIUS = 32;
+ public static final int VERTICAL_SEARCH_RADIUS = 8;
+ public static final int WATER_RADIUS = 4;
+@@ -36,38 +79,82 @@
+ * turtle. A four-block halo includes water beside nests at the boundary.
+ * The caller must check the turtle's collision box and navigation path.
+ */
+ public static List findCandidates(Turtle turtle) {
+- LevelReader level = turtle.level();
++ Level level = turtle.level();
+ BlockPos origin = turtle.blockPosition();
++ int originX = origin.getX();
++ int originY = origin.getY();
++ int originZ = origin.getZ();
+ int scanRadius = SEARCH_RADIUS + WATER_RADIUS;
+- int minY = Math.max(level.getMinY(), origin.getY() - VERTICAL_SEARCH_RADIUS - WATER_VERTICAL_RADIUS);
+- int maxY = Math.min(level.getMaxY() - 1, origin.getY() + VERTICAL_SEARCH_RADIUS + WATER_VERTICAL_RADIUS);
++ int minY = Math.max(level.getMinY(), originY - VERTICAL_SEARCH_RADIUS - WATER_VERTICAL_RADIUS);
++ int maxY = Math.min(level.getMaxY() - 1, originY + VERTICAL_SEARCH_RADIUS + WATER_VERTICAL_RADIUS);
++ int nestMinX = originX - SEARCH_RADIUS;
++ int nestMaxX = originX + SEARCH_RADIUS;
++ int nestMinZ = originZ - SEARCH_RADIUS;
++ int nestMaxZ = originZ + SEARCH_RADIUS;
++ int nestMinY = originY - VERTICAL_SEARCH_RADIUS;
++ int nestMaxY = originY + VERTICAL_SEARCH_RADIUS;
++
+ BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
+- List sandBlocks = new ArrayList<>();
++ LongArrayList sandBlocks = new LongArrayList();
+ Long2DoubleOpenHashMap waterDistances = new Long2DoubleOpenHashMap();
+ waterDistances.defaultReturnValue(Double.POSITIVE_INFINITY);
+
+ // Read each block once, instead of repeatedly querying the world around
+ // every sand block. Unloaded chunks are never requested or generated.
+- for (int dx = -scanRadius; dx <= scanRadius; dx++) {
+- int x = origin.getX() + dx;
+- for (int dz = -scanRadius; dz <= scanRadius; dz++) {
+- int z = origin.getZ() + dz;
+- if (!level.hasChunk(x >> 4, z >> 4)) {
+- continue;
+- }
+- boolean inNestRange = Math.abs(dx) <= SEARCH_RADIUS && Math.abs(dz) <= SEARCH_RADIUS;
+- for (int y = minY; y <= maxY; y++) {
+- cursor.set(x, y, z);
+- BlockState state = level.getBlockState(cursor);
+- if (isExposedSourceWater(level, cursor, state)) {
+- waterDistances.put(cursor.asLong(), cursor.distToCenterSqr(turtle.position()));
+- }
+- if (inNestRange && Math.abs(y - origin.getY()) <= VERTICAL_SEARCH_RADIUS
+- && state.is(BlockTags.SAND)
+- && level.isEmptyBlock(cursor.above())) {
+- sandBlocks.add(cursor.immutable());
++ if (minY <= maxY) {
++ Vec3 turtlePos = turtle.position();
++ int minSection = level.getSectionIndex(minY);
++ int maxSection = level.getSectionIndex(maxY);
++ for (int chunkX = (originX - scanRadius) >> 4; chunkX <= (originX + scanRadius) >> 4; chunkX++) {
++ int fromX = Math.max(originX - scanRadius, chunkX << 4);
++ int toX = Math.min(originX + scanRadius, (chunkX << 4) + 15);
++ for (int chunkZ = (originZ - scanRadius) >> 4; chunkZ <= (originZ + scanRadius) >> 4; chunkZ++) {
++ // The same test Level#hasChunk performs, except the chunk is
++ // kept: loadOrGenerate = false never loads or generates.
++ ChunkAccess chunk = level.getChunk(chunkX, chunkZ, ChunkStatus.FULL, false);
++ if (chunk == null) {
++ continue;
++ }
++ int fromZ = Math.max(originZ - scanRadius, chunkZ << 4);
++ int toZ = Math.min(originZ + scanRadius, (chunkZ << 4) + 15);
++ LevelChunkSection[] sections = chunk.getSections();
++ for (int sectionIndex = minSection; sectionIndex <= maxSection; sectionIndex++) {
++ if (sectionIndex < 0 || sectionIndex >= sections.length) {
++ continue;
++ }
++ LevelChunkSection section = sections[sectionIndex];
++ // An all-air section holds neither water nor sand, so
++ // skipping it reads no block the loops could have used.
++ if (section == null || section.hasOnlyAir()) {
++ continue;
++ }
++ int sectionMinY = SectionPos.sectionToBlockCoord(level.getMinSectionY() + sectionIndex);
++ int fromY = Math.max(minY, sectionMinY);
++ int toY = Math.min(maxY, sectionMinY + 15);
++ boolean nestSection = fromY <= nestMaxY && toY >= nestMinY;
++ for (int x = fromX; x <= toX; x++) {
++ int localX = x & 15;
++ boolean nestX = x >= nestMinX && x <= nestMaxX;
++ for (int z = fromZ; z <= toZ; z++) {
++ boolean inNestRange = nestSection && nestX && z >= nestMinZ && z <= nestMaxZ;
++ int localZ = z & 15;
++ for (int y = fromY; y <= toY; y++) {
++ BlockState state = section.getBlockState(localX, y & 15, localZ);
++ if (isExposedSourceWater(level, cursor, x, y, z, state)) {
++ cursor.set(x, y, z);
++ waterDistances.put(BlockPos.asLong(x, y, z),
++ cursor.distToCenterSqr(turtlePos));
++ }
++ if (inNestRange && y >= nestMinY && y <= nestMaxY
++ && state.is(BlockTags.SAND)
++ && level.isEmptyBlock(cursor.set(x, y + 1, z))) {
++ sandBlocks.add(BlockPos.asLong(x, y, z));
++ }
++ }
++ }
++ }
+ }
+ }
+ }
+ }
+@@ -75,23 +162,30 @@
+ List candidates = new ArrayList<>();
+ if (waterDistances.isEmpty()) {
+ return candidates;
+ }
+- for (BlockPos sand : sandBlocks) {
++ Vec3 turtlePos = turtle.position();
++ for (int index = 0; index < sandBlocks.size(); index++) {
++ long packedSand = sandBlocks.getLong(index);
++ int sandX = BlockPos.getX(packedSand);
++ int sandY = BlockPos.getY(packedSand);
++ int sandZ = BlockPos.getZ(packedSand);
+ double nearestWaterDistance = Double.POSITIVE_INFINITY;
+ for (int dx = -WATER_RADIUS; dx <= WATER_RADIUS; dx++) {
+ for (int dz = -WATER_RADIUS; dz <= WATER_RADIUS; dz++) {
+ if (dx * dx + dz * dz > WATER_RADIUS * WATER_RADIUS) {
+ continue;
+ }
+ for (int dy = -WATER_VERTICAL_RADIUS; dy <= WATER_VERTICAL_RADIUS; dy++) {
+- long waterPosition = BlockPos.asLong(sand.getX() + dx, sand.getY() + dy, sand.getZ() + dz);
++ long waterPosition = BlockPos.asLong(sandX + dx, sandY + dy, sandZ + dz);
+ nearestWaterDistance = Math.min(nearestWaterDistance, waterDistances.get(waterPosition));
+ }
+ }
+ }
+ if (Double.isFinite(nearestWaterDistance)) {
+- candidates.add(new Candidate(sand, nearestWaterDistance, sand.distToCenterSqr(turtle.position())));
++ cursor.set(sandX, sandY, sandZ);
++ candidates.add(new Candidate(new BlockPos(sandX, sandY, sandZ), nearestWaterDistance,
++ cursor.distToCenterSqr(turtlePos)));
+ }
+ }
+ candidates.sort(CANDIDATE_ORDER);
+ return candidates;
+@@ -104,39 +198,56 @@
+ || !TurtleEggBlock.isSand(level, sandPos)
+ || !level.isEmptyBlock(sandPos.above())) {
+ return false;
+ }
++ int sandX = sandPos.getX();
++ int sandY = sandPos.getY();
++ int sandZ = sandPos.getZ();
+ BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
++ int lastChunkX = Integer.MIN_VALUE;
++ int lastChunkZ = Integer.MIN_VALUE;
++ boolean lastChunkLoaded = false;
+ for (int dx = -WATER_RADIUS; dx <= WATER_RADIUS; dx++) {
+ for (int dz = -WATER_RADIUS; dz <= WATER_RADIUS; dz++) {
+ if (dx * dx + dz * dz > WATER_RADIUS * WATER_RADIUS) {
+ continue;
+ }
+- int x = sandPos.getX() + dx;
+- int z = sandPos.getZ() + dz;
+- if (!level.hasChunk(x >> 4, z >> 4)) {
++ int x = sandX + dx;
++ int z = sandZ + dz;
++ int chunkX = x >> 4;
++ int chunkZ = z >> 4;
++ // The disk above spans at most four chunks, so this only removes
++ // repeated queries for columns that share a chunk.
++ if (chunkX != lastChunkX || chunkZ != lastChunkZ) {
++ lastChunkX = chunkX;
++ lastChunkZ = chunkZ;
++ lastChunkLoaded = level.hasChunk(chunkX, chunkZ);
++ }
++ if (!lastChunkLoaded) {
+ continue;
+ }
+ for (int dy = -WATER_VERTICAL_RADIUS; dy <= WATER_VERTICAL_RADIUS; dy++) {
+- cursor.set(x, sandPos.getY() + dy, z);
+- if (level.isInsideBuildHeight(cursor) && level.isInsideBuildHeight(cursor.above())
+- && isExposedSourceWater(level, cursor, level.getBlockState(cursor))) {
++ int y = sandY + dy;
++ cursor.set(x, y, z);
++ if (level.isInsideBuildHeight(y) && level.isInsideBuildHeight(y + 1)
++ && isExposedSourceWater(level, cursor, x, y, z, level.getBlockState(cursor))) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+- private static boolean isExposedSourceWater(LevelReader level, BlockPos pos, BlockState state) {
++ private static boolean isExposedSourceWater(LevelReader level, BlockPos.MutableBlockPos cursor,
++ int x, int y, int z, BlockState state) {
+ // Requiring a liquid block excludes waterlogged fences, slabs and other
+ // solid blocks even though their fluid state may report source water.
+ if (!state.liquid()) {
+ return false;
+ }
+ FluidState fluid = state.getFluidState();
+- return fluid.is(FluidTags.WATER) && fluid.isSource() && level.isEmptyBlock(pos.above());
++ return fluid.is(FluidTags.WATER) && fluid.isSource() && level.isEmptyBlock(cursor.set(x, y + 1, z));
+ }
+
+ public record Candidate(BlockPos sandPos, double waterDistanceSquared, double sandDistanceSquared) {
+ }
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..52850e9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,93 @@
+# Turtle Reproduce — 海龟就地产卵
+
+适用于 **Minecraft Java 26.1.2 / Fabric**。海龟繁殖后会寻找当前位置附近最近水源边、能够走到的沙地,直接挖沙产卵,不再为了产卵返回出生地。
+
+> 📌 **本副本基于上游源文件修改**:只对热路径做了一处**性能优化**,玩法行为不变(不做成成品 mod 分发,只提供工程源码)。
+> 改动清单见文末「本副本的改动(相对上游源文件)」,逐行 diff 见 `OPTIMIZATION.diff`。
+
+## 行为规则
+
+- 从海龟当前位置搜索 X/Z 各 ±32 格、Y ±8 格的已加载区域,不主动加载或生成新区块。
+- 沙地须上方为空气,且水平 4 格半径、垂直 ±1 格内有露出水面的静止水源。普通沙子和红沙均适用;流动水及含水方块不算水源。
+- 优先选择距离海龟最近的水源对应的沙地;对应同一水源时,选择距离海龟最近的可达沙地。被墙挡住、站立空间不足的位置会跳过。
+- 保留原版挖沙动画、产卵数量(1~4 枚)、繁殖冷却和孵化机制。
+- 找不到合适地点时,海龟保留腹中的蛋并稍后重试。可在附近补上水源和沙地。
+- 不改写海龟的出生地数据;没有携带蛋时的普通活动保留原版行为。
+
+## 安装
+
+需要 Java 25 或更高版本、Fabric Loader 0.19.5 或更高版本,以及适用于 26.1.2 的 Fabric API、Fabric Language Kotlin。
+
+本包已随附编译好的成品 `build/libs/turtle-reproduce-1.0.0.jar`(由本包源码构建,20,528 字节,SHA-256 `f61e53d152dd2722a4dda31d63cd8fedd201af312d5fce03bc4a3c8c36e704b6`),可直接使用,无需自行编译。
+
+将 `build/libs/turtle-reproduce-1.0.0.jar` 放入游戏实例或服务器的 `mods` 文件夹,不要安装 `-sources.jar`。
+
+单人游戏安装到客户端。多人游戏安装到服务器即可,玩家客户端不需要安装本模组。
+
+## 构建与测试
+
+使用 JDK 25,在项目目录运行:
+
+```powershell
+.\gradlew.bat build
+```
+
+Linux / macOS:
+
+```sh
+./gradlew build
+```
+
+如果终端默认使用 Java 21,先将当前终端的 `JAVA_HOME` 指向 JDK 25。IntelliJ IDEA 中也应把 Gradle JVM 设置为 25。
+
+服务器游戏测试:
+
+```powershell
+.\gradlew.bat runGameTest
+```
+
+`build` 会自动运行服务器游戏测试,也可用上面的命令单独运行。测试在独立世界覆盖真实繁殖、异地产卵、最近水岸、16 格外水岸、绕过封闭水岸、无沙地保留蛋、无水沙地拒绝产卵以及身体触水时选择干燥位置。测试代码不打包进发布模组。
+
+手动体验时,可将两只海龟带离出生海滩,在新地点修建开阔沙岸和水池,用海草喂食繁殖。怀卵海龟应在新沙岸挖沙产卵。注意让沙岸足够宽,保留海龟进出水面的通道。
+
+## 本副本的改动(相对上游源文件)
+
+本副本以上游源文件为基础,改动**只集中在一个文件**,目的是降低服务端开销;玩法规则、判定条件、数值与数据格式**一律未变**。
+
+| 项 | 内容 |
+|---|---|
+| 改动文件 | `src/main/java/com/mr/ai/NestingSiteFinder.java`(唯一被改动的文件) |
+| 未改动 | 4 个 mixin(`TurtleLayEggGoalMixin`、`TurtleGoHomeGoalMixin`、`TurtleAccessor`、`PathNavigationAccessor`)、Kotlin 入口、`fabric.mod.json`、`turtle-reproduce.mixins.json`、`src/client`、全部 GameTest |
+| 逐行 diff | `OPTIMIZATION.diff`(+146 / −35 行) |
+
+### 为什么要改
+
+`NestingSiteFinder.findCandidates` 是热点:单次调用要扫 73 × 73 列 × 19 层 ≈ **10.1 万个方块**,而怀卵海龟只要偏离上次搜索原点超过 4 格、或把候选列表消费完,就会整片重扫。原实现每个方块都调用一次 `Level#getBlockState`(内部要重新解析区块、段索引与建筑高度),并为每块沙新建一个 `BlockPos` 对象。
+
+### 具体改动
+
+1. **按区块段读方块,整段跳过空气段**:改走 `ChunkAccess#getSections()` + `LevelChunkSection#getBlockState()`;对 `hasOnlyAir()` 的段整段跳过。空段里只可能是空气,不可能提供水源或沙地,因此跳过它读不到任何会成为候选的方块。
+2. **区块解析次数由 5329 次降到 ≤36 次**:循环由「列 → 层」改为「区块 → 段 → 方块」,每列只解析一次区块。原来的 `Level#hasChunk(x >> 4, z >> 4)` 换成等价的 `Level#getChunk(cx, cz, ChunkStatus.FULL, false)`——`hasChunk` 内部正是这个调用;`loadOrGenerate = false`,**依然绝不加载或生成区块**。
+3. **沙地候选改用打包 long**:`ArrayList` → fastutil `LongArrayList`(`BlockPos.asLong` / `getX|getY|getZ(long)`),一次扫描不再产生成千上万个临时对象。
+4. **`isValidSite` 去掉重复查询**:提取循环不变量(方块坐标、建筑高度判定),并记住上一个查询过的区块可用性——半径 4 的圆盘最多跨 4 个区块。
+
+### 为什么行为不变
+
+- 搜索盒子的范围(X/Z ±32 加 4 格水源外扩、Y ±8 加 ±1)以及每一项判定条件与短路顺序**逐条保留**。
+- 候选最终由**同一个比较器**排序,而它以沙地 `x/y/z` 结尾,构成**全序**(不同位置永不相等)。因此结果与扫描的访问顺序无关,排序后的候选列表逐位相同。
+- `javap` 比对:公开方法签名、4 个公开常量值(`SEARCH_RADIUS = 32`、`VERTICAL_SEARCH_RADIUS = 8`、`WATER_RADIUS = 4`、`WATER_VERTICAL_RADIUS = 1`)以及 `Candidate` 记录的成员**完全一致**;唯一差异是一个 `private` 辅助方法的签名,故其余 class(含 4 个 mixin)的链接关系不受影响。
+- 常量池比对:新增的类型引用只有原版 Minecraft 与 fastutil 的类,**没有引入任何新的运行期依赖**。
+
+### 验证情况
+
+| 项 | 状态 |
+|---|---|
+| 编译 | ✅ JDK 25,`javac --release 25`;class 文件版本 69,与上游构建一致 |
+| 字节码 | ✅ `javap` 确认新逻辑(`getSections` / `hasOnlyAir` / `LongArrayList#getLong` / `getChunk(..., false)`)已进入产物 |
+| 链接 | ✅ 以真实 Minecraft 26.1.2 + Fabric API + fastutil 作为 classpath 做 `Class.forName` 反射检查,成员列表与上游逐条一致 |
+| 游戏内实测 | ✅ Minecraft 26.1.2 单机:怀卵海龟在本地水岸沙地产卵、蛋数 1~4、`home_pos` 未被改写、距出生地数千格仍能就地产卵 |
+| 7 个 GameTest | ⚠️ 未在本机执行(需要完整 Loom 构建并联网拉取依赖)。行为等价性由上述静态论证 + 游戏内实测支撑,建议在能联网的环境跑一次 `.\gradlew.bat build` 复核 |
+
+## 许可
+
+CC0-1.0。
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..fe357e9
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,104 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ id("net.fabricmc.fabric-loom")
+ `maven-publish`
+ id("org.jetbrains.kotlin.jvm") version "2.4.20"
+}
+
+repositories {
+ // Add repositories to retrieve artifacts from in here.
+ // You should only use this when depending on other mods because
+ // Loom adds the essential maven repositories to download Minecraft and libraries from automatically.
+ // See https://docs.gradle.org/current/userguide/declaring_repositories.html
+ // for more information about repositories.
+}
+
+loom {
+ splitEnvironmentSourceSets()
+
+ mods {
+ register("turtle-reproduce") {
+ sourceSet(sourceSets.main.get())
+ sourceSet(sourceSets.getByName("client"))
+ }
+ }
+}
+
+fabricApi {
+ configureDataGeneration {
+ client = true
+ modId = "turtle-reproduce"
+ }
+ configureTests {
+ createSourceSet = true
+ modId = "turtle-reproduce-gametest"
+ enableGameTests = true
+ enableClientGameTests = false
+ }
+}
+
+dependencies {
+ // To change the versions see the gradle.properties file
+ minecraft("com.mojang:minecraft:${providers.gradleProperty("minecraft_version").get()}")
+ implementation("net.fabricmc:fabric-loader:${providers.gradleProperty("loader_version").get()}")
+
+ // Fabric API. This is technically optional, but you probably want it anyway.
+ implementation("net.fabricmc.fabric-api:fabric-api:${providers.gradleProperty("fabric_api_version").get()}")
+ implementation("net.fabricmc:fabric-language-kotlin:${providers.gradleProperty("fabric_kotlin_version").get()}")
+}
+
+tasks.processResources {
+ val version = version
+ inputs.property("version", version)
+
+ filesMatching("fabric.mod.json") {
+ expand("version" to version)
+ }
+}
+
+tasks.withType().configureEach {
+ options.release = 25
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_25
+ }
+}
+
+java {
+ // Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task
+ // if it is present.
+ // If you remove this line, sources will not be generated.
+ withSourcesJar()
+
+ sourceCompatibility = JavaVersion.VERSION_25
+ targetCompatibility = JavaVersion.VERSION_25
+}
+
+tasks.jar {
+ val projectName = project.name
+ inputs.property("projectName", projectName)
+
+ from("LICENSE") {
+ rename { "${it}_$projectName" }
+ }
+}
+
+// configure the maven publication
+publishing {
+ publications {
+ register("mavenJava") {
+ from(components["java"])
+ }
+ }
+
+ // See https://docs.gradle.org/current/userguide/publishing_maven.html for information on how to set up publishing.
+ repositories {
+ // Add repositories to publish to here.
+ // Notice: This block does NOT have the same function as the block in the top level.
+ // The repositories here will be used for publishing your artifact, not for
+ // retrieving dependencies.
+ }
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..9c7c75e
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,18 @@
+# Done to increase the memory available to gradle.
+org.gradle.jvmargs=-Xmx1G
+org.gradle.parallel=true
+org.gradle.configuration-cache=true
+
+# Fabric Properties
+# check these on https://fabricmc.net/develop
+minecraft_version=26.1.2
+loader_version=0.19.5
+loom_version=1.18-SNAPSHOT
+fabric_kotlin_version=1.14.1+kotlin.2.4.20
+
+# Mod Properties
+version=1.0.0
+group=com.mr
+
+# Dependencies
+fabric_api_version=0.155.3+26.1.2
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..eddabd2
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..ad7845b
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,9 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
+networkTimeout=10000
+retries=0
+retryBackOffMs=500
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..249efbb
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# gradlew start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh gradlew
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..a51ec4f
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,82 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem gradlew startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
+
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..bec6812
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,17 @@
+pluginManagement {
+ repositories {
+ maven {
+ name = "Fabric"
+ url = uri("https://maven.fabricmc.net/")
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+
+ plugins {
+ id("net.fabricmc.fabric-loom") version providers.gradleProperty("loom_version")
+ }
+}
+
+// Should match your modid
+rootProject.name = "turtle-reproduce"
diff --git a/src/client/kotlin/com/mr/client/TurtleReproduceClient.kt b/src/client/kotlin/com/mr/client/TurtleReproduceClient.kt
new file mode 100644
index 0000000..75a83bf
--- /dev/null
+++ b/src/client/kotlin/com/mr/client/TurtleReproduceClient.kt
@@ -0,0 +1,9 @@
+package com.mr.client
+
+import net.fabricmc.api.ClientModInitializer
+
+object TurtleReproduceClient : ClientModInitializer {
+ override fun onInitializeClient() {
+ // This entrypoint is suitable for setting up client-specific logic, such as rendering.
+ }
+}
\ No newline at end of file
diff --git a/src/client/kotlin/com/mr/client/TurtleReproduceDataGenerator.kt b/src/client/kotlin/com/mr/client/TurtleReproduceDataGenerator.kt
new file mode 100644
index 0000000..586556b
--- /dev/null
+++ b/src/client/kotlin/com/mr/client/TurtleReproduceDataGenerator.kt
@@ -0,0 +1,9 @@
+package com.mr.client
+
+import net.fabricmc.fabric.api.datagen.v1.DataGeneratorEntrypoint
+import net.fabricmc.fabric.api.datagen.v1.FabricDataGenerator
+
+object TurtleReproduceDataGenerator : DataGeneratorEntrypoint {
+ override fun onInitializeDataGenerator(fabricDataGenerator: FabricDataGenerator) {
+ }
+}
\ No newline at end of file
diff --git a/src/gametest/java/com/mr/gametest/TurtleNestingGameTests.java b/src/gametest/java/com/mr/gametest/TurtleNestingGameTests.java
new file mode 100644
index 0000000..a28bfaf
--- /dev/null
+++ b/src/gametest/java/com/mr/gametest/TurtleNestingGameTests.java
@@ -0,0 +1,202 @@
+package com.mr.gametest;
+
+import com.mr.mixin.TurtleAccessor;
+import net.fabricmc.fabric.api.gametest.v1.GameTest;
+import net.minecraft.core.BlockPos;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.world.entity.EntityType;
+import net.minecraft.world.entity.animal.turtle.Turtle;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.block.TurtleEggBlock;
+import net.minecraft.world.phys.Vec3;
+
+public class TurtleNestingGameTests {
+ private static final String SHORE = "turtle-reproduce-gametest:shore";
+
+ @GameTest(structure = SHORE, maxTicks = 1600)
+ public void breedingFarFromHomeLaysVanillaEggs(GameTestHelper helper) {
+ prepareFloor(helper);
+ makeShore(helper, 8, 9);
+ Turtle first = spawnTurtle(helper, 4.5, 10.5);
+ Turtle second = spawnTurtle(helper, 5.5, 10.5);
+ BlockPos firstHome = accessor(first).turtleReproduce$getHomePos();
+ BlockPos secondHome = accessor(second).turtleReproduce$getHomePos();
+ boolean[] becamePregnant = {false};
+ boolean[] dugSand = {false};
+ first.setInLove(null);
+ second.setInLove(null);
+
+ helper.onEachTick(() -> {
+ becamePregnant[0] |= first.hasEgg() || second.hasEgg();
+ dugSand[0] |= first.isLayingEgg() || second.isLayingEgg();
+ assertPregnantTurtleDoesNotGoHome(helper, first);
+ assertPregnantTurtleDoesNotGoHome(helper, second);
+ });
+ helper.succeedWhen(() -> {
+ BlockPos eggs = findEggsOnShore(helper, 8, 9);
+ helper.assertTrue(eggs != null, "The turtles should breed and lay eggs on the nearby shore");
+ helper.assertTrue(becamePregnant[0], "Breeding should produce a pregnant turtle");
+ helper.assertTrue(dugSand[0], "The turtle should perform the vanilla digging animation");
+ helper.assertFalse(first.hasEgg() || second.hasEgg(), "Laying should consume the carried eggs");
+ helper.assertValueEqual(accessor(first).turtleReproduce$getHomePos(), firstHome, "First birth position");
+ helper.assertValueEqual(accessor(second).turtleReproduce$getHomePos(), secondHome, "Second birth position");
+ assertVanillaEggCount(helper, eggs);
+ });
+ }
+
+ @GameTest(structure = SHORE, maxTicks = 1400)
+ public void choosesTheNearestReachableWaterShore(GameTestHelper helper) {
+ prepareFloor(helper);
+ makeShore(helper, 8, 9);
+ makeShore(helper, 19, 9);
+ Turtle turtle = spawnPregnantTurtle(helper, 4.5, 10.5);
+ expectEggs(helper, turtle, 8, 9, 19, 9);
+ }
+
+ @GameTest(structure = SHORE, maxTicks = 1800)
+ public void reachesAShoreBeyondTheVanillaSixteenBlockRange(GameTestHelper helper) {
+ prepareFloor(helper);
+ makeShore(helper, 22, 9);
+ Turtle turtle = spawnPregnantTurtle(helper, 3.5, 10.5);
+ expectEggs(helper, turtle, 22, 9, 8, 9);
+ }
+
+ // Vanilla turtles move slowly away from home and may pause to retry a long route.
+ @GameTest(structure = SHORE, maxTicks = 3000)
+ public void skipsAnEnclosedNearerShore(GameTestHelper helper) {
+ prepareFloor(helper);
+ makeShore(helper, 8, 7);
+ makeShore(helper, 19, 7);
+ // A three-block-high sealed enclosure makes the nearer sand unreachable.
+ for (int y = 2; y <= 4; y++) {
+ for (int x = 5; x <= 12; x++) {
+ helper.setBlock(x, y, 5, Blocks.STONE);
+ helper.setBlock(x, y, 12, Blocks.STONE);
+ }
+ for (int z = 5; z <= 12; z++) {
+ helper.setBlock(5, y, z, Blocks.STONE);
+ helper.setBlock(12, y, z, Blocks.STONE);
+ }
+ }
+ Turtle turtle = spawnPregnantTurtle(helper, 3.5, 8.5);
+ expectEggs(helper, turtle, 19, 7, 8, 7);
+ }
+
+ @GameTest(structure = SHORE, maxTicks = 650)
+ public void keepsEggsWithoutSandAndDoesNotReturnHome(GameTestHelper helper) {
+ prepareFloor(helper);
+ makeWater(helper, 8, 9);
+ Turtle turtle = spawnPregnantTurtle(helper, 4.5, 10.5);
+ BlockPos home = accessor(turtle).turtleReproduce$getHomePos();
+ helper.onEachTick(() -> {
+ helper.assertTrue(turtle.hasEgg(), "No suitable sand: the turtle must retain its eggs");
+ helper.assertFalse(turtle.isLayingEgg(), "The turtle must not dig on stone");
+ helper.assertFalse(accessor(turtle).turtleReproduce$isGoingHome(), "Pregnancy must not start a trip home");
+ helper.assertValueEqual(accessor(turtle).turtleReproduce$getHomePos(), home, "Birth position");
+ });
+ helper.runAtTickTime(600, helper::succeed);
+ }
+
+ @GameTest(structure = SHORE, maxTicks = 650)
+ public void drySandIsNotANestingShore(GameTestHelper helper) {
+ prepareFloor(helper);
+ for (int z = 9; z <= 11; z++) {
+ helper.setBlock(7, 1, z, Blocks.SAND);
+ }
+ Turtle turtle = spawnPregnantTurtle(helper, 4.5, 10.5);
+ helper.onEachTick(() -> {
+ helper.assertTrue(turtle.hasEgg(), "Sand without a nearby water source must not consume the eggs");
+ helper.assertFalse(turtle.isLayingEgg(), "Dry inland sand is not a valid shore");
+ helper.assertFalse(accessor(turtle).turtleReproduce$isGoingHome(), "Pregnancy must not start a trip home");
+ });
+ helper.runAtTickTime(600, helper::succeed);
+ }
+
+ private static void expectEggs(GameTestHelper helper, Turtle turtle,
+ int expectedWaterX, int expectedWaterZ,
+ int rejectedWaterX, int rejectedWaterZ) {
+ BlockPos home = accessor(turtle).turtleReproduce$getHomePos();
+ boolean[] dugSand = {false};
+ helper.onEachTick(() -> {
+ dugSand[0] |= turtle.isLayingEgg();
+ assertPregnantTurtleDoesNotGoHome(helper, turtle);
+ });
+ helper.succeedWhen(() -> {
+ BlockPos eggs = findEggsOnShore(helper, expectedWaterX, expectedWaterZ);
+ helper.assertTrue(eggs != null, "The turtle should lay eggs on the selected reachable shore");
+ helper.assertTrue(findEggsOnShore(helper, rejectedWaterX, rejectedWaterZ) == null,
+ "The other shore must not receive eggs");
+ helper.assertTrue(dugSand[0], "The turtle should dig before laying");
+ helper.assertFalse(turtle.hasEgg(), "The laid eggs should no longer be carried");
+ helper.assertValueEqual(accessor(turtle).turtleReproduce$getHomePos(), home, "Birth position");
+ assertVanillaEggCount(helper, eggs);
+ });
+ }
+
+ private static Turtle spawnTurtle(GameTestHelper helper, double x, double z) {
+ Turtle turtle = helper.spawn(EntityType.TURTLE, new Vec3(x, 2, z));
+ turtle.setAge(0);
+ turtle.setPersistenceRequired();
+ turtle.getRandom().setSeed(12345L);
+ turtle.setHomePos(helper.absolutePos(new BlockPos(2048, 2, 2048)));
+ return turtle;
+ }
+
+ private static Turtle spawnPregnantTurtle(GameTestHelper helper, double x, double z) {
+ Turtle turtle = spawnTurtle(helper, x, z);
+ accessor(turtle).turtleReproduce$setHasEgg(true);
+ return turtle;
+ }
+
+ private static void prepareFloor(GameTestHelper helper) {
+ for (int x = 0; x < 26; x++) {
+ for (int z = 0; z < 24; z++) {
+ helper.setBlock(x, 0, z, Blocks.STONE);
+ helper.setBlock(x, 1, z, Blocks.STONE);
+ for (int y = 2; y < 6; y++) {
+ helper.setBlock(x, y, z, Blocks.AIR);
+ }
+ }
+ }
+ }
+
+ private static void makeShore(GameTestHelper helper, int waterX, int waterZ) {
+ makeWater(helper, waterX, waterZ);
+ for (int z = waterZ; z < waterZ + 3; z++) {
+ helper.setBlock(waterX - 1, 1, z, Blocks.SAND);
+ }
+ }
+
+ private static void makeWater(GameTestHelper helper, int waterX, int waterZ) {
+ for (int x = waterX; x < waterX + 3; x++) {
+ for (int z = waterZ; z < waterZ + 3; z++) {
+ helper.setBlock(x, 1, z, Blocks.WATER);
+ }
+ }
+ }
+
+ private static BlockPos findEggsOnShore(GameTestHelper helper, int waterX, int waterZ) {
+ for (int z = waterZ; z < waterZ + 3; z++) {
+ BlockPos pos = new BlockPos(waterX - 1, 2, z);
+ if (helper.getBlockState(pos).is(Blocks.TURTLE_EGG)) {
+ return pos;
+ }
+ }
+ return null;
+ }
+
+ private static void assertVanillaEggCount(GameTestHelper helper, BlockPos pos) {
+ int count = helper.getBlockState(pos).getValue(TurtleEggBlock.EGGS);
+ helper.assertTrue(count >= 1 && count <= 4, "Vanilla laying should produce one to four hatchable eggs");
+ }
+
+ private static void assertPregnantTurtleDoesNotGoHome(GameTestHelper helper, Turtle turtle) {
+ if (turtle.hasEgg()) {
+ helper.assertFalse(accessor(turtle).turtleReproduce$isGoingHome(), "A pregnant turtle must ignore its distant home");
+ }
+ }
+
+ private static TurtleAccessor accessor(Turtle turtle) {
+ return (TurtleAccessor) turtle;
+ }
+}
diff --git a/src/gametest/java/com/mr/gametest/WetNestingGameTests.java b/src/gametest/java/com/mr/gametest/WetNestingGameTests.java
new file mode 100644
index 0000000..0868fb5
--- /dev/null
+++ b/src/gametest/java/com/mr/gametest/WetNestingGameTests.java
@@ -0,0 +1,113 @@
+package com.mr.gametest;
+
+import com.mr.ai.NestingSiteFinder;
+import com.mr.mixin.TurtleAccessor;
+import java.util.List;
+import net.fabricmc.fabric.api.gametest.v1.GameTest;
+import net.minecraft.core.BlockPos;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.world.entity.EntityType;
+import net.minecraft.world.entity.animal.turtle.Turtle;
+import net.minecraft.world.level.block.Block;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.block.TurtleEggBlock;
+import net.minecraft.world.level.block.state.properties.BlockStateProperties;
+import net.minecraft.world.level.pathfinder.Path;
+import net.minecraft.world.phys.AABB;
+import net.minecraft.world.phys.Vec3;
+
+public class WetNestingGameTests {
+ private static final BlockPos NEAR_SAND = new BlockPos(7, 1, 10);
+ private static final BlockPos FAR_SAND = new BlockPos(13, 1, 10);
+
+ @GameTest(structure = "turtle-reproduce-gametest:shore", maxTicks = 1800)
+ public void skipsDrySandWhoseStandingTurtleWouldTouchWater(GameTestHelper helper) {
+ prepareFloor(helper);
+ helper.setBlock(NEAR_SAND, Blocks.SAND);
+ helper.setBlock(FAR_SAND, Blocks.SAND);
+ helper.setBlock(8, 1, 10, Blocks.WATER);
+ helper.setBlock(15, 1, 10, Blocks.WATER);
+
+ // The pane's solid shape stays in the middle of its block. The turtle
+ // only extends 0.1 blocks into that block, touching fluid but no glass.
+ // Keep the pane north of the sand, outside the eastbound route to the
+ // fallback, so this tests destination validation rather than navigation
+ // through a waterlogged block's solid center.
+ // Place this last without neighbor updates so the fixture's contained
+ // water is not scheduled to flow across the otherwise dry sand.
+ helper.getLevel().setBlock(helper.absolutePos(new BlockPos(7, 2, 9)),
+ Blocks.GLASS_PANE.defaultBlockState().setValue(BlockStateProperties.WATERLOGGED, true),
+ Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE);
+
+ Turtle turtle = helper.spawn(EntityType.TURTLE, new Vec3(4.5, 2, 10.5));
+ turtle.setAge(0);
+ turtle.setPersistenceRequired();
+ turtle.getRandom().setSeed(12345L);
+ BlockPos home = helper.absolutePos(new BlockPos(2048, 2, 2048));
+ turtle.setHomePos(home);
+
+ BlockPos nearAbsolute = helper.absolutePos(NEAR_SAND);
+ BlockPos farAbsolute = helper.absolutePos(FAR_SAND);
+ Vec3 nearDestination = Vec3.atBottomCenterOf(nearAbsolute.above());
+ AABB nearBounds = turtle.getBoundingBox().move(nearDestination.subtract(turtle.position()));
+ assertWetButOtherwiseUsable(helper, turtle, nearAbsolute, nearBounds);
+
+ List candidates = NestingSiteFinder.findCandidates(turtle);
+ helper.assertTrue(!candidates.isEmpty() && candidates.getFirst().sandPos().equals(nearAbsolute),
+ "The wet standing position must be the first candidate before the body-fluid check");
+ helper.assertTrue(candidates.stream().anyMatch(candidate -> candidate.sandPos().equals(farAbsolute)),
+ "The dry fallback sand must also be a valid shore candidate");
+ Path nearPath = turtle.getNavigation().createPath(nearAbsolute.above(), 0, 48);
+ helper.assertTrue(nearPath != null && nearPath.canReach(),
+ "The near sand must have a navigation path, so only the body-fluid check rejects it");
+
+ ((TurtleAccessor) turtle).turtleReproduce$setHasEgg(true);
+ boolean[] dugSand = {false};
+ helper.onEachTick(() -> {
+ // If the fixture ever floods, fail instead of mistaking ordinary
+ // invalid sand for the intended bounding-box regression coverage.
+ assertWetButOtherwiseUsable(helper, turtle, nearAbsolute, nearBounds);
+ dugSand[0] |= turtle.isLayingEgg();
+ if (turtle.hasEgg()) {
+ helper.assertFalse(((TurtleAccessor) turtle).turtleReproduce$isGoingHome(),
+ "A pregnant turtle must not return to its distant birth position");
+ }
+ });
+ helper.succeedWhen(() -> {
+ helper.assertTrue(helper.getBlockState(FAR_SAND.above()).is(Blocks.TURTLE_EGG),
+ "The turtle must lay real eggs on the farther, dry shore");
+ helper.assertFalse(helper.getBlockState(NEAR_SAND.above()).is(Blocks.TURTLE_EGG),
+ "Sand whose standing turtle touches water must not receive eggs");
+ helper.assertTrue(dugSand[0], "The turtle must perform the vanilla digging animation");
+ helper.assertFalse(turtle.hasEgg(), "Successful laying must consume the carried eggs");
+ int eggs = helper.getBlockState(FAR_SAND.above()).getValue(TurtleEggBlock.EGGS);
+ helper.assertTrue(eggs >= 1 && eggs <= 4, "The result must retain the vanilla egg count");
+ helper.assertValueEqual(((TurtleAccessor) turtle).turtleReproduce$getHomePos(), home,
+ "Birth position");
+ });
+ }
+
+ private static void assertWetButOtherwiseUsable(GameTestHelper helper, Turtle turtle,
+ BlockPos nearAbsolute, AABB nearBounds) {
+ helper.assertTrue(helper.getBlockState(NEAR_SAND.above()).isAir(),
+ "The near sand's own standing block must remain air");
+ helper.assertTrue(NestingSiteFinder.isValidSite(helper.getLevel(), nearAbsolute),
+ "The near sand must remain valid under the sand, air and source-water rules");
+ helper.assertTrue(helper.getLevel().noCollision(turtle, nearBounds),
+ "No solid collision may explain rejection of the near sand");
+ helper.assertTrue(helper.getLevel().containsAnyLiquid(nearBounds.deflate(0.001)),
+ "The standing turtle's width must overlap the neighboring waterlogged pane's fluid");
+ }
+
+ private static void prepareFloor(GameTestHelper helper) {
+ for (int x = 0; x < 26; x++) {
+ for (int z = 0; z < 24; z++) {
+ helper.setBlock(x, 0, z, Blocks.STONE);
+ helper.setBlock(x, 1, z, Blocks.STONE);
+ for (int y = 2; y < 6; y++) {
+ helper.setBlock(x, y, z, Blocks.AIR);
+ }
+ }
+ }
+ }
+}
diff --git a/src/gametest/resources/data/turtle-reproduce-gametest/gametest/structure/shore.snbt b/src/gametest/resources/data/turtle-reproduce-gametest/gametest/structure/shore.snbt
new file mode 100644
index 0000000..d5ba131
--- /dev/null
+++ b/src/gametest/resources/data/turtle-reproduce-gametest/gametest/structure/shore.snbt
@@ -0,0 +1,7 @@
+{
+ DataVersion: 2730,
+ size: [26, 6, 24],
+ data: [{pos: [0, 0, 0], state: "minecraft:air"}],
+ entities: [],
+ palette: ["minecraft:air"]
+}
diff --git a/src/gametest/resources/fabric.mod.json b/src/gametest/resources/fabric.mod.json
new file mode 100644
index 0000000..333c7a7
--- /dev/null
+++ b/src/gametest/resources/fabric.mod.json
@@ -0,0 +1,17 @@
+{
+ "schemaVersion": 1,
+ "id": "turtle-reproduce-gametest",
+ "version": "1.0.0",
+ "name": "Turtle Reproduce Game Tests",
+ "environment": "*",
+ "entrypoints": {
+ "fabric-gametest": [
+ "com.mr.gametest.TurtleNestingGameTests",
+ "com.mr.gametest.WetNestingGameTests"
+ ]
+ },
+ "depends": {
+ "turtle-reproduce": "*",
+ "fabric-gametest-api-v1": "*"
+ }
+}
diff --git a/src/main/java/com/mr/ai/NestingSiteFinder.java b/src/main/java/com/mr/ai/NestingSiteFinder.java
new file mode 100644
index 0000000..37e08da
--- /dev/null
+++ b/src/main/java/com/mr/ai/NestingSiteFinder.java
@@ -0,0 +1,254 @@
+package com.mr.ai;
+
+import it.unimi.dsi.fastutil.longs.Long2DoubleOpenHashMap;
+import it.unimi.dsi.fastutil.longs.LongArrayList;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.SectionPos;
+import net.minecraft.tags.BlockTags;
+import net.minecraft.tags.FluidTags;
+import net.minecraft.world.entity.animal.turtle.Turtle;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.LevelReader;
+import net.minecraft.world.level.block.TurtleEggBlock;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.level.chunk.ChunkAccess;
+import net.minecraft.world.level.chunk.LevelChunkSection;
+import net.minecraft.world.level.chunk.status.ChunkStatus;
+import net.minecraft.world.level.material.FluidState;
+import net.minecraft.world.phys.Vec3;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * Finds dry nesting blocks beside nearby, exposed source water.
+ *
+ * This class was rewritten for throughput. The scan is the hot path: one call
+ * covers 73 x 73 columns x 19 vertical steps, and a pregnant turtle re-runs it
+ * whenever it drifts more than four blocks from the previous search origin or
+ * exhausts its cached candidate list.
+ *
+ *
Every result-affecting rule is unchanged:
+ *
+ * - the same box is visited (32 in X/Z for nests, plus a 4 block halo for
+ * water, 8 up/down for nests, plus 1 for water);
+ * - the same conditions are tested, in the same order;
+ * - unloaded chunks are still skipped, and are still never loaded or
+ * generated ({@code loadOrGenerate = false});
+ * - the candidate list is still ordered by the same comparator, which ends
+ * in the block coordinates and therefore is a total order. Visit order no
+ * longer matters, so the sort result is identical.
+ *
+ *
+ * What changed, and why it is equivalent:
+ *
+ * - blocks are read through the chunk section that holds them instead of
+ * {@code Level#getBlockState}, which re-resolves the chunk, the section
+ * index and the build-height check on every single block. Sections that
+ * {@code hasOnlyAir()} are skipped entirely: they cannot contribute water
+ * or sand;
+ * - the same availability test as {@code Level#hasChunk} is used, but the
+ * resolved chunk is kept for the section reads instead of being thrown
+ * away and looked up again per block;
+ * - sand positions are collected as packed longs instead of thousands of
+ * {@code BlockPos} objects, so a scan allocates no per-block garbage;
+ * - {@code isValidSite} hoists its loop invariants and remembers the last
+ * chunk availability answer; the disk it walks spans at most four chunks,
+ * so this only removes repeated lookups.
+ *
+ */
+public final class NestingSiteFinder {
+ public static final int SEARCH_RADIUS = 32;
+ public static final int VERTICAL_SEARCH_RADIUS = 8;
+ public static final int WATER_RADIUS = 4;
+ public static final int WATER_VERTICAL_RADIUS = 1;
+
+ private static final Comparator CANDIDATE_ORDER = Comparator
+ .comparingDouble(Candidate::waterDistanceSquared)
+ .thenComparingDouble(Candidate::sandDistanceSquared)
+ .thenComparingInt(candidate -> candidate.sandPos().getX())
+ .thenComparingInt(candidate -> candidate.sandPos().getY())
+ .thenComparingInt(candidate -> candidate.sandPos().getZ());
+
+ private NestingSiteFinder() {
+ }
+
+ /**
+ * Searches a square extending 32 blocks in X/Z and 8 blocks in Y from the
+ * turtle. A four-block halo includes water beside nests at the boundary.
+ * The caller must check the turtle's collision box and navigation path.
+ */
+ public static List findCandidates(Turtle turtle) {
+ Level level = turtle.level();
+ BlockPos origin = turtle.blockPosition();
+ int originX = origin.getX();
+ int originY = origin.getY();
+ int originZ = origin.getZ();
+ int scanRadius = SEARCH_RADIUS + WATER_RADIUS;
+ int minY = Math.max(level.getMinY(), originY - VERTICAL_SEARCH_RADIUS - WATER_VERTICAL_RADIUS);
+ int maxY = Math.min(level.getMaxY() - 1, originY + VERTICAL_SEARCH_RADIUS + WATER_VERTICAL_RADIUS);
+ int nestMinX = originX - SEARCH_RADIUS;
+ int nestMaxX = originX + SEARCH_RADIUS;
+ int nestMinZ = originZ - SEARCH_RADIUS;
+ int nestMaxZ = originZ + SEARCH_RADIUS;
+ int nestMinY = originY - VERTICAL_SEARCH_RADIUS;
+ int nestMaxY = originY + VERTICAL_SEARCH_RADIUS;
+
+ BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
+ LongArrayList sandBlocks = new LongArrayList();
+ Long2DoubleOpenHashMap waterDistances = new Long2DoubleOpenHashMap();
+ waterDistances.defaultReturnValue(Double.POSITIVE_INFINITY);
+
+ // Read each block once, instead of repeatedly querying the world around
+ // every sand block. Unloaded chunks are never requested or generated.
+ if (minY <= maxY) {
+ Vec3 turtlePos = turtle.position();
+ int minSection = level.getSectionIndex(minY);
+ int maxSection = level.getSectionIndex(maxY);
+ for (int chunkX = (originX - scanRadius) >> 4; chunkX <= (originX + scanRadius) >> 4; chunkX++) {
+ int fromX = Math.max(originX - scanRadius, chunkX << 4);
+ int toX = Math.min(originX + scanRadius, (chunkX << 4) + 15);
+ for (int chunkZ = (originZ - scanRadius) >> 4; chunkZ <= (originZ + scanRadius) >> 4; chunkZ++) {
+ // The same test Level#hasChunk performs, except the chunk is
+ // kept: loadOrGenerate = false never loads or generates.
+ ChunkAccess chunk = level.getChunk(chunkX, chunkZ, ChunkStatus.FULL, false);
+ if (chunk == null) {
+ continue;
+ }
+ int fromZ = Math.max(originZ - scanRadius, chunkZ << 4);
+ int toZ = Math.min(originZ + scanRadius, (chunkZ << 4) + 15);
+ LevelChunkSection[] sections = chunk.getSections();
+ for (int sectionIndex = minSection; sectionIndex <= maxSection; sectionIndex++) {
+ if (sectionIndex < 0 || sectionIndex >= sections.length) {
+ continue;
+ }
+ LevelChunkSection section = sections[sectionIndex];
+ // An all-air section holds neither water nor sand, so
+ // skipping it reads no block the loops could have used.
+ if (section == null || section.hasOnlyAir()) {
+ continue;
+ }
+ int sectionMinY = SectionPos.sectionToBlockCoord(level.getMinSectionY() + sectionIndex);
+ int fromY = Math.max(minY, sectionMinY);
+ int toY = Math.min(maxY, sectionMinY + 15);
+ boolean nestSection = fromY <= nestMaxY && toY >= nestMinY;
+ for (int x = fromX; x <= toX; x++) {
+ int localX = x & 15;
+ boolean nestX = x >= nestMinX && x <= nestMaxX;
+ for (int z = fromZ; z <= toZ; z++) {
+ boolean inNestRange = nestSection && nestX && z >= nestMinZ && z <= nestMaxZ;
+ int localZ = z & 15;
+ for (int y = fromY; y <= toY; y++) {
+ BlockState state = section.getBlockState(localX, y & 15, localZ);
+ if (isExposedSourceWater(level, cursor, x, y, z, state)) {
+ cursor.set(x, y, z);
+ waterDistances.put(BlockPos.asLong(x, y, z),
+ cursor.distToCenterSqr(turtlePos));
+ }
+ if (inNestRange && y >= nestMinY && y <= nestMaxY
+ && state.is(BlockTags.SAND)
+ && level.isEmptyBlock(cursor.set(x, y + 1, z))) {
+ sandBlocks.add(BlockPos.asLong(x, y, z));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ List candidates = new ArrayList<>();
+ if (waterDistances.isEmpty()) {
+ return candidates;
+ }
+ Vec3 turtlePos = turtle.position();
+ for (int index = 0; index < sandBlocks.size(); index++) {
+ long packedSand = sandBlocks.getLong(index);
+ int sandX = BlockPos.getX(packedSand);
+ int sandY = BlockPos.getY(packedSand);
+ int sandZ = BlockPos.getZ(packedSand);
+ double nearestWaterDistance = Double.POSITIVE_INFINITY;
+ for (int dx = -WATER_RADIUS; dx <= WATER_RADIUS; dx++) {
+ for (int dz = -WATER_RADIUS; dz <= WATER_RADIUS; dz++) {
+ if (dx * dx + dz * dz > WATER_RADIUS * WATER_RADIUS) {
+ continue;
+ }
+ for (int dy = -WATER_VERTICAL_RADIUS; dy <= WATER_VERTICAL_RADIUS; dy++) {
+ long waterPosition = BlockPos.asLong(sandX + dx, sandY + dy, sandZ + dz);
+ nearestWaterDistance = Math.min(nearestWaterDistance, waterDistances.get(waterPosition));
+ }
+ }
+ }
+ if (Double.isFinite(nearestWaterDistance)) {
+ cursor.set(sandX, sandY, sandZ);
+ candidates.add(new Candidate(new BlockPos(sandX, sandY, sandZ), nearestWaterDistance,
+ cursor.distToCenterSqr(turtlePos)));
+ }
+ }
+ candidates.sort(CANDIDATE_ORDER);
+ return candidates;
+ }
+
+ /** Rechecks a chosen nest, including changes to its sand, air, or water. */
+ public static boolean isValidSite(LevelReader level, BlockPos sandPos) {
+ if (!level.isInsideBuildHeight(sandPos) || !level.isInsideBuildHeight(sandPos.above())
+ || !level.hasChunkAt(sandPos)
+ || !TurtleEggBlock.isSand(level, sandPos)
+ || !level.isEmptyBlock(sandPos.above())) {
+ return false;
+ }
+ int sandX = sandPos.getX();
+ int sandY = sandPos.getY();
+ int sandZ = sandPos.getZ();
+ BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos();
+ int lastChunkX = Integer.MIN_VALUE;
+ int lastChunkZ = Integer.MIN_VALUE;
+ boolean lastChunkLoaded = false;
+ for (int dx = -WATER_RADIUS; dx <= WATER_RADIUS; dx++) {
+ for (int dz = -WATER_RADIUS; dz <= WATER_RADIUS; dz++) {
+ if (dx * dx + dz * dz > WATER_RADIUS * WATER_RADIUS) {
+ continue;
+ }
+ int x = sandX + dx;
+ int z = sandZ + dz;
+ int chunkX = x >> 4;
+ int chunkZ = z >> 4;
+ // The disk above spans at most four chunks, so this only removes
+ // repeated queries for columns that share a chunk.
+ if (chunkX != lastChunkX || chunkZ != lastChunkZ) {
+ lastChunkX = chunkX;
+ lastChunkZ = chunkZ;
+ lastChunkLoaded = level.hasChunk(chunkX, chunkZ);
+ }
+ if (!lastChunkLoaded) {
+ continue;
+ }
+ for (int dy = -WATER_VERTICAL_RADIUS; dy <= WATER_VERTICAL_RADIUS; dy++) {
+ int y = sandY + dy;
+ cursor.set(x, y, z);
+ if (level.isInsideBuildHeight(y) && level.isInsideBuildHeight(y + 1)
+ && isExposedSourceWater(level, cursor, x, y, z, level.getBlockState(cursor))) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ private static boolean isExposedSourceWater(LevelReader level, BlockPos.MutableBlockPos cursor,
+ int x, int y, int z, BlockState state) {
+ // Requiring a liquid block excludes waterlogged fences, slabs and other
+ // solid blocks even though their fluid state may report source water.
+ if (!state.liquid()) {
+ return false;
+ }
+ FluidState fluid = state.getFluidState();
+ return fluid.is(FluidTags.WATER) && fluid.isSource() && level.isEmptyBlock(cursor.set(x, y + 1, z));
+ }
+
+ public record Candidate(BlockPos sandPos, double waterDistanceSquared, double sandDistanceSquared) {
+ }
+}
diff --git a/src/main/java/com/mr/mixin/PathNavigationAccessor.java b/src/main/java/com/mr/mixin/PathNavigationAccessor.java
new file mode 100644
index 0000000..f8c4a0a
--- /dev/null
+++ b/src/main/java/com/mr/mixin/PathNavigationAccessor.java
@@ -0,0 +1,11 @@
+package com.mr.mixin;
+
+import net.minecraft.world.entity.ai.navigation.PathNavigation;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Accessor;
+
+@Mixin(PathNavigation.class)
+public interface PathNavigationAccessor {
+ @Accessor("maxVisitedNodesMultiplier")
+ float turtleReproduce$getMaxVisitedNodesMultiplier();
+}
diff --git a/src/main/java/com/mr/mixin/TurtleAccessor.java b/src/main/java/com/mr/mixin/TurtleAccessor.java
new file mode 100644
index 0000000..c498dea
--- /dev/null
+++ b/src/main/java/com/mr/mixin/TurtleAccessor.java
@@ -0,0 +1,22 @@
+package com.mr.mixin;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.world.entity.animal.turtle.Turtle;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Accessor;
+import org.spongepowered.asm.mixin.gen.Invoker;
+
+@Mixin(Turtle.class)
+public interface TurtleAccessor {
+ @Invoker("setHasEgg")
+ void turtleReproduce$setHasEgg(boolean hasEgg);
+
+ @Invoker("setLayingEgg")
+ void turtleReproduce$setLayingEgg(boolean layingEgg);
+
+ @Accessor("homePos")
+ BlockPos turtleReproduce$getHomePos();
+
+ @Accessor("goingHome")
+ boolean turtleReproduce$isGoingHome();
+}
diff --git a/src/main/java/com/mr/mixin/TurtleGoHomeGoalMixin.java b/src/main/java/com/mr/mixin/TurtleGoHomeGoalMixin.java
new file mode 100644
index 0000000..6bb5657
--- /dev/null
+++ b/src/main/java/com/mr/mixin/TurtleGoHomeGoalMixin.java
@@ -0,0 +1,32 @@
+package com.mr.mixin;
+
+import net.minecraft.world.entity.animal.turtle.Turtle;
+import org.spongepowered.asm.mixin.Final;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
+
+@Mixin(targets = "net.minecraft.world.entity.animal.turtle.Turtle$TurtleGoHomeGoal")
+public abstract class TurtleGoHomeGoalMixin {
+ @Shadow @Final
+ private Turtle turtle;
+
+ // This goal has no MOVE flag, so priority alone cannot stop it fighting nesting.
+ @Inject(method = {"canUse", "canContinueToUse"}, at = @At("HEAD"), cancellable = true)
+ private void turtleReproduce$stayLocalWhileCarryingEggs(CallbackInfoReturnable cir) {
+ if (this.turtle.hasEgg()) {
+ cir.setReturnValue(false);
+ }
+ }
+
+ @Inject(method = "stop", at = @At("TAIL"))
+ private void turtleReproduce$discardOldHomeRoute(CallbackInfo ci) {
+ if (this.turtle.hasEgg()) {
+ // The vanilla stop only clears goingHome, leaving its last route active.
+ this.turtle.getNavigation().stop();
+ }
+ }
+}
diff --git a/src/main/java/com/mr/mixin/TurtleLayEggGoalMixin.java b/src/main/java/com/mr/mixin/TurtleLayEggGoalMixin.java
new file mode 100644
index 0000000..a1fd772
--- /dev/null
+++ b/src/main/java/com/mr/mixin/TurtleLayEggGoalMixin.java
@@ -0,0 +1,129 @@
+package com.mr.mixin;
+
+import com.mr.ai.NestingSiteFinder;
+import java.util.List;
+import net.minecraft.core.BlockPos;
+import net.minecraft.world.entity.PathfinderMob;
+import net.minecraft.world.entity.ai.goal.MoveToBlockGoal;
+import net.minecraft.world.entity.ai.navigation.PathNavigation;
+import net.minecraft.world.entity.animal.turtle.Turtle;
+import net.minecraft.world.level.LevelReader;
+import net.minecraft.world.level.pathfinder.Path;
+import net.minecraft.world.phys.AABB;
+import net.minecraft.world.phys.Vec3;
+import org.spongepowered.asm.mixin.Final;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.Unique;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
+
+@Mixin(targets = "net.minecraft.world.entity.animal.turtle.Turtle$TurtleLayEggGoal")
+public abstract class TurtleLayEggGoalMixin extends MoveToBlockGoal {
+ @Unique private static final int PATH_SEARCH_RANGE = 48;
+ @Unique private static final int PATH_ATTEMPTS_PER_CHECK = 16;
+
+ @Shadow @Final
+ private Turtle turtle;
+ @Unique private List turtleReproduce$candidates = List.of();
+ @Unique private int turtleReproduce$candidateIndex;
+ @Unique private BlockPos turtleReproduce$searchOrigin = BlockPos.ZERO;
+ @Unique private Path turtleReproduce$nestingPath;
+
+ protected TurtleLayEggGoalMixin(PathfinderMob mob, double speed, int range) {
+ super(mob, speed, range);
+ }
+
+ @Inject(method = "canUse", at = @At("HEAD"), cancellable = true)
+ private void turtleReproduce$findLocalShore(CallbackInfoReturnable cir) {
+ cir.setReturnValue(this.turtle.hasEgg() && !this.turtle.isBaby() && super.canUse());
+ }
+
+ @Inject(method = "canContinueToUse", at = @At("HEAD"), cancellable = true)
+ private void turtleReproduce$continueAwayFromHome(CallbackInfoReturnable cir) {
+ cir.setReturnValue(this.turtle.hasEgg() && super.canContinueToUse());
+ }
+
+ @Inject(method = "isValidTarget", at = @At("HEAD"), cancellable = true)
+ private void turtleReproduce$requireDrySandNearWater(LevelReader level, BlockPos pos,
+ CallbackInfoReturnable cir) {
+ cir.setReturnValue(NestingSiteFinder.isValidSite(level, pos));
+ }
+
+ @Override
+ protected boolean findNearestBlock() {
+ if (this.turtleReproduce$candidateIndex >= this.turtleReproduce$candidates.size()
+ || this.turtle.blockPosition().distSqr(this.turtleReproduce$searchOrigin) > 16.0) {
+ this.turtleReproduce$candidates = NestingSiteFinder.findCandidates(this.turtle);
+ this.turtleReproduce$candidateIndex = 0;
+ this.turtleReproduce$searchOrigin = this.turtle.blockPosition();
+ }
+
+ // Spread expensive path checks over AI updates when a shore is inaccessible.
+ int attempts = 0;
+ while (this.turtleReproduce$candidateIndex < this.turtleReproduce$candidates.size()
+ && attempts++ < PATH_ATTEMPTS_PER_CHECK) {
+ BlockPos sand = this.turtleReproduce$candidates.get(this.turtleReproduce$candidateIndex++).sandPos();
+ if (!this.turtle.isWithinHome(sand) || !NestingSiteFinder.isValidSite(this.turtle.level(), sand)) {
+ continue;
+ }
+ Vec3 destination = Vec3.atBottomCenterOf(sand.above());
+ AABB destinationBounds = this.turtle.getBoundingBox().move(destination.subtract(this.turtle.position()));
+ // Adult turtles are wider than one block. An air block alone does not
+ // guarantee that their body is clear of adjacent water or walls.
+ if (!this.turtle.level().noCollision(this.turtle, destinationBounds)
+ || this.turtle.level().containsAnyLiquid(destinationBounds.deflate(0.001))) {
+ continue;
+ }
+ Path path = this.turtleReproduce$createNestingPath(sand.above());
+ if (path != null && path.canReach()) {
+ this.blockPos = sand;
+ this.turtleReproduce$nestingPath = path;
+ this.turtleReproduce$candidates = List.of();
+ return true;
+ }
+ }
+ if (this.turtleReproduce$candidateIndex < this.turtleReproduce$candidates.size()) {
+ this.nextStartTick = 1;
+ }
+ return false;
+ }
+
+ @Unique
+ private Path turtleReproduce$createNestingPath(BlockPos destination) {
+ PathNavigation navigation = this.turtle.getNavigation();
+ float previousMultiplier = ((PathNavigationAccessor) navigation).turtleReproduce$getMaxVisitedNodesMultiplier();
+ // A larger distance alone leaves vanilla's small node budget unchanged,
+ // incorrectly rejecting shores that require a detour around an obstacle.
+ navigation.setMaxVisitedNodesMultiplier(Math.max(previousMultiplier, 8.0F));
+ try {
+ return navigation.createPath(destination, 0, PATH_SEARCH_RANGE);
+ } finally {
+ navigation.setMaxVisitedNodesMultiplier(previousMultiplier);
+ }
+ }
+
+ @Override
+ protected void moveMobToBlock() {
+ this.turtle.getNavigation().moveTo(this.turtleReproduce$nestingPath, this.speedModifier);
+ }
+
+ @Inject(method = "tick", at = @At("TAIL"))
+ private void turtleReproduce$retryLongerPath(CallbackInfo ci) {
+ if (!this.isReachedTarget() && this.tryTicks % 40 == 0 && this.turtle.getNavigation().isDone()) {
+ this.turtleReproduce$nestingPath = this.turtleReproduce$createNestingPath(this.blockPos.above());
+ this.moveMobToBlock();
+ }
+ }
+
+ @Override
+ public void stop() {
+ super.stop();
+ this.turtle.getNavigation().stop();
+ ((TurtleAccessor) this.turtle).turtleReproduce$setLayingEgg(false);
+ this.turtleReproduce$nestingPath = null;
+ this.turtleReproduce$candidates = List.of();
+ }
+}
diff --git a/src/main/kotlin/com/mr/TurtleReproduce.kt b/src/main/kotlin/com/mr/TurtleReproduce.kt
new file mode 100644
index 0000000..35bca5e
--- /dev/null
+++ b/src/main/kotlin/com/mr/TurtleReproduce.kt
@@ -0,0 +1,18 @@
+package com.mr
+
+import net.fabricmc.api.ModInitializer
+import net.minecraft.resources.Identifier
+import org.slf4j.LoggerFactory
+
+object TurtleReproduce : ModInitializer {
+ const val MOD_ID: String = "turtle-reproduce"
+
+ private val LOGGER = LoggerFactory.getLogger(MOD_ID)
+
+ override fun onInitialize() {
+ LOGGER.info("Turtle Reproduce: local waterside nesting enabled")
+ }
+
+ fun id(path: String): Identifier
+ = Identifier.fromNamespaceAndPath(MOD_ID, path)
+}
diff --git a/src/main/resources/assets/turtle-reproduce/icon.png b/src/main/resources/assets/turtle-reproduce/icon.png
new file mode 100644
index 0000000..de38279
Binary files /dev/null and b/src/main/resources/assets/turtle-reproduce/icon.png differ
diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json
new file mode 100644
index 0000000..9f42508
--- /dev/null
+++ b/src/main/resources/fabric.mod.json
@@ -0,0 +1,43 @@
+{
+ "schemaVersion": 1,
+ "id": "turtle-reproduce",
+ "version": "${version}",
+ "name": "Turtle-Reproduce",
+ "description": "Turtles lay eggs on reachable sand near the closest local water source instead of returning to their birthplace.",
+ "authors": [
+ "Me!"
+ ],
+ "license": "CC0-1.0",
+ "icon": "assets/turtle-reproduce/icon.png",
+ "environment": "*",
+ "entrypoints": {
+ "main": [
+ {
+ "value": "com.mr.TurtleReproduce",
+ "adapter": "kotlin"
+ }
+ ],
+ "client": [
+ {
+ "value": "com.mr.client.TurtleReproduceClient",
+ "adapter": "kotlin"
+ }
+ ],
+ "fabric-datagen": [
+ {
+ "value": "com.mr.client.TurtleReproduceDataGenerator",
+ "adapter": "kotlin"
+ }
+ ]
+ },
+ "mixins": [
+ "turtle-reproduce.mixins.json"
+ ],
+ "depends": {
+ "fabricloader": ">=0.19.5",
+ "minecraft": "~26.1.2",
+ "java": ">=25",
+ "fabric-api": "*",
+ "fabric-language-kotlin": "*"
+ }
+}
diff --git a/src/main/resources/turtle-reproduce.mixins.json b/src/main/resources/turtle-reproduce.mixins.json
new file mode 100644
index 0000000..27d6864
--- /dev/null
+++ b/src/main/resources/turtle-reproduce.mixins.json
@@ -0,0 +1,17 @@
+{
+ "required": true,
+ "package": "com.mr.mixin",
+ "compatibilityLevel": "JAVA_25",
+ "mixins": [
+ "PathNavigationAccessor",
+ "TurtleAccessor",
+ "TurtleGoHomeGoalMixin",
+ "TurtleLayEggGoalMixin"
+ ],
+ "injectors": {
+ "defaultRequire": 1
+ },
+ "overwrites": {
+ "requireAnnotations": true
+ }
+}