海龟就地下蛋mod
build / build (push) Has been cancelled

This commit is contained in:
AxTps committed 2026-10-03 08:40:13 +08:00
commit ab5eb31a28
28 files changed
+1936

No files matched your search

+9
View File
@@ -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
+40
View File
@@ -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/
+40
View File
@@ -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
+121
View File
@@ -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.
+272
View File
@@ -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.
+ *
+ * <p>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.
+ *
+ * <p>Every result-affecting rule is unchanged:
+ * <ul>
+ * <li>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);</li>
+ * <li>the same conditions are tested, in the same order;</li>
+ * <li>unloaded chunks are still skipped, and are still never loaded or
+ * generated ({@code loadOrGenerate = false});</li>
+ * <li>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.</li>
+ * </ul>
+ *
+ * <p>What changed, and why it is equivalent:
+ * <ul>
+ * <li>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;</li>
+ * <li>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;</li>
+ * <li>sand positions are collected as packed longs instead of thousands of
+ * {@code BlockPos} objects, so a scan allocates no per-block garbage;</li>
+ * <li>{@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.</li>
+ * </ul>
+ */
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<Candidate> 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<BlockPos> 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<Candidate> 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) {
}
+93
View File
@@ -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<BlockPos>` → 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。
+104
View File
@@ -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<JavaCompile>().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<MavenPublication>("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.
}
}
+18
View File
@@ -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
Binary file not shown.
+9
View File
@@ -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
Vendored
+248
View File
@@ -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" "$@"
Vendored
+82
View File
@@ -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%
+17
View File
@@ -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"
@@ -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.
}
}
@@ -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) {
}
}
@@ -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;
}
}
@@ -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<NestingSiteFinder.Candidate> 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);
}
}
}
}
}
@@ -0,0 +1,7 @@
{
DataVersion: 2730,
size: [26, 6, 24],
data: [{pos: [0, 0, 0], state: "minecraft:air"}],
entities: [],
palette: ["minecraft:air"]
}
+17
View File
@@ -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": "*"
}
}
@@ -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.
*
* <p>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.
*
* <p>Every result-affecting rule is unchanged:
* <ul>
* <li>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);</li>
* <li>the same conditions are tested, in the same order;</li>
* <li>unloaded chunks are still skipped, and are still never loaded or
* generated ({@code loadOrGenerate = false});</li>
* <li>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.</li>
* </ul>
*
* <p>What changed, and why it is equivalent:
* <ul>
* <li>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;</li>
* <li>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;</li>
* <li>sand positions are collected as packed longs instead of thousands of
* {@code BlockPos} objects, so a scan allocates no per-block garbage;</li>
* <li>{@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.</li>
* </ul>
*/
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> 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<Candidate> 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<Candidate> 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) {
}
}
@@ -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();
}
@@ -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();
}
@@ -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<Boolean> 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();
}
}
}
@@ -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<NestingSiteFinder.Candidate> 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<Boolean> cir) {
cir.setReturnValue(this.turtle.hasEgg() && !this.turtle.isBaby() && super.canUse());
}
@Inject(method = "canContinueToUse", at = @At("HEAD"), cancellable = true)
private void turtleReproduce$continueAwayFromHome(CallbackInfoReturnable<Boolean> 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<Boolean> 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();
}
}
+18
View File
@@ -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)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

+43
View File
@@ -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": "*"
}
}
@@ -0,0 +1,17 @@
{
"required": true,
"package": "com.mr.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": [
"PathNavigationAccessor",
"TurtleAccessor",
"TurtleGoHomeGoalMixin",
"TurtleLayEggGoalMixin"
],
"injectors": {
"defaultRequire": 1
},
"overwrites": {
"requireAnnotations": true
}
}