From 8f072182038e7730c159de6530037c30aeb30c1a Mon Sep 17 00:00:00 2001 From: cuccuc377-dev <257740650+cuccuc377-dev@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:32:11 +0800 Subject: [PATCH 1/2] =?UTF-8?q?docs:=20=E6=95=B4=E7=90=86=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E8=AF=B4=E6=98=8E=E4=B8=8E=E5=BC=80=E5=8F=91=E6=8C=87?= =?UTF-8?q?=E5=8D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 130 +++++++++++++++++++++++++++++++++++++++++--- docs/DEVELOPMENT.md | 81 +++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 docs/DEVELOPMENT.md diff --git a/README.md b/README.md index e2552db..dffe372 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,124 @@ -# Wild Wind Modules 原野之风模块包 +# Wild Wind Modules · 原野之风模块包 -### Module List 模块列表 +面向 Minecraft NeoForge 的模块化模组项目,围绕木材、植物、世界生成和制箭玩法扩展原版内容。 -| name | note | ch name | note | -|:----:|:--------------------:|:-------:|:-------:| -| hfas | Wild Wind HFAS | | 原野之风函数库 | -| deco | Wild Wind Decoration | 装饰 | 原野之风装饰库 | -| adv | Wind Wind Adventure | 冒险 | 原野之风冒险库 | -| vpp | Wild Wind Vanilla++ | 原版修饰 | 原野之风原版库 | +> 当前说明对应 `26.1` 分支。项目处于开发阶段,目前仅启用 **HFAS(`ww_hfas`)**;下文区分源码中已有的实现与尚未完成的玩法,具体游戏表现仍需运行验证。 -[CHANGES](./CHANGE_LOG.MD) \ No newline at end of file +[开发指南](docs/DEVELOPMENT.md) · [历史变更](CHANGE_LOG.MD) · [问题反馈](https://github.com/Polari-Stars-MC/WildWindModule/issues) · [许可证](LICENSE) + +## 版本与环境 + +| 项目 | 当前配置 | +| --- | --- | +| Minecraft | `26.1.2`(依赖范围为精确版本 `[26.1.2]`) | +| 模组加载器 | NeoForge `26.1.2.10-beta` | +| Java 开发工具链 | JDK `25` | +| Gradle Wrapper | `9.4.1` | +| 模组版本 | `1.0.0`(构建配置值) | +| HFAS 模组 ID / 资源命名空间 | `ww_hfas` | + +版本以 [gradle.properties](gradle.properties) 和 [构建脚本](build.gradle.kts) 为准。`1.0.0` 是源码中的版本号,不代表已经发布稳定版。 + +## 模块规划 + +| 模块 | 名称 | 当前分支状态 | +| --- | --- | --- | +| `hfas` | Wild Wind HFAS | 已接入 Gradle,包含木材、植物、世界生成及制箭系统基础代码 | +| `deco` | Wild Wind Decoration · 装饰 | 旧版模块规划,尚未在当前分支启用 | +| `adv` | Wild Wind Adventure · 冒险 | 旧版模块规划,尚未在当前分支启用 | +| `vpp` | Wild Wind Vanilla++ · 原版修饰 | 旧版模块规划,尚未在当前分支启用 | + +模块启用列表见 [settings.gradle.kts](settings.gradle.kts)。 + +## HFAS 内容 + +### 木材与植物 + +源码已注册三组木质方块: + +| 木材 | 注册内容 | 树木相关实现 | +| --- | --- | --- | +| 焚烬木 `blaze` | 15 种方块 | 独立树叶、树苗;配置在下界废土生成 | +| 灵焰木 `soul` | 15 种方块 | 独立树叶、树苗;配置在灵魂沙峡谷生成 | +| 杜鹃木 `azalea` | 13 种方块 | 复用原版杜鹃植物与树叶;配置替换繁茂洞穴的原版杜鹃树生成 | + +三组木材均包含原木、木头、去皮原木、去皮木头、木板、楼梯、台阶、栅栏、栅栏门、门、活板门、压力板和按钮。焚烬木、灵焰木另有树叶和树苗。 + +此外已注册焦灰草方块、焦土、焦灰草丛、焦灰枝条、仙人球,并实现木材去皮映射、部分木质物品的燃料处理,以及向原版创造模式分类添加木材的逻辑。杜鹃施加骨粉的行为通过 Mixin 接入自定义树木生成。 + +### 制箭系统(开发中) + +目前包含自定义制箭台方块、方块实体、菜单、箭羽/箭杆/箭头枚举和箭矢数据组件。客户端界面与实际合成流程尚未完成,箭矢配方生成方法仍为空;当前不能视作完整可玩的制箭系统。 + +模型、语言、标签、掉落表和世界生成数据由数据生成器输出;从源码构建前请阅读[开发指南](docs/DEVELOPMENT.md)。纹理与注册 ID 的一致性也仍需整理。 + +## 安装与配置 + +取得经过验证、与上述 Minecraft / NeoForge 版本匹配的模组 JAR 后,将其放入对应实例的 `mods/` 目录。从源码生成 JAR 的步骤见下方。当前开发状态与待完善项见[开发指南](docs/DEVELOPMENT.md#待完善事项)。 + +HFAS 注册了一个 COMMON 配置项: + +| 配置键 | 默认值 | 作用 | +| --- | --- | --- | +| `worldgen.replaceVanillaFletchingTable` | `true` | 启用服务端原版制箭台替换逻辑 | + +替换发生在方块放置事件及区块加载时。**已有存档中加载的区块也会受影响**:当前区块扫描从世界最低建造高度至固定 `Y=200`,将其中的原版制箭台替换为模组制箭台。关闭配置只会停止后续替换,不会自动恢复已经替换的方块。由于制箭流程尚未完成,测试前请结合这一行为选择配置。 + +## 从源码开始 + +准备 JDK 25,在仓库根目录执行。首次运行需要联网下载 Gradle、插件与游戏依赖。 + +```powershell +# Windows PowerShell +git clone --branch 26.1 https://github.com/Polari-Stars-MC/WildWindModule.git +cd WildWindModule +.\gradlew.bat :hfas:runClientData +.\gradlew.bat :hfas:runServerData +.\gradlew.bat build +``` + +```bash +# Linux / macOS +git clone --branch 26.1 https://github.com/Polari-Stars-MC/WildWindModule.git +cd WildWindModule +chmod +x gradlew +./gradlew :hfas:runClientData +./gradlew :hfas:runServerData +./gradlew build +``` + +数据生成成功后再构建;若生成任务报错,请先处理报错,不要把缺少生成资源的 JAR 当作完整发行包。当前构建脚本未将数据生成任务自动接入 `build`。 + +HFAS 构建产物位于 `modules/hfas/build/libs/`,按当前配置命名为 `ww_hfas-1.0.0.jar`。开发客户端可通过 `:hfas:runClient` 启动。 + +## 项目结构 + +```text +WildWindModule/ +├── src/ +│ ├── hfas/ +│ │ ├── java/ # HFAS 注册、玩法、配置与数据生成代码 +│ │ └── resources/ # 纹理、Blockbench 源文件、Mixin 配置等 +│ └── templates/ # 各模块共用的模组元数据模板 +├── modules/hfas/ # Gradle 配置时创建的子项目目录 +│ └── build/ # HFAS JAR、生成元数据和开发运行目录 +├── build/generated/hfas/ # 数据生成输出(client/、server/) +├── docs/DEVELOPMENT.md # 开发命令、源码导航与待完善项 +├── .github/workflows/build.yml # GitHub Actions 构建流程 +├── build.gradle.kts # 共用构建配置与模块任务 +├── settings.gradle.kts # 模块启用列表与项目目录映射 +├── gradle.properties # 版本、工具与模组属性 +└── CHANGE_LOG.MD # 历史变更记录 +``` + +`modules/hfas/` 是 Gradle 子项目目录,实际源码集中在 `src/hfas/`。生成目录不纳入 Git;不要直接修改其中的文件,修改对应的数据生成器后重新生成。 + +## 参与开发 + +欢迎通过 Issue 反馈问题、讨论功能,并通过 Pull Request 提交修改。提交前请阅读[开发指南](docs/DEVELOPMENT.md),注明目标分支、对应 Minecraft / NeoForge 版本、复现步骤及验证结果。历史记录见 [CHANGE_LOG.MD](CHANGE_LOG.MD),当前实现状态以本分支源码为准。 + +## 许可证 + +仓库 [LICENSE](LICENSE) 声明采用分项许可:源码使用 [GPL-3.0-only](LICENSE_CODE),资源、文档和配置等非代码内容使用 [CC-BY-SA-4.0](LICENSE_ASSETS)。具体条款请阅读对应文件。 + +当前 `gradle.properties` 中的 `modLicense` 仍为 `All Rights Reserved`,与上述仓库声明存在差异,需维护者统一元数据。 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..abc2a67 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,81 @@ +# 开发指南 + +本指南对应 `26.1` 分支。项目介绍、版本与模块状态见 [README](../README.md)。 + +## 环境与构建 + +- 使用 JDK 25;`build.gradle.kts` 为模块设置了 Java 25 工具链。 +- 使用仓库自带的 Gradle Wrapper,无需单独安装 Gradle。 +- Gradle 配置了 Foojay 工具链解析器,但首次依赖下载仍需要可用网络。 +- 当前只有 `hfas` 子项目,模组 ID 为 `ww_hfas`。 + +以下命令均在仓库根目录执行,Windows 使用 `.\gradlew.bat`,Linux / macOS 使用 `./gradlew`: + +| 操作 | Windows PowerShell 命令 | +| --- | --- | +| 查看模块任务 | `.\gradlew.bat :hfas:tasks --all` | +| 生成客户端数据 | `.\gradlew.bat :hfas:runClientData` | +| 生成服务端数据 | `.\gradlew.bat :hfas:runServerData` | +| 构建项目 | `.\gradlew.bat build` | +| 启动开发客户端 | `.\gradlew.bat :hfas:runClient` | +| 启动开发服务端 | `.\gradlew.bat :hfas:runServer` | +| 清理构建输出 | `.\gradlew.bat clean` | + +这些运行入口来自构建脚本中的 `client`、`server`、`clientData`、`serverData` 配置。依赖解析成功是运行任务的前提。 + +### 数据生成顺序 + +首次构建、清理后构建,或修改数据生成代码后,按顺序执行客户端数据生成、服务端数据生成、`build`。开发运行前也应先生成所需资源。任一步失败时,先解决失败原因再继续。 + +| 输出目录 | 来源与内容 | +| --- | --- | +| `build/generated/hfas/client/` | 模型、`en_us` / `zh_cn` 语言文件 | +| `build/generated/hfas/server/` | 方块与物品标签、掉落表、世界生成、数据映射、配方生成入口 | +| `modules/hfas/build/generated/sources/modMetadata/` | 从 `src/templates/` 展开的模组元数据 | +| `modules/hfas/build/libs/` | 构建的 JAR | +| `modules/hfas/build/runs/hfas/` | 开发客户端、服务端及数据生成的运行目录 | + +生成资源被加入模块的资源集,但 `build` 不会自动先运行数据生成,`clean` 会删除生成结果。生成器是否输出了完整内容还取决于其具体实现,例如当前箭矢配方生成方法为空。 + +当前 CI 使用 JDK 25 执行 `./gradlew build`,没有显式执行两类数据生成任务,因此构建通过本身不能证明生成资源完整或玩法可用。`.bbmodel` 文件作为建模源文件保存在源码资源目录中,打包时被排除。 + +## 源码导航 + +HFAS Java 包根目录为 `src/hfas/java/org/polaris2023/wildwind/hfas/`。 + +| 入口 / 目录 | 职责 | +| --- | --- | +| `HFASMod.java` | 模组入口,注册方块、物品、菜单、方块实体、组件与配置 | +| `block/`、`block/entity/` | 方块及制箭台方块实体 | +| `menu/` | 制箭台菜单与槽位逻辑 | +| `arrow/`、`component/` | 箭矢部件定义与数据组件序列化 | +| `config/` | COMMON 配置规范 | +| `event/` | 制箭台替换及燃料事件 | +| `groups/` | 向原版创造模式分类插入物品 | +| `worldgen/` | 树木、放置特征与生物群系修改器 | +| `mixin/` | 杜鹃骨粉生长行为修改 | +| `datagen/` | 客户端 / 服务端数据生成入口和各类 Provider | + +修改资源时注意命名空间 `ww_hfas`,同时核对注册 ID、模型纹理引用、语言键、标签与配方。`gradle.properties` 中的 `modId=wild_wind` 没有直接作为 HFAS 的最终 ID;当前构建脚本通过根项目名 `ww` 与模块名 `hfas` 组合得到 `ww_hfas`。 + +## 模块维护 + +当前构建约定将 `src/<模块名>/java/` 和 `src/<模块名>/resources/` 接入相应子项目,`modules/<模块名>/` 在配置阶段创建。新增模块需要同步修改 `settings.gradle.kts` 的启用列表,并核对模组入口、资源命名空间、Mixin 配置与共用元数据模板。仅在 README 增加模块名称不会生成可用模块。 + +## 待完善事项 + +以下项目根据当前源码整理,是后续开发清单,不代表已完成功能: + +- **制箭交互**:补齐客户端 Screen 与注册,核对菜单和方块实体的容器连接、槽位数量及快捷移动索引;当前制箭台没有注册对应方块物品。 +- **箭矢玩法**:实现配方生成及实际合成流程,并验证部件数据的保存、网络同步与效果行为。 +- **资源一致性**:方块注册使用 `blaze_*` / `soul_*`,现有相关纹理使用 `cinder_*` / `ember_*`;需统一引用与文件命名。资源中的船、告示牌贴图尚不对应已注册的玩法内容。 +- **数据生成与打包**:验证两类数据生成任务,补齐资源后考虑将其接入打包流程及 CI,避免发布缺少模型、语言或世界生成 JSON 的产物。 +- **存档行为**:验证制箭台对旧区块的替换逻辑、扫描范围与性能;README 已记录默认开启和不会自动回滚的行为。 +- **元数据**:替换模组描述模板中的 `Example mod description.`,并由维护者统一 `modLicense` 与仓库许可证声明。 +- **其他模块**:`deco`、`adv`、`vpp` 尚未加入当前分支的 Gradle 模块列表,需要分别实现后再更新状态。 + +## 提交前验证 + +文档改动应核对相对链接、命令、版本和模块状态,并执行 `git diff --check`。 + +涉及代码或资源的改动,应按影响范围运行数据生成与构建,再进行对应客户端或服务端验证;世界生成与制箭台替换需要同时关注新世界及已有区块。Pull Request 中应写明实际执行的检查,以及尚未验证的部分。 From 55b70befb783282c08298c592635f92875f3f0a5 Mon Sep 17 00:00:00 2001 From: cuccuc377-dev <257740650+cuccuc377-dev@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:54:48 +0800 Subject: [PATCH 2/2] feat(structures): add configurable template generation module --- .gitignore | 1 + README.md | 39 ++- build.gradle.kts | 6 +- docs/DEVELOPMENT.md | 7 +- docs/STRUCTURES.md | 158 ++++++++++++ modules/structures/build.gradle.kts | 54 +++++ settings.gradle.kts | 2 +- .../ww_structures/definitions/blocked.json | 7 + .../ww_structures/definitions/disabled.json | 6 + .../ww_structures/definitions/smoke.json | 9 + .../ww_structures/templates/wide_ruins.nbt | Bin 0 -> 1275 bytes .../structures/test/StructureGameTests.java | 89 +++++++ .../test/mixin/GameTestServerMixin.java | 14 ++ .../resources/META-INF/neoforge.mods.toml | 13 + .../minecraft/worldgen/world_preset/flat.json | 21 ++ .../test_instance/generation.json | 7 + .../resources/ww_structures_tests.mixins.json | 7 + .../wildwind/structures/StructuresMod.java | 34 +++ .../structures/command/StructureCommands.java | 91 +++++++ .../config/ConfigStructurePack.java | 229 ++++++++++++++++++ .../config/StructureDefinition.java | 135 +++++++++++ .../worldgen/ConfiguredStructure.java | 111 +++++++++ .../worldgen/ConfiguredTemplatePiece.java | 44 ++++ .../worldgen/GeneratedStructureIndex.java | 69 ++++++ .../resources/examples/example_ruins.json | 23 ++ .../resources/examples/example_ruins.nbt | Bin 0 -> 315 bytes .../resources/ww_structures.mixins.json | 7 + .../config/StructureConfigTest.java | 137 +++++++++++ .../worldgen/GeneratedStructureIndexTest.java | 29 +++ src/templates/META-INF/neoforge.mods.toml | 2 +- 30 files changed, 1344 insertions(+), 7 deletions(-) create mode 100644 docs/STRUCTURES.md create mode 100644 modules/structures/build.gradle.kts create mode 100644 src/structures/gametest/config/ww_structures/definitions/blocked.json create mode 100644 src/structures/gametest/config/ww_structures/definitions/disabled.json create mode 100644 src/structures/gametest/config/ww_structures/definitions/smoke.json create mode 100644 src/structures/gametest/config/ww_structures/templates/wide_ruins.nbt create mode 100644 src/structures/gametest/java/org/polaris2023/wildwind/structures/test/StructureGameTests.java create mode 100644 src/structures/gametest/java/org/polaris2023/wildwind/structures/test/mixin/GameTestServerMixin.java create mode 100644 src/structures/gametest/resources/META-INF/neoforge.mods.toml create mode 100644 src/structures/gametest/resources/data/minecraft/worldgen/world_preset/flat.json create mode 100644 src/structures/gametest/resources/data/ww_structures_tests/test_instance/generation.json create mode 100644 src/structures/gametest/resources/ww_structures_tests.mixins.json create mode 100644 src/structures/java/org/polaris2023/wildwind/structures/StructuresMod.java create mode 100644 src/structures/java/org/polaris2023/wildwind/structures/command/StructureCommands.java create mode 100644 src/structures/java/org/polaris2023/wildwind/structures/config/ConfigStructurePack.java create mode 100644 src/structures/java/org/polaris2023/wildwind/structures/config/StructureDefinition.java create mode 100644 src/structures/java/org/polaris2023/wildwind/structures/worldgen/ConfiguredStructure.java create mode 100644 src/structures/java/org/polaris2023/wildwind/structures/worldgen/ConfiguredTemplatePiece.java create mode 100644 src/structures/java/org/polaris2023/wildwind/structures/worldgen/GeneratedStructureIndex.java create mode 100644 src/structures/resources/examples/example_ruins.json create mode 100644 src/structures/resources/examples/example_ruins.nbt create mode 100644 src/structures/resources/ww_structures.mixins.json create mode 100644 src/structures/test/org/polaris2023/wildwind/structures/config/StructureConfigTest.java create mode 100644 src/structures/test/org/polaris2023/wildwind/structures/worldgen/GeneratedStructureIndexTest.java diff --git a/.gitignore b/.gitignore index b777c76..dfb4a68 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ bin/ ### Minecraft Modding ### run/ +modules/*/logs/ !**/src/**/run/ **/src/generated/**/.cache/ repo/ diff --git a/README.md b/README.md index dffe372..44f015d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 面向 Minecraft NeoForge 的模块化模组项目,围绕木材、植物、世界生成和制箭玩法扩展原版内容。 -> 当前说明对应 `26.1` 分支。项目处于开发阶段,目前仅启用 **HFAS(`ww_hfas`)**;下文区分源码中已有的实现与尚未完成的玩法,具体游戏表现仍需运行验证。 +> 当前说明对应 `26.1` 分支。项目处于开发阶段,已启用 **HFAS(`ww_hfas`)** 和 **结构生成(`ww_structures`)** 两个独立模块。 [开发指南](docs/DEVELOPMENT.md) · [历史变更](CHANGE_LOG.MD) · [问题反馈](https://github.com/Polari-Stars-MC/WildWindModule/issues) · [许可证](LICENSE) @@ -16,6 +16,7 @@ | Gradle Wrapper | `9.4.1` | | 模组版本 | `1.0.0`(构建配置值) | | HFAS 模组 ID / 资源命名空间 | `ww_hfas` | +| 结构生成模组 ID / 资源命名空间 | `ww_structures` | 版本以 [gradle.properties](gradle.properties) 和 [构建脚本](build.gradle.kts) 为准。`1.0.0` 是源码中的版本号,不代表已经发布稳定版。 @@ -24,6 +25,7 @@ | 模块 | 名称 | 当前分支状态 | | --- | --- | --- | | `hfas` | Wild Wind HFAS | 已接入 Gradle,包含木材、植物、世界生成及制箭系统基础代码 | +| `structures` | Wild Wind Structures · 结构生成 | 通过 JSON + NBT 模板添加世界结构,支持生成条件与坐标查询 | | `deco` | Wild Wind Decoration · 装饰 | 旧版模块规划,尚未在当前分支启用 | | `adv` | Wild Wind Adventure · 冒险 | 旧版模块规划,尚未在当前分支启用 | | `vpp` | Wild Wind Vanilla++ · 原版修饰 | 旧版模块规划,尚未在当前分支启用 | @@ -54,6 +56,34 @@ ## 安装与配置 +### 结构生成模块 + +`ww_structures` 可单独安装,不依赖 HFAS。构建命令为 `./gradlew :structures:build`(Windows 使用 `.\gradlew.bat`),产物为 `modules/structures/build/libs/ww_structures-1.0.0.jar`。服务器与客户端使用匹配版本的模块。 + +首次创建或打开世界后,会在游戏/服务端实例目录创建: + +```text +config/ww_structures/ +├── definitions/ # 每个结构一个 JSON 配置 +├── templates/ # 原版结构方块导出的压缩 .nbt 文件 +│ └── example_ruins.nbt # 随附 7 × 4 × 7 遗迹示例 +└── example_ruins.json.disabled # 配置示例,默认不参与生成 +``` + +把示例配置复制为 `definitions/example_ruins.json`,将 `enabled` 改为 `true`,重启世界/服务器后即可在符合条件的新区块生成。可以配置模板文件名、群系与群系标签、生成间距、概率、随机盐、高度、旋转、地形起伏、流体与基础地形方块条件。 + +```mcfunction +/wwstructure list +/wwstructure locate example_ruins +/wwstructure search example_ruins 16 +``` + +`locate` 返回当前维度已记录的生成结构中,水平距离最近的一处坐标;记录随存档保存。`search` 使用原版结构搜索,可以查找尚未探索区域中的生成位置。命令需要管理员权限(与原版 `/locate` 一致)。 + +完整配置、条件含义、结构导出方法、停用规则和测试命令见 **[结构生成模块指南](docs/STRUCTURES.md)**。配置变更需要重启;不会给已生成区块补生成结构。 + +### HFAS 模块 + 取得经过验证、与上述 Minecraft / NeoForge 版本匹配的模组 JAR 后,将其放入对应实例的 `mods/` 目录。从源码生成 JAR 的步骤见下方。当前开发状态与待完善项见[开发指南](docs/DEVELOPMENT.md#待完善事项)。 HFAS 注册了一个 COMMON 配置项: @@ -99,11 +129,18 @@ WildWindModule/ │ ├── hfas/ │ │ ├── java/ # HFAS 注册、玩法、配置与数据生成代码 │ │ └── resources/ # 纹理、Blockbench 源文件、Mixin 配置等 +│ ├── structures/ +│ │ ├── java/ # 配置数据包、结构生成、坐标记录与指令 +│ │ ├── resources/ # 示例配置、NBT 模板、Mixin 配置 +│ │ ├── test/ # JUnit 配置与持久化测试 +│ │ └── gametest/ # 独立游戏测试模组与测试配置 │ └── templates/ # 各模块共用的模组元数据模板 ├── modules/hfas/ # Gradle 配置时创建的子项目目录 │ └── build/ # HFAS JAR、生成元数据和开发运行目录 +├── modules/structures/ # 结构生成模块构建与测试任务 ├── build/generated/hfas/ # 数据生成输出(client/、server/) ├── docs/DEVELOPMENT.md # 开发命令、源码导航与待完善项 +├── docs/STRUCTURES.md # 结构配置与坐标查询指南 ├── .github/workflows/build.yml # GitHub Actions 构建流程 ├── build.gradle.kts # 共用构建配置与模块任务 ├── settings.gradle.kts # 模块启用列表与项目目录映射 diff --git a/build.gradle.kts b/build.gradle.kts index ae723b7..861e9c0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,8 +44,10 @@ allprojects { "minecraft_version_range" to minecraftVersionRange, "neo_version" to neoVersion, "mod_id" to projectModId, - "mod_name" to modName, - "mod_license" to modLicense, + "mod_name" to if (project.name == "structures") "Wild Wind Structures" else modName, + "mod_description" to if (project.name == "structures") + "Configurable NBT structures, world generation rules and generated structure coordinates." else "Example mod description.", + "mod_license" to if (project.name == "structures") "GPL-3.0-only" else modLicense, "mod_version" to modVersion, ) inputs.properties(replaceProperties) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index abc2a67..a01d87b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -7,7 +7,7 @@ - 使用 JDK 25;`build.gradle.kts` 为模块设置了 Java 25 工具链。 - 使用仓库自带的 Gradle Wrapper,无需单独安装 Gradle。 - Gradle 配置了 Foojay 工具链解析器,但首次依赖下载仍需要可用网络。 -- 当前只有 `hfas` 子项目,模组 ID 为 `ww_hfas`。 +- 当前启用 `hfas`(`ww_hfas`)和 `structures`(`ww_structures`)两个子项目,可分别构建。 以下命令均在仓库根目录执行,Windows 使用 `.\gradlew.bat`,Linux / macOS 使用 `./gradlew`: @@ -20,12 +20,15 @@ | 启动开发客户端 | `.\gradlew.bat :hfas:runClient` | | 启动开发服务端 | `.\gradlew.bat :hfas:runServer` | | 清理构建输出 | `.\gradlew.bat clean` | +| 结构模块单元测试 | `.\gradlew.bat :structures:test` | +| 结构模块游戏测试 | `.\gradlew.bat :structures:runGameTest` | +| 仅构建结构模块 | `.\gradlew.bat :structures:build` | 这些运行入口来自构建脚本中的 `client`、`server`、`clientData`、`serverData` 配置。依赖解析成功是运行任务的前提。 ### 数据生成顺序 -首次构建、清理后构建,或修改数据生成代码后,按顺序执行客户端数据生成、服务端数据生成、`build`。开发运行前也应先生成所需资源。任一步失败时,先解决失败原因再继续。 +HFAS 首次构建、清理后构建,或修改数据生成代码后,按顺序执行客户端数据生成、服务端数据生成、`build`。开发运行前也应先生成所需资源。任一步失败时,先解决失败原因再继续。结构模块的资源已直接包含在源码中,无需数据生成,配置与测试说明见 [STRUCTURES.md](STRUCTURES.md)。 | 输出目录 | 来源与内容 | | --- | --- | diff --git a/docs/STRUCTURES.md b/docs/STRUCTURES.md new file mode 100644 index 0000000..9ebb1d4 --- /dev/null +++ b/docs/STRUCTURES.md @@ -0,0 +1,158 @@ +# 结构生成模块 · ww_structures + +为 Minecraft **26.1.2 / NeoForge 26.1.2.10-beta / Java 25** 添加可配置的世界结构。模块独立构建,不依赖 `ww_hfas`。每个结构由一个 JSON 配置与一个原版压缩 NBT 模板组成,无需编写 Java 或手工创建数据包。 + +## 1. 创建第一个结构 + +1. 安装 `ww_structures-1.0.0.jar`,创建或打开世界,使配置目录初始化,然后关闭世界/服务器。 +2. 在实例目录中找到 `config/ww_structures/`。专用服务器使用服务器自己的目录;客户端加入服务器时,以服务器配置为准。 +3. 将 `example_ruins.json.disabled` 复制到 `definitions/example_ruins.json`,把 `enabled` 改为 `true`。 +4. 保留随附的 `templates/example_ruins.nbt`,或换成自己的结构方块模板。 +5. 重启并进入允许的群系,探索新区块。世界必须启用原版的结构生成开关。 +6. 用 `/wwstructure search example_ruins` 搜索生成位置;探索该处后用 `/wwstructure locate example_ruins` 查询已生成记录。 + +目录结构: + +```text +config/ww_structures/ +├── definitions/ +│ ├── example_ruins.json +│ └── forest_house.json +├── templates/ +│ ├── example_ruins.nbt +│ └── houses/forest_house.nbt +└── example_ruins.json.disabled +``` + +只读取 `definitions/` 直属目录下以 `.json` 结尾的文件。模板允许子目录;配置中的路径相对于 `templates/`,使用 `/` 分隔,禁止绝对路径和越界链接。 + +**配置和模板在世界启动时读取。修改后必须完整关闭并重新打开世界/重启服务器,`/reload` 不更新这些结构配置。** 同一实例中的存档共用配置,坐标索引则按存档、维度分别保存。 + +## 2. 配置示例 + +```json +{ + "name": "forest_house", + "enabled": true, + "template": "houses/forest_house.nbt", + "biomes": ["minecraft:plains", "#minecraft:is_forest"], + "placement": { + "spacing": 32, + "separation": 8, + "salt": 18372954, + "frequency": 1.0 + }, + "heightmap": "WORLD_SURFACE_WG", + "y_offset": -1, + "rotation": "random", + "include_entities": false, + "conditions": { + "min_y": 60, + "max_y": 160, + "max_terrain_difference": 6, + "avoid_fluids": true, + "ground_blocks": ["minecraft:stone", "#minecraft:dirt", "minecraft:sand"] + } +} +``` + +### 基本字段 + +| 字段 | 默认值 | 含义 | +| --- | --- | --- | +| `name` | 必填 | 唯一名称,匹配 `[a-z0-9_][a-z0-9_-]{0,63}`。生成 ID 为 `ww_structures:`,也是指令使用的名称 | +| `enabled` | `true` | 是否参与自然生成。`false` 仍加载定义和模板,保留已有结构记录 | +| `template` | 必填 | 相对模板文件名,必须是 `.nbt` | +| `biomes` | 必填、非空 | 群系 ID 或以 `#` 开头的群系标签,列表之间为“或”关系 | +| `heightmap` | `WORLD_SURFACE_WG` | 生成高度基准;也支持 `OCEAN_FLOOR_WG`,适合水底结构 | +| `y_offset` | `0` | 在中心采样点第一格空位高度上加的偏移,范围 `-256..256`;地板在模板第 0 层时可设 `-1` 嵌入地面 | +| `rotation` | `random` | `random`、`none`、`clockwise_90`、`clockwise_180`、`counterclockwise_90` | +| `include_entities` | `false` | 是否生成模板保存的实体;方块实体如箱子不受此开关影响 | + +群系遵循原版结构起点检查,不要求整个建筑覆盖范围都位于同一群系。没有额外维度过滤:在使用相同群系的自定义维度中也可能生成。下界和末地可填写对应的群系 ID;地表高度模式不提供下界洞穴底面专用搜索。 + +### 生成管理:`placement` + +| 字段 | 默认值 | 含义 | +| --- | --- | --- | +| `spacing` | `32` | 原版 random-spread 网格间距,单位为区块(每区块 16 格),范围 `1..4096` | +| `separation` | `8` | 网格内候选点的间隔约束,单位为区块;必须 `0 <= separation < spacing` | +| `salt` | 根据名称计算 | `0..2147483647` 的随机盐。不同结构建议使用不同值,减少候选点重合 | +| `frequency` | `1.0` | 候选点通过概率,必须大于 `0` 且不超过 `1`;`0.25` 表示约四分之一候选点继续检查条件 | + +间距不是从玩家位置量起的固定距离,也不保证每个网格必定生成。群系、高度和地形条件可能排除候选点。同一个世界种子、相同配置下的候选位置具有确定性;改变盐值、间距或条件只影响尚未生成的区块。不同结构之间没有额外的互斥或防重叠规则。 + +### 生成条件:`conditions` + +所有条件按“且”组合。模块在旋转后模板覆盖范围的四角与中心共 **5 个点**采样基础地形,不加载相邻区块。 + +| 字段 | 默认值 | 含义 | +| --- | --- | --- | +| `min_y` / `max_y` | `-64` / `320` | 各采样点的第一格空位高度范围,含边界;不是模板最终最低/最高 Y。取值范围 `-2032..2031`,最小值不得超过最大值 | +| `max_terrain_difference` | `8` | 5 点最高与最低高度差的上限,`0..512`。设 `0` 要求采样点等高 | +| `avoid_fluids` | `true` | 排除采样点地面方块或其上方含流体的位置。水底结构通常应设 `false` | +| `ground_blocks` | `[]` | 地面基础方块 ID/标签的允许列表,空数组表示不限;各采样点均须匹配其中之一 | + +采样发生在原版地表装饰之前。**`ground_blocks` 检查噪声生成器的基础地形,而非最终可见草皮、积雪或树叶;例如主世界常为 `minecraft:stone`。** 只填 `minecraft:grass_block` 可能导致完全不生成。5 点采样不能保证整个底面无坑洞或无水;模块也不会自动铺地基、平整山体或防止与其他结构相交。 + +最终模板底部到顶部必须处于当前维度的建筑高度范围内;不满足条件的候选点会被跳过。 + +## 3. 模板制作 + +使用原版结构方块的保存模式制作结构。保存后的压缩 `.nbt` 文件通常在存档的 `generated/<命名空间>/structure/` 下,将它复制到 `config/ww_structures/templates/`,然后在配置中填写文件名。文件必须包含原版 `size`、`blocks` 和 `palette`/`palettes` 数据。 + +- 最大尺寸为 **64 × 256 × 64**(X × Y × Z),压缩文件最大 16 MiB,解压读取预算为 64 MiB。 +- 支持方块状态和方块实体 NBT。箱子的物品/战利品表按模板保存的数据处理;模块不会自动配置战利品。 +- 模板中的空气会替换覆盖位置的方块;需要保留地形的位置请在保存时使用结构空位方块(structure void)。 +- 结构方块本身会被过滤,DATA 标记没有额外的自定义处理逻辑。拼图方块沿用原版模板片段处理,不提供递归拼接建筑功能。 +- `include_entities=true` 时实体按原版模板生成逻辑放置;请自行控制模板实体数量。 +- 不支持直接加载 `.schem`、`.schematic`、`.litematic` 或 `.snbt`。 +- 引用其他模组方块时,客户端和服务器需安装对应模组;移除依赖可能使模板内容无法正确加载。 + +随附 `example_ruins.nbt` 是 7 × 4 × 7 的苔石基座和四根石砖柱,默认不参与自然生成,可用来验证安装。 + +## 4. 指令与坐标 + +这些命令使用与原版 `/locate` 相同的管理员权限(GAMEMASTER,通常为 OP 等级 2)。支持名称补全,结构名可省略 `ww_structures:`。 + +| 指令 | 作用 | +| --- | --- | +| `/wwstructure list` | 列出已加载的配置结构与当前维度有历史记录的结构名称 | +| `/wwstructure locate forest_house` | 查询当前维度已生成记录中,距离执行位置最近的结构 | +| `/wwstructure search forest_house [radius]` | 使用原版搜索算法查找结构起点,默认半径 `16`,允许 `1..64` | +| `/locate structure ww_structures:forest_house` | 也可以使用原版结构定位指令 | + +`locate` 按 **XZ 平面距离**比较,返回结构包围盒底面中心的 X、Y、Z、维度和距离。它查询已保存的坐标索引,不会为搜索额外生成区块。起始区块完成生成并加载后才入库;仅调用 `search` 得到候选位置不会让它自动成为“已生成记录”。跨区块结构的其他部分会随着相邻区块生成而补全。 + +索引随存档按维度保存,服务器重启后仍保留。玩家拆除建筑不会删除历史记录。旧存档的既有结构会在其起始区块重新加载时补记,模块不扫描整个存档的区域文件,因此不会声称能找到从未记录、从未重新加载的所有旧结构。 + +`search` 可以访问未探索区块的结构起点,返回原版定位位置(其 Y 可能为原版的占位高度),不保证整座建筑已经生成。`radius` 是原版结构候选网格的搜索圈数,**不是方块数或普通区块半径**;大间距和复杂条件可能使搜索明显耗时。先用小半径,未找到不等于整个世界不存在该结构。 + +## 5. 修改、停用与错误排查 + +- 停止继续生成:设置 `enabled=false`,保留 JSON 与模板,重启。已有建筑、结构定义和历史索引会保留;原版搜索可能因不再有放置规则而找不到它,已生成记录仍可用 `locate` 查询。 +- 彻底移除/改名:先备份存档。删除定义或修改 `name` 会改变动态注册表 ID,原版可能丢弃旧区块的对应结构元数据;不会替你拆除已有方块。 +- 修改模板:关闭世界后修改再重启。尚未生成的区块使用新模板;跨区块结构若已生成一部分,应保留旧模板以避免接缝。不要在存档的 `generated/ww_structures/structure/` 下保存同名模板,否则原版存档模板优先级可能覆盖配置模板。 +- 已生成区块不补生成。改变配置后请探索新区块或新建测试世界。 +- JSON 拼写错误、参数类型错误、重名、缺失/越界模板、尺寸不合法会带文件名报错并阻止加载,避免悄悄使用错误规则。未知的群系/方块 ID 或缺失标签由原版数据包注册表加载校验,检查服务器日志。 +- 找不到结构时先核对 `enabled`、世界结构生成开关、群系、Y 范围、地形起伏、流体和基础方块限制。新区块生成仍遵循概率,不能保证刚进世界就遇到。 + +## 6. 开发与验证 + +Windows PowerShell: + +```powershell +.\gradlew.bat :structures:test +.\gradlew.bat :structures:runGameTest +.\gradlew.bat :structures:build +``` + +Linux / macOS 将 `.\gradlew.bat` 换成 `./gradlew`。单元测试覆盖配置边界、路径越界、关闭生成时保留定义、资源输出、模板尺寸与坐标索引序列化。独立 GameTest 模组检查配置数据包加载、原版搜索、实际区块生成、模板方块、片段 NBT 保存恢复和指令执行;不会打包进发行 JAR。 + +GameTest 使用 `modules/structures/build/runs/structures/gameTest/` 下的专用临时世界;重复运行会重置该测试世界。测试报告为该目录的 `results.xml`。开发运行首次需要下载游戏资源;Gradle 的资源下载工具还可能自动安装 JDK 21,游戏与编译继续使用 JDK 25。 + +测试模组在专用 GameTest 世界启用原版默认关闭的结构生成,并为超平坦预设添加测试结构集合。这些改动只存在于测试资源中。普通超平坦世界若指定了 `structure_overrides`,也需将自己的结构集合加入允许列表;常规默认地形世界没有这项超平坦限制。 + +若中文 Windows 路径下 JUnit 报 `ClassNotFoundException`,但对应 `.class` 已存在,可临时使用 `.\gradlew.bat :structures:test "-Dorg.gradle.jvmargs=-Xmx1G -Dfile.encoding=GBK"`,使 Gradle 参数文件与本机 Java 启动器编码一致。源码仍以 UTF-8 编译;此参数无需写入仓库配置。 + +实际代码在 `src/structures/java/`:`config` 负责配置和不可变资源快照,`worldgen` 注册结构类型/模板片段及坐标索引,`command` 注册查询指令。配置资源通过强制启用的服务器数据包 `ww_structures/config` 接入原版世界生成。 diff --git a/modules/structures/build.gradle.kts b/modules/structures/build.gradle.kts new file mode 100644 index 0000000..ae4ed33 --- /dev/null +++ b/modules/structures/build.gradle.kts @@ -0,0 +1,54 @@ +sourceSets { + test { + java.srcDir(rootProject.file("src/structures/test")) + } +} + +repositories { mavenCentral() } + +dependencies { + testImplementation(platform("org.junit:junit-bom:5.13.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() + val runtimeDirectory = layout.buildDirectory.dir("test-runtime") + workingDir(runtimeDirectory) + doFirst { runtimeDirectory.get().asFile.mkdirs() } +} + +neoForge { + addModdingDependenciesTo(sourceSets.test.get()) +} + +val gameTest by sourceSets.creating { + java.srcDir(rootProject.file("src/structures/gametest/java")) + resources.srcDir(rootProject.file("src/structures/gametest/resources")) + compileClasspath += sourceSets.main.get().output + runtimeClasspath += sourceSets.main.get().output +} + +val prepareStructureGameTest by tasks.registering(Sync::class) { + from(rootProject.file("src/structures/gametest/config")) + from(rootProject.file("src/structures/resources/examples/example_ruins.nbt")) { + into("ww_structures/templates") + } + into(layout.buildDirectory.dir("runs/structures/gameTest/config")) +} + +neoForge { + addModdingDependenciesTo(gameTest) + val mainMod = mods.getByName("ww_structures") + val testMod = mods.create("ww_structures_tests") { sourceSet(gameTest) } + runs.configureEach { loadedMods.set(setOf(mainMod)) } + runs.register("gameTest") { + type = "gameTestServer" + sourceSet = gameTest + loadedMods.set(setOf(mainMod, testMod)) + gameDirectory = layout.buildDirectory.dir("runs/structures/gameTest") + programArguments.addAll("--tests", "ww_structures_tests:*", "--report", "results.xml") + taskBefore(prepareStructureGameTest) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 247830d..abfb670 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,7 +8,7 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version("1.0.0") } -val let = listOf("hfas") +val let = listOf("hfas", "structures") let.forEach { include(it) diff --git a/src/structures/gametest/config/ww_structures/definitions/blocked.json b/src/structures/gametest/config/ww_structures/definitions/blocked.json new file mode 100644 index 0000000..4c0bbc2 --- /dev/null +++ b/src/structures/gametest/config/ww_structures/definitions/blocked.json @@ -0,0 +1,7 @@ +{ + "name": "blocked", + "template": "example_ruins.nbt", + "biomes": ["minecraft:plains"], + "placement": {"spacing": 2, "separation": 1, "salt": 123456}, + "conditions": {"min_y": 1000, "max_y": 1001} +} diff --git a/src/structures/gametest/config/ww_structures/definitions/disabled.json b/src/structures/gametest/config/ww_structures/definitions/disabled.json new file mode 100644 index 0000000..6997a49 --- /dev/null +++ b/src/structures/gametest/config/ww_structures/definitions/disabled.json @@ -0,0 +1,6 @@ +{ + "name": "disabled", + "enabled": false, + "template": "example_ruins.nbt", + "biomes": ["minecraft:plains"] +} diff --git a/src/structures/gametest/config/ww_structures/definitions/smoke.json b/src/structures/gametest/config/ww_structures/definitions/smoke.json new file mode 100644 index 0000000..b66cc37 --- /dev/null +++ b/src/structures/gametest/config/ww_structures/definitions/smoke.json @@ -0,0 +1,9 @@ +{ + "name": "smoke", + "template": "wide_ruins.nbt", + "biomes": ["minecraft:plains"], + "placement": {"spacing": 2, "separation": 1, "salt": 142857, "frequency": 1.0}, + "y_offset": -1, + "rotation": "clockwise_90", + "conditions": {"min_y": -2032, "max_y": 2031, "max_terrain_difference": 0, "avoid_fluids": true, "ground_blocks": ["#minecraft:dirt", "minecraft:grass_block"]} +} diff --git a/src/structures/gametest/config/ww_structures/templates/wide_ruins.nbt b/src/structures/gametest/config/ww_structures/templates/wide_ruins.nbt new file mode 100644 index 0000000000000000000000000000000000000000..66d00f6f1873c31ef1e7305499fb20c6183852a9 GIT binary patch literal 1275 zcmajT`%6<%0KoCPUAOt#q=$u-Nz9B8Eszz>%zg-@;_468@wCj&4`OgdgJP33YL8-2 zn4na$GJ+z(bJX+8T&>r=J?XLfA&!DlV&&_OrL~?eGWr9Ket-J%$vKxS{7*mEGW_!F zr@gC`(cE=E6W?#mNL6=SQ?AJd`5L`bH`(2?t)qG5)zKUKYHEM!O^d%;C;p5M-;~<& zp7%|Rl&{&@_F$m#=Y^5Mfn3(*pLiQ{$jG%}HJS?5mBAbY_U__DmBmfbbk)+Ju5SY=-rUEP|DE$jyvo9#Z8raDWbZ*)r0Pf(Bdw>-pqi7UDud5og1g zBAY-l9paM-m%x}RXMkop1MdUnkCSfbgR<&eM zM(tk4rzNmc(UQPAYWJ`dQjVN@%%{WD?qgZxHF6qp5$sg8bik)V*wU^xxD znSsnhs+I~WY1qp$NiT95a6UZ9Cl51|2ILg+O88FEj9@bj^T~vZ;X75cfJPejv25}g zIr*dm8MMjGM6w!v7qAJIC>o!VffcqOo0ly-$LIVrrPAz~JYU}S_P^6x4-MCyxp$xK z=*WLqwP^h8#>&&TKU9u)SM-JAu@m9`Q#m&hUq*k-#HV6S_wT-sj> functions = DeferredRegister.create(Registries.TEST_FUNCTION, "ww_structures_tests"); + functions.register("generation", () -> StructureGameTests::generation); + functions.register(bus); + } + + private static void generation(GameTestHelper helper) { + var level = helper.getLevel(); + var id = Identifier.parse("ww_structures:smoke"); + var holder = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow(ResourceKey.create(Registries.STRUCTURE, id)); + helper.assertTrue(holder.value() instanceof ConfiguredStructure, Component.literal("Config must become a registered structure")); + for (String rejected : new String[]{"blocked", "disabled"}) { + var rejectedHolder = level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getOrThrow( + ResourceKey.create(Registries.STRUCTURE, Identifier.parse("ww_structures:" + rejected))); + helper.assertTrue(level.getChunkSource().getGenerator().findNearestMapStructure(level, HolderSet.direct(rejectedHolder), + new BlockPos(2048, 0, 2048), 2, false) == null, Component.literal(rejected + " must not generate")); + } + // Far from the test arena; generate through the actual ChunkGenerator, not /place. + var result = level.getChunkSource().getGenerator().findNearestMapStructure(level, HolderSet.direct(holder), + new BlockPos(2048, 0, 2048), 4, false); + helper.assertTrue(result != null, Component.literal("Native locate must discover the configured structure")); + var pos = result.getFirst(); + var chunk = level.getChunk(pos); + var start = chunk.getStartForStructure(holder.value()); + helper.assertTrue(start != null && start.isValid(), Component.literal("Generated chunk must contain a valid start")); + var piece = (ConfiguredTemplatePiece) start.getPieces().getFirst(); + var box = piece.getBoundingBox(); + helper.assertTrue(box.getXSpan() == 19 && box.getZSpan() == 23, Component.literal("Rotation must swap template footprint")); + BlockPos floor = new BlockPos(box.minX(), box.minY(), box.minZ()); + // Load every intersecting chunk so that the cross-chunk template has been placed completely. + for (int x = box.minX() >> 4; x <= box.maxX() >> 4; x++) { + for (int z = box.minZ() >> 4; z <= box.maxZ() >> 4; z++) level.getChunk(x, z); + } + helper.assertTrue(level.getBlockState(floor).is(Blocks.MOSSY_COBBLESTONE), Component.literal("Template foundation must generate")); + helper.assertTrue(level.getBlockState(floor.above(1)).is(Blocks.STONE_BRICKS), Component.literal("Template pillar must generate")); + for (int x : new int[]{box.minX(), box.maxX()}) { + for (int z : new int[]{box.minZ(), box.maxZ()}) { + helper.assertTrue(level.getBlockState(new BlockPos(x, box.minY(), z)).is(Blocks.MOSSY_COBBLESTONE), + Component.literal("All corners must generate across chunk boundaries")); + helper.assertTrue(level.getBlockState(new BlockPos(x, box.minY() + 1, z)).is(Blocks.STONE_BRICKS), + Component.literal("Rotated pillars must generate across chunk boundaries")); + } + } + var serialization = StructurePieceSerializationContext.fromLevel(level); + CompoundTag saved = piece.createTag(serialization); + var restored = new ConfiguredTemplatePiece(serialization, saved); + helper.assertTrue(restored.templatePosition().equals(piece.templatePosition()) && restored.getRotation() == piece.getRotation(), + Component.literal("Piece origin and rotation must survive chunk NBT round trip")); + helper.runAfterDelay(2, () -> { + var index = level.getDataStorage().computeIfAbsent(GeneratedStructureIndex.TYPE); + helper.assertTrue(index.nearest(id, pos).isPresent(), Component.literal("Full chunk must enter the generated structure index")); + var source = level.getServer().createCommandSourceStack().withLevel(level); + var dispatcher = level.getServer().getCommands().getDispatcher(); + try { + helper.assertTrue(dispatcher.execute("wwstructure locate smoke", source.withPosition(pos.getCenter())) == 1, + Component.literal("Generated locate command must succeed")); + helper.assertTrue(dispatcher.execute("wwstructure search smoke 2", source.withPosition(pos.getCenter())) == 1, + Component.literal("Native search command must succeed")); + helper.assertTrue(dispatcher.execute("wwstructure locate missing", source) == 0, + Component.literal("Unknown structure must report no result")); + } catch (com.mojang.brigadier.exceptions.CommandSyntaxException exception) { + throw new IllegalStateException(exception); + } + helper.succeed(); + }); + } +} diff --git a/src/structures/gametest/java/org/polaris2023/wildwind/structures/test/mixin/GameTestServerMixin.java b/src/structures/gametest/java/org/polaris2023/wildwind/structures/test/mixin/GameTestServerMixin.java new file mode 100644 index 0000000..6b86b94 --- /dev/null +++ b/src/structures/gametest/java/org/polaris2023/wildwind/structures/test/mixin/GameTestServerMixin.java @@ -0,0 +1,14 @@ +package org.polaris2023.wildwind.structures.test.mixin; + +import net.minecraft.gametest.framework.GameTestServer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyArg; + +/** Vanilla GameTest disables structure generation. Enable it only in the test mod. */ +@Mixin(GameTestServer.class) +public abstract class GameTestServerMixin { + @ModifyArg(method = "", at = @At(value = "INVOKE", + target = "Lnet/minecraft/world/level/levelgen/WorldOptions;(JZZ)V"), index = 1) + private static boolean structuresTests$enableStructures(boolean original) { return true; } +} diff --git a/src/structures/gametest/resources/META-INF/neoforge.mods.toml b/src/structures/gametest/resources/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..55104da --- /dev/null +++ b/src/structures/gametest/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,13 @@ +license="GPL-3.0-only" +[[mods]] +modId="ww_structures_tests" +version="1.0" +displayName="Wild Wind Structure Tests" +[[mixins]] +config="ww_structures_tests.mixins.json" +[[dependencies.ww_structures_tests]] +modId="ww_structures" +type="required" +versionRange="[1.0.0,)" +ordering="AFTER" +side="BOTH" diff --git a/src/structures/gametest/resources/data/minecraft/worldgen/world_preset/flat.json b/src/structures/gametest/resources/data/minecraft/worldgen/world_preset/flat.json new file mode 100644 index 0000000..e53cbdd --- /dev/null +++ b/src/structures/gametest/resources/data/minecraft/worldgen/world_preset/flat.json @@ -0,0 +1,21 @@ +{ + "dimensions": { + "minecraft:overworld": { + "type": "minecraft:overworld", + "generator": { + "type": "minecraft:flat", + "settings": { + "biome": "minecraft:plains", + "features": false, + "lakes": false, + "layers": [ + {"block": "minecraft:bedrock", "height": 1}, + {"block": "minecraft:dirt", "height": 2}, + {"block": "minecraft:grass_block", "height": 1} + ], + "structure_overrides": ["ww_structures:smoke", "ww_structures:blocked"] + } + } + } + } +} diff --git a/src/structures/gametest/resources/data/ww_structures_tests/test_instance/generation.json b/src/structures/gametest/resources/data/ww_structures_tests/test_instance/generation.json new file mode 100644 index 0000000..266b80c --- /dev/null +++ b/src/structures/gametest/resources/data/ww_structures_tests/test_instance/generation.json @@ -0,0 +1,7 @@ +{ + "type": "minecraft:function", + "function": "ww_structures_tests:generation", + "environment": "minecraft:default", + "structure": "ww_structures:smoke", + "max_ticks": 200 +} diff --git a/src/structures/gametest/resources/ww_structures_tests.mixins.json b/src/structures/gametest/resources/ww_structures_tests.mixins.json new file mode 100644 index 0000000..cb18fe7 --- /dev/null +++ b/src/structures/gametest/resources/ww_structures_tests.mixins.json @@ -0,0 +1,7 @@ +{ + "required": true, + "package": "org.polaris2023.wildwind.structures.test.mixin", + "compatibilityLevel": "JAVA_25", + "mixins": ["GameTestServerMixin"], + "injectors": {"defaultRequire": 1} +} diff --git a/src/structures/java/org/polaris2023/wildwind/structures/StructuresMod.java b/src/structures/java/org/polaris2023/wildwind/structures/StructuresMod.java new file mode 100644 index 0000000..33bddf1 --- /dev/null +++ b/src/structures/java/org/polaris2023/wildwind/structures/StructuresMod.java @@ -0,0 +1,34 @@ +package org.polaris2023.wildwind.structures; + +import net.minecraft.core.registries.Registries; +import net.minecraft.world.level.levelgen.structure.StructureType; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceType; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.common.Mod; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.registries.DeferredHolder; +import net.neoforged.neoforge.registries.DeferredRegister; +import org.polaris2023.wildwind.structures.command.StructureCommands; +import org.polaris2023.wildwind.structures.config.ConfigStructurePack; +import org.polaris2023.wildwind.structures.worldgen.ConfiguredStructure; +import org.polaris2023.wildwind.structures.worldgen.ConfiguredTemplatePiece; +import org.polaris2023.wildwind.structures.worldgen.GeneratedStructureIndex; + +@Mod(StructuresMod.MOD_ID) +public final class StructuresMod { + public static final String MOD_ID = "ww_structures"; + private static final DeferredRegister> TYPES = DeferredRegister.create(Registries.STRUCTURE_TYPE, MOD_ID); + private static final DeferredRegister PIECES = DeferredRegister.create(Registries.STRUCTURE_PIECE, MOD_ID); + public static final DeferredHolder, StructureType> STRUCTURE = + TYPES.register("configured", () -> () -> ConfiguredStructure.CODEC); + public static final DeferredHolder PIECE = + PIECES.register("template", () -> ConfiguredTemplatePiece::new); + + public StructuresMod(IEventBus bus) { + TYPES.register(bus); + PIECES.register(bus); + bus.addListener(ConfigStructurePack::addPack); + NeoForge.EVENT_BUS.addListener(StructureCommands::register); + NeoForge.EVENT_BUS.addListener(GeneratedStructureIndex::onChunkLoad); + } +} diff --git a/src/structures/java/org/polaris2023/wildwind/structures/command/StructureCommands.java b/src/structures/java/org/polaris2023/wildwind/structures/command/StructureCommands.java new file mode 100644 index 0000000..c125195 --- /dev/null +++ b/src/structures/java/org/polaris2023/wildwind/structures/command/StructureCommands.java @@ -0,0 +1,91 @@ +package org.polaris2023.wildwind.structures.command; + +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import java.util.TreeSet; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.SharedSuggestionProvider; +import net.minecraft.core.BlockPos; +import net.minecraft.core.HolderSet; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.permissions.Permissions; +import net.neoforged.neoforge.event.RegisterCommandsEvent; +import org.polaris2023.wildwind.structures.StructuresMod; +import org.polaris2023.wildwind.structures.worldgen.ConfiguredStructure; +import org.polaris2023.wildwind.structures.worldgen.GeneratedStructureIndex; + +public final class StructureCommands { + public static void register(RegisterCommandsEvent event) { + event.getDispatcher().register(Commands.literal("wwstructure") + .requires(source -> source.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER)) + .then(Commands.literal("list").executes(context -> { + var names = names(context.getSource()); + context.getSource().sendSuccess(() -> Component.literal("结构名称(配置或当前维度历史记录):" + String.join(", ", names)), false); + return names.size(); + })) + .then(Commands.literal("locate").then(Commands.argument("name", StringArgumentType.word()) + .suggests((context, builder) -> SharedSuggestionProvider.suggest(names(context.getSource()), builder)) + .executes(context -> locate(context.getSource(), StringArgumentType.getString(context, "name"))))) + .then(Commands.literal("search").then(Commands.argument("name", StringArgumentType.word()) + .suggests((context, builder) -> SharedSuggestionProvider.suggest(names(context.getSource()), builder)) + .executes(context -> search(context.getSource(), StringArgumentType.getString(context, "name"), 16)) + .then(Commands.argument("radius", IntegerArgumentType.integer(1, 64)) + .executes(context -> search(context.getSource(), StringArgumentType.getString(context, "name"), + IntegerArgumentType.getInteger(context, "radius"))))))); + } + + private static TreeSet names(CommandSourceStack source) { + TreeSet names = new TreeSet<>(); + source.registryAccess().lookupOrThrow(Registries.STRUCTURE).listElements() + .filter(holder -> holder.value() instanceof ConfiguredStructure) + .forEach(holder -> names.add(holder.key().identifier().toString())); + source.getLevel().getDataStorage().computeIfAbsent(GeneratedStructureIndex.TYPE).names() + .forEach(id -> names.add(id.toString())); + return names; + } + + private static Identifier id(String name) { + return Identifier.tryParse(name.contains(":") ? name : StructuresMod.MOD_ID + ":" + name); + } + + private static int locate(CommandSourceStack source, String name) { + Identifier id = id(name); + if (id == null) { source.sendFailure(Component.literal("无效的结构名称:" + name)); return 0; } + var found = source.getLevel().getDataStorage().computeIfAbsent(GeneratedStructureIndex.TYPE) + .nearest(id, BlockPos.containing(source.getPosition())); + if (found.isEmpty()) { + source.sendFailure(Component.literal("当前维度没有该结构的已生成记录:" + id + "。可用 /wwstructure search " + name + " 寻找生成位置。")); + return 0; + } + return report(source, id, found.get(), "最近的已生成结构"); + } + + private static int search(CommandSourceStack source, String name, int radius) { + Identifier id = id(name); + if (id == null) { source.sendFailure(Component.literal("无效的结构名称:" + name)); return 0; } + var holder = source.registryAccess().lookupOrThrow(Registries.STRUCTURE).get(ResourceKey.create(Registries.STRUCTURE, id)); + if (holder.isEmpty() || !(holder.get().value() instanceof ConfiguredStructure)) { + source.sendFailure(Component.literal("未启用或不存在的结构:" + id)); + return 0; + } + var level = source.getLevel(); + var found = level.getChunkSource().getGenerator().findNearestMapStructure(level, HolderSet.direct(holder.get()), + BlockPos.containing(source.getPosition()), radius, false); + if (found == null) { + source.sendFailure(Component.literal("搜索范围内未找到 " + id + ";请检查群系、生成条件和世界的结构生成开关。")); + return 0; + } + return report(source, id, found.getFirst(), "原版搜索结果(可能尚未生成完整区块)"); + } + + private static int report(CommandSourceStack source, Identifier id, BlockPos position, String label) { + int distance = (int) Math.sqrt(GeneratedStructureIndex.horizontalDistanceSquared(position, BlockPos.containing(source.getPosition()))); + source.sendSuccess(() -> Component.literal(label + " " + id + ": [" + position.getX() + ", " + position.getY() + ", " + + position.getZ() + "],维度 " + source.getLevel().dimension().identifier() + ",水平距离 " + distance + " 格"), false); + return 1; + } +} diff --git a/src/structures/java/org/polaris2023/wildwind/structures/config/ConfigStructurePack.java b/src/structures/java/org/polaris2023/wildwind/structures/config/ConfigStructurePack.java new file mode 100644 index 0000000..bb74036 --- /dev/null +++ b/src/structures/java/org/polaris2023/wildwind/structures/config/ConfigStructurePack.java @@ -0,0 +1,229 @@ +package org.polaris2023.wildwind.structures.config; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mojang.logging.LogUtils; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import net.minecraft.SharedConstants; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.PackLocationInfo; +import net.minecraft.server.packs.PackResources; +import net.minecraft.server.packs.PackSelectionConfig; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.metadata.pack.PackMetadataSection; +import net.minecraft.server.packs.repository.Pack; +import net.minecraft.server.packs.repository.PackSource; +import net.minecraft.server.packs.resources.IoSupplier; +import net.minecraft.util.InclusiveRange; +import net.neoforged.fml.loading.FMLPaths; +import net.neoforged.neoforge.event.AddPackFindersEvent; +import net.neoforged.neoforge.resource.EmptyPackResources; +import org.polaris2023.wildwind.structures.StructuresMod; + +/** Each server repository gets one immutable snapshot, including template bytes. */ +public final class ConfigStructurePack extends EmptyPackResources { + private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create(); + private final Map resources; + + private ConfigStructurePack(PackLocationInfo info, Map resources) { + super(info, new PackMetadataSection(Component.literal("Wild Wind configured structures"), + new InclusiveRange<>(SharedConstants.getCurrentVersion().packVersion(PackType.SERVER_DATA)))); + this.resources = resources; + } + + public static void addPack(AddPackFindersEvent event) { + if (event.getPackType() != PackType.SERVER_DATA || event.isTrusted()) return; + Path root = FMLPaths.CONFIGDIR.get().resolve(StructuresMod.MOD_ID); + final Map snapshot; + try { + snapshot = load(root); + } catch (IOException | RuntimeException exception) { + // Fail before registries load; silently dropping a bad definition would change the world. + throw new IllegalStateException("Cannot load structure config at " + root + ": " + exception.getMessage(), exception); + } + var supplier = new Pack.ResourcesSupplier() { + @Override public PackResources openPrimary(PackLocationInfo info) { return new ConfigStructurePack(info, snapshot); } + @Override public PackResources openFull(PackLocationInfo info, Pack.Metadata metadata) { return openPrimary(info); } + }; + var pack = Pack.readMetaAndCreate(new PackLocationInfo("ww_structures/config", + Component.literal("Wild Wind · 结构生成配置"), PackSource.DEFAULT, Optional.empty()), + supplier, PackType.SERVER_DATA, new PackSelectionConfig(true, Pack.Position.TOP, false)); + if (pack == null) throw new IllegalStateException("Could not create structure configuration datapack"); + event.addRepositorySource(consumer -> consumer.accept(pack)); + } + + public static Map load(Path root) throws IOException { + Files.createDirectories(root.resolve("definitions")); + Files.createDirectories(root.resolve("templates")); + Path example = root.resolve("example_ruins.json.disabled"); + if (Files.notExists(example)) { + try (InputStream input = ConfigStructurePack.class.getResourceAsStream("/examples/example_ruins.json")) { + if (input == null) throw new IOException("Missing bundled example"); + Files.copy(input, example); + } + } + Path exampleTemplate = root.resolve("templates/example_ruins.nbt"); + if (Files.notExists(exampleTemplate)) { + try (InputStream input = ConfigStructurePack.class.getResourceAsStream("/examples/example_ruins.nbt")) { + if (input == null) throw new IOException("Missing bundled example template"); + Files.copy(input, exampleTemplate); + } + } + Map output = new HashMap<>(); + Set names = new HashSet<>(); + Path definitions = root.resolve("definitions").toRealPath(); + try (var files = Files.list(definitions)) { + for (Path file : files.filter(p -> p.getFileName().toString().endsWith(".json")).sorted().toList()) { + try { + if (!file.toRealPath().startsWith(definitions)) throw new IOException("Definition symlink escapes directory"); + if (Files.size(file) > 1024 * 1024) throw new IOException("Definition exceeds 1 MiB"); + JsonObject json = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8)).getAsJsonObject(); + StructureDefinition definition = StructureDefinition.parse(json); + if (!names.add(definition.name())) throw new IllegalArgumentException("Duplicate name: " + definition.name()); + Path template = definition.resolveTemplate(root.resolve("templates")); + if (Files.size(template) > 16 * 1024 * 1024) throw new IOException("Template exceeds 16 MiB"); + byte[] bytes = Files.readAllBytes(template); + validateTemplate(bytes); + addDefinition(output, definition, bytes); + } catch (IOException | RuntimeException exception) { + throw new IOException(file.getFileName() + ": " + exception.getMessage(), exception); + } + } + } + LogUtils.getLogger().info("Loaded {} configured Wild Wind structures from {}", names.size(), root); + return Map.copyOf(output); + } + + static void validateTemplate(byte[] bytes) throws IOException { + var nbt = NbtIo.readCompressed(new ByteArrayInputStream(bytes), NbtAccounter.create(64L * 1024 * 1024)); + var size = nbt.getListOrEmpty("size"); + if (size.size() != 3) throw new IOException("Template must have a three-component size"); + int x = size.getInt(0).orElse(0), y = size.getInt(1).orElse(0), z = size.getInt(2).orElse(0); + if (x < 1 || x > 64 || z < 1 || z > 64 || y < 1 || y > 256) { + throw new IOException("Template size must be within 64 x 256 x 64"); + } + var blocks = nbt.getListOrEmpty("blocks"); + var palettes = nbt.getListOrEmpty("palettes"); + var palette = nbt.getListOrEmpty("palette"); + if (!palettes.isEmpty()) palette = palettes.getList(0).orElseThrow(() -> new IOException("Invalid palettes list")); + if (blocks.isEmpty() || palette.isEmpty()) { + throw new IOException("Template must contain blocks and palette/palettes (compressed structure NBT required)"); + } + int paletteSize = palette.size(); + validatePalette(palette); + for (int i = 0; i < palettes.size(); i++) { + var variant = palettes.getList(i).orElseThrow(() -> new IOException("Invalid palette variant")); + if (variant.size() != paletteSize) throw new IOException("Palette variants must have equal length"); + validatePalette(variant); + } + for (int i = 0; i < blocks.size(); i++) { + var block = blocks.getCompound(i).orElseThrow(() -> new IOException("Invalid block entry")); + int state = block.getIntOr("state", -1); + var pos = block.getListOrEmpty("pos"); + if (state < 0 || state >= paletteSize || pos.size() != 3) throw new IOException("Invalid block state index or position"); + int px = pos.getInt(0).orElse(-1), py = pos.getInt(1).orElse(-1), pz = pos.getInt(2).orElse(-1); + if (px < 0 || px >= x || py < 0 || py >= y || pz < 0 || pz >= z) { + throw new IOException("Block position lies outside template size"); + } + } + } + + private static void validatePalette(net.minecraft.nbt.ListTag palette) throws IOException { + for (int i = 0; i < palette.size(); i++) { + var state = palette.getCompound(i).orElseThrow(() -> new IOException("Invalid palette entry")); + String name = state.getStringOr("Name", ""); + if (!name.contains(":") || Identifier.tryParse(name) == null) throw new IOException("Invalid palette block ID: " + name); + } + } + + static void addDefinition(Map output, StructureDefinition definition, byte[] template) { + String name = definition.name(); + String id = StructuresMod.MOD_ID + ":" + name; + JsonObject structure = new JsonObject(); + structure.addProperty("type", StructuresMod.MOD_ID + ":configured"); + structure.addProperty("biomes", "#" + id); + structure.add("spawn_overrides", new JsonObject()); + structure.addProperty("step", "surface_structures"); + structure.addProperty("terrain_adaptation", "none"); + structure.addProperty("template", id); + structure.addProperty("heightmap", definition.heightmap()); + structure.addProperty("y_offset", definition.yOffset()); + structure.addProperty("rotation", definition.rotation()); + structure.addProperty("include_entities", definition.includeEntities()); + structure.addProperty("min_y", definition.conditions().minY()); + structure.addProperty("max_y", definition.conditions().maxY()); + structure.addProperty("max_terrain_difference", definition.conditions().maxTerrainDifference()); + structure.addProperty("avoid_fluids", definition.conditions().avoidFluids()); + if (definition.conditions().groundBlocks().isEmpty()) structure.add("ground_blocks", new JsonArray()); + else { + structure.addProperty("ground_blocks", "#" + id); + putJson(output, "tags/block/" + name + ".json", tag(definition.conditions().groundBlocks())); + } + putJson(output, "worldgen/structure/" + name + ".json", structure); + putJson(output, "tags/worldgen/biome/" + name + ".json", tag(definition.biomes())); + output.put(resource("structure/" + name + ".nbt"), template); + // Keep disabled structures and templates registered so existing chunk starts remain readable. + if (!definition.enabled()) return; + JsonObject set = new JsonObject(); + JsonArray entries = new JsonArray(); + JsonObject entry = new JsonObject(); + entry.addProperty("structure", id); + entry.addProperty("weight", 1); + entries.add(entry); + set.add("structures", entries); + JsonObject placement = new JsonObject(); + placement.addProperty("type", "minecraft:random_spread"); + placement.addProperty("spacing", definition.spacing()); + placement.addProperty("separation", definition.separation()); + placement.addProperty("salt", definition.salt()); + placement.addProperty("frequency", definition.frequency()); + placement.addProperty("frequency_reduction_method", "default"); + placement.addProperty("spread_type", "linear"); + set.add("placement", placement); + putJson(output, "worldgen/structure_set/" + name + ".json", set); + } + + private static JsonObject tag(java.util.List values) { + JsonObject tag = new JsonObject(); + tag.addProperty("replace", false); + tag.add("values", JSON.toJsonTree(values)); + return tag; + } + + private static Identifier resource(String path) { return Identifier.fromNamespaceAndPath(StructuresMod.MOD_ID, path); } + private static void putJson(Map output, String path, JsonObject json) { + output.put(resource(path), JSON.toJson(json).getBytes(StandardCharsets.UTF_8)); + } + + @Override public Set getNamespaces(PackType type) { + return type == PackType.SERVER_DATA ? Set.of(StructuresMod.MOD_ID) : Set.of(); + } + @Override public IoSupplier getResource(PackType type, Identifier location) { + byte[] bytes = type == PackType.SERVER_DATA ? resources.get(location) : null; + return bytes == null ? null : () -> new ByteArrayInputStream(bytes); + } + @Override public void listResources(PackType type, String namespace, String path, ResourceOutput output) { + if (type != PackType.SERVER_DATA) return; + resources.forEach((id, bytes) -> { + if (id.getNamespace().equals(namespace) && id.getPath().startsWith(path + "/")) { + output.accept(id, () -> new ByteArrayInputStream(bytes)); + } + }); + } +} diff --git a/src/structures/java/org/polaris2023/wildwind/structures/config/StructureDefinition.java b/src/structures/java/org/polaris2023/wildwind/structures/config/StructureDefinition.java new file mode 100644 index 0000000..88dc35c --- /dev/null +++ b/src/structures/java/org/polaris2023/wildwind/structures/config/StructureDefinition.java @@ -0,0 +1,135 @@ +package org.polaris2023.wildwind.structures.config; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** Validated user configuration. Registry and template validation happens when building the pack. */ +public record StructureDefinition(String name, boolean enabled, String template, List biomes, + int spacing, int separation, int salt, double frequency, String heightmap, + int yOffset, String rotation, boolean includeEntities, Conditions conditions) { + public record Conditions(int minY, int maxY, int maxTerrainDifference, + boolean avoidFluids, List groundBlocks) {} + + public static StructureDefinition parse(JsonObject json) { + keys(json, Set.of("name", "enabled", "template", "biomes", "placement", "heightmap", + "y_offset", "rotation", "include_entities", "conditions")); + String name = string(json, "name", null); + if (!name.matches("[a-z0-9_][a-z0-9_-]{0,63}")) { + throw new IllegalArgumentException("name must match [a-z0-9_][a-z0-9_-]{0,63}"); + } + String template = string(json, "template", null); + if (!template.endsWith(".nbt")) throw new IllegalArgumentException("template must end with .nbt"); + List biomes = identifiers(json, "biomes"); + if (biomes.isEmpty()) throw new IllegalArgumentException("biomes must not be empty"); + JsonObject placement = object(json, "placement"); + keys(placement, Set.of("spacing", "separation", "salt", "frequency")); + int spacing = integer(placement, "spacing", 32, 1, 4096); + int separation = integer(placement, "separation", 8, 0, 4095); + if (separation >= spacing) throw new IllegalArgumentException("separation must be smaller than spacing"); + int salt = integer(placement, "salt", name.hashCode() & Integer.MAX_VALUE, 0, Integer.MAX_VALUE); + double frequency = number(placement, "frequency", 1.0); + if (!Double.isFinite(frequency) || frequency <= 0 || frequency > 1) { + throw new IllegalArgumentException("frequency must be > 0 and <= 1 (use enabled=false to disable)"); + } + String heightmap = string(json, "heightmap", "WORLD_SURFACE_WG"); + if (!Set.of("WORLD_SURFACE_WG", "OCEAN_FLOOR_WG").contains(heightmap)) { + throw new IllegalArgumentException("heightmap must be WORLD_SURFACE_WG or OCEAN_FLOOR_WG"); + } + String rotation = string(json, "rotation", "random"); + if (!Set.of("random", "none", "clockwise_90", "clockwise_180", "counterclockwise_90").contains(rotation)) { + throw new IllegalArgumentException("unsupported rotation: " + rotation); + } + JsonObject conditions = object(json, "conditions"); + keys(conditions, Set.of("min_y", "max_y", "max_terrain_difference", "avoid_fluids", "ground_blocks")); + int minY = integer(conditions, "min_y", -64, -2032, 2031); + int maxY = integer(conditions, "max_y", 320, -2032, 2031); + if (minY > maxY) throw new IllegalArgumentException("min_y must be <= max_y"); + return new StructureDefinition(name, bool(json, "enabled", true), template, biomes, spacing, separation, salt, frequency, + heightmap, integer(json, "y_offset", 0, -256, 256), rotation, + bool(json, "include_entities", false), + new Conditions(minY, maxY, integer(conditions, "max_terrain_difference", 8, 0, 512), + bool(conditions, "avoid_fluids", true), identifiers(conditions, "ground_blocks"))); + } + + /** Both lexical traversal and symlink escapes are rejected. */ + public Path resolveTemplate(Path directory) throws java.io.IOException { + Path root = directory.toRealPath(); + Path relative = Path.of(template); + if (relative.isAbsolute() || template.contains("\\") || template.contains(":")) { + throw new IllegalArgumentException("template must be a relative path using /"); + } + Path candidate = root.resolve(relative).normalize(); + if (!candidate.startsWith(root)) throw new IllegalArgumentException("template escapes templates directory"); + Path real = candidate.toRealPath(); + if (!real.startsWith(root)) throw new IllegalArgumentException("template symlink escapes templates directory"); + return real; + } + + public static boolean bool(JsonObject object, String key, boolean fallback) { + if (!object.has(key)) return fallback; + JsonElement value = object.get(key); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) { + throw new IllegalArgumentException(key + " must be a boolean"); + } + return value.getAsBoolean(); + } + + private static void keys(JsonObject object, Set allowed) { + for (String key : object.keySet()) { + if (!allowed.contains(key)) throw new IllegalArgumentException("unknown option: " + key); + } + } + + private static JsonObject object(JsonObject parent, String key) { + if (!parent.has(key)) return new JsonObject(); + if (!parent.get(key).isJsonObject()) throw new IllegalArgumentException(key + " must be an object"); + return parent.getAsJsonObject(key); + } + + private static String string(JsonObject object, String key, String fallback) { + if (!object.has(key)) { + if (fallback == null) throw new IllegalArgumentException("missing option: " + key); + return fallback; + } + JsonElement value = object.get(key); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) { + throw new IllegalArgumentException(key + " must be a string"); + } + return value.getAsString(); + } + + private static double number(JsonObject object, String key, double fallback) { + if (!object.has(key)) return fallback; + JsonElement value = object.get(key); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) { + throw new IllegalArgumentException(key + " must be a number"); + } + return value.getAsDouble(); + } + + private static int integer(JsonObject object, String key, int fallback, int min, int max) { + double value = number(object, key, fallback); + if (!Double.isFinite(value) || value != Math.rint(value) || value < min || value > max) { + throw new IllegalArgumentException(key + " must be an integer in [" + min + ", " + max + "]"); + } + return (int) value; + } + + private static List identifiers(JsonObject object, String key) { + if (!object.has(key)) return List.of(); + if (!object.get(key).isJsonArray()) throw new IllegalArgumentException(key + " must be an array"); + List values = new ArrayList<>(); + for (JsonElement entry : object.getAsJsonArray(key)) { + if (!entry.isJsonPrimitive() || !entry.getAsJsonPrimitive().isString() + || !entry.getAsString().matches("#?[a-z0-9_.-]+:[a-z0-9/._-]+")) { + throw new IllegalArgumentException(key + " entries must be namespaced IDs or #tags"); + } + values.add(entry.getAsString()); + } + return List.copyOf(values); + } +} diff --git a/src/structures/java/org/polaris2023/wildwind/structures/worldgen/ConfiguredStructure.java b/src/structures/java/org/polaris2023/wildwind/structures/worldgen/ConfiguredStructure.java new file mode 100644 index 0000000..4af2549 --- /dev/null +++ b/src/structures/java/org/polaris2023/wildwind/structures/worldgen/ConfiguredStructure.java @@ -0,0 +1,111 @@ +package org.polaris2023.wildwind.structures.worldgen; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import java.util.Optional; +import net.minecraft.core.BlockPos; +import net.minecraft.core.HolderSet; +import net.minecraft.core.RegistryCodecs; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Mirror; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureType; +import org.polaris2023.wildwind.structures.StructuresMod; + +public final class ConfiguredStructure extends Structure { + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( + settingsCodec(instance), + Identifier.CODEC.fieldOf("template").forGetter(s -> s.template), + Heightmap.Types.CODEC.fieldOf("heightmap").forGetter(s -> s.heightmap), + Codec.intRange(-256, 256).fieldOf("y_offset").forGetter(s -> s.yOffset), + Codec.STRING.fieldOf("rotation").forGetter(s -> s.rotation), + Codec.BOOL.fieldOf("include_entities").forGetter(s -> s.includeEntities), + Codec.INT.fieldOf("min_y").forGetter(s -> s.minY), + Codec.INT.fieldOf("max_y").forGetter(s -> s.maxY), + Codec.intRange(0, 512).fieldOf("max_terrain_difference").forGetter(s -> s.maxTerrainDifference), + Codec.BOOL.fieldOf("avoid_fluids").forGetter(s -> s.avoidFluids), + RegistryCodecs.homogeneousList(Registries.BLOCK).fieldOf("ground_blocks").forGetter(s -> s.groundBlocks) + ).apply(instance, ConfiguredStructure::new)); + + private final Identifier template; + private final Heightmap.Types heightmap; + private final int yOffset; + private final String rotation; + private final boolean includeEntities; + private final int minY, maxY, maxTerrainDifference; + private final boolean avoidFluids; + private final HolderSet groundBlocks; + + public ConfiguredStructure(StructureSettings settings, Identifier template, Heightmap.Types heightmap, + int yOffset, String rotation, boolean includeEntities, int minY, int maxY, + int maxTerrainDifference, boolean avoidFluids, HolderSet groundBlocks) { + super(settings); + this.template = template; + this.heightmap = heightmap; + this.yOffset = yOffset; + this.rotation = rotation; + this.includeEntities = includeEntities; + this.minY = minY; + this.maxY = maxY; + this.maxTerrainDifference = maxTerrainDifference; + this.avoidFluids = avoidFluids; + this.groundBlocks = groundBlocks; + } + + @Override protected Optional findGenerationPoint(GenerationContext context) { + var loaded = context.structureTemplateManager().get(template); + if (loaded.isEmpty()) return Optional.empty(); + var structureTemplate = loaded.get(); + Rotation chosenRotation = rotation.equals("random") ? Rotation.getRandom(context.random()) : rotation(rotation); + var size = structureTemplate.getSize(chosenRotation); + if (size.getX() < 1 || size.getZ() < 1 || size.getY() < 1 + || size.getX() > 64 || size.getZ() > 64 || size.getY() > 256) return Optional.empty(); + int centerX = context.chunkPos().getMiddleBlockX(), centerZ = context.chunkPos().getMiddleBlockZ(); + int left = centerX - size.getX() / 2, front = centerZ - size.getZ() / 2; + int right = left + size.getX() - 1, back = front + size.getZ() - 1; + // Sample four corners and the center before surface decoration. No adjacent chunks are loaded. + int[][] samples = {{left, front}, {right, front}, {left, back}, {right, back}, {centerX, centerZ}}; + int lowest = Integer.MAX_VALUE, highest = Integer.MIN_VALUE, centerHeight = 0; + for (int[] sample : samples) { + int height = context.chunkGenerator().getFirstFreeHeight(sample[0], sample[1], heightmap, + context.heightAccessor(), context.randomState()); + if (height < minY || height > maxY) return Optional.empty(); + lowest = Math.min(lowest, height); + highest = Math.max(highest, height); + centerHeight = height; + var column = context.chunkGenerator().getBaseColumn(sample[0], sample[1], context.heightAccessor(), context.randomState()); + var ground = column.getBlock(height - 1); + if (avoidFluids && (!ground.getFluidState().isEmpty() || !column.getBlock(height).getFluidState().isEmpty())) { + return Optional.empty(); + } + if (groundBlocks.size() > 0 && !groundBlocks.contains(ground.typeHolder())) return Optional.empty(); + } + if (highest - lowest > maxTerrainDifference) return Optional.empty(); + int y = centerHeight + yOffset; + if (y < context.heightAccessor().getMinY() || y + size.getY() > context.heightAccessor().getMaxY() + 1) { + return Optional.empty(); + } + BlockPos corner = new BlockPos(left, y, front); + BlockPos origin = structureTemplate.getZeroPositionWithTransform(corner, Mirror.NONE, chosenRotation); + BlockPos locatePosition = new BlockPos(centerX, y, centerZ); + return Optional.of(new GenerationStub(locatePosition, builder -> builder.addPiece( + new ConfiguredTemplatePiece(context.structureTemplateManager(), template, origin, chosenRotation, includeEntities)))); + } + + static Rotation rotation(String name) { + return switch (name) { + case "none" -> Rotation.NONE; + case "clockwise_90" -> Rotation.CLOCKWISE_90; + case "clockwise_180" -> Rotation.CLOCKWISE_180; + case "counterclockwise_90" -> Rotation.COUNTERCLOCKWISE_90; + default -> throw new IllegalArgumentException("Unknown structure rotation: " + name); + }; + } + + @Override public StructureType type() { return StructuresMod.STRUCTURE.get(); } +} diff --git a/src/structures/java/org/polaris2023/wildwind/structures/worldgen/ConfiguredTemplatePiece.java b/src/structures/java/org/polaris2023/wildwind/structures/worldgen/ConfiguredTemplatePiece.java new file mode 100644 index 0000000..8a1ab34 --- /dev/null +++ b/src/structures/java/org/polaris2023/wildwind/structures/worldgen/ConfiguredTemplatePiece.java @@ -0,0 +1,44 @@ +package org.polaris2023.wildwind.structures.worldgen; + +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.Identifier; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ServerLevelAccessor; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.TemplateStructurePiece; +import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext; +import net.minecraft.world.level.levelgen.structure.templatesystem.BlockIgnoreProcessor; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; +import org.polaris2023.wildwind.structures.StructuresMod; + +/** Vanilla pieces clip blocks/entities to each generating chunk and persist template/rotation. */ +public final class ConfiguredTemplatePiece extends TemplateStructurePiece { + public ConfiguredTemplatePiece(StructureTemplateManager manager, Identifier template, BlockPos position, + Rotation rotation, boolean includeEntities) { + super(StructuresMod.PIECE.get(), 0, manager, template, template.toString(), settings(rotation, includeEntities), position); + } + + public ConfiguredTemplatePiece(StructurePieceSerializationContext context, CompoundTag tag) { + super(StructuresMod.PIECE.get(), tag, context.structureTemplateManager(), + id -> settings(Rotation.valueOf(tag.getStringOr("Rotation", "NONE")), tag.getBooleanOr("IncludeEntities", false))); + } + + private static StructurePlaceSettings settings(Rotation rotation, boolean includeEntities) { + return new StructurePlaceSettings().setRotation(rotation).setIgnoreEntities(!includeEntities) + .addProcessor(BlockIgnoreProcessor.STRUCTURE_BLOCK); + } + + @Override protected void addAdditionalSaveData(StructurePieceSerializationContext context, CompoundTag tag) { + super.addAdditionalSaveData(context, tag); + tag.putString("Rotation", placeSettings.getRotation().name()); + tag.putBoolean("IncludeEntities", !placeSettings.isIgnoreEntities()); + } + + @Override protected void handleDataMarker(String marker, BlockPos position, ServerLevelAccessor level, + RandomSource random, BoundingBox bounds) { + // Custom DATA markers are intentionally not interpreted; structure blocks are filtered out. + } +} diff --git a/src/structures/java/org/polaris2023/wildwind/structures/worldgen/GeneratedStructureIndex.java b/src/structures/java/org/polaris2023/wildwind/structures/worldgen/GeneratedStructureIndex.java new file mode 100644 index 0000000..520cd78 --- /dev/null +++ b/src/structures/java/org/polaris2023/wildwind/structures/worldgen/GeneratedStructureIndex.java @@ -0,0 +1,69 @@ +package org.polaris2023.wildwind.structures.worldgen; + +import com.mojang.serialization.Codec; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; +import net.neoforged.neoforge.event.level.ChunkEvent; +import org.polaris2023.wildwind.structures.StructuresMod; + +/** Per-dimension index; only full chunks actually loaded into a world are recorded. */ +public final class GeneratedStructureIndex extends SavedData { + public static final Codec CODEC = Codec.unboundedMap(Identifier.CODEC, BlockPos.CODEC.listOf()) + .xmap(GeneratedStructureIndex::new, GeneratedStructureIndex::snapshot); + public static final SavedDataType TYPE = new SavedDataType<>( + Identifier.fromNamespaceAndPath(StructuresMod.MOD_ID, "generated_structures"), GeneratedStructureIndex::new, CODEC); + private final Map> positions = new HashMap<>(); + + public GeneratedStructureIndex() {} + private GeneratedStructureIndex(Map> saved) { + saved.forEach((id, list) -> positions.put(id, new HashSet<>(list))); + } + private Map> snapshot() { + Map> result = new HashMap<>(); + positions.forEach((id, values) -> result.put(id, new ArrayList<>(values))); + return result; + } + public void record(Identifier id, BlockPos position) { + if (positions.computeIfAbsent(id, ignored -> new HashSet<>()).add(position.immutable())) setDirty(); + } + public Set names() { return Set.copyOf(positions.keySet()); } + public Optional nearest(Identifier id, BlockPos from) { + return positions.getOrDefault(id, Set.of()).stream().min(Comparator + .comparingDouble((BlockPos p) -> horizontalDistanceSquared(p, from)) + .thenComparingInt(BlockPos::getX).thenComparingInt(BlockPos::getZ).thenComparingInt(BlockPos::getY)); + } + public static double horizontalDistanceSquared(BlockPos a, BlockPos b) { + double x = (double) a.getX() - b.getX(), z = (double) a.getZ() - b.getZ(); + return x * x + z * z; + } + + public static void onChunkLoad(ChunkEvent.Load event) { + if (!(event.getLevel() instanceof ServerLevel level)) return; + var registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); + Map found = new HashMap<>(); + event.getChunk().getAllStarts().forEach((structure, start) -> { + if (!(structure instanceof ConfiguredStructure) || !start.isValid()) return; + Identifier id = registry.getKey(structure); + var bounds = start.getBoundingBox(); + if (id != null) found.put(id, new BlockPos(bounds.getCenter().getX(), bounds.minY(), bounds.getCenter().getZ())); + }); + if (found.isEmpty()) return; + // Do not touch SavedData or request chunks from the chunk-load callback. + level.getServer().execute(() -> { + var index = level.getDataStorage().computeIfAbsent(TYPE); + found.forEach(index::record); + }); + } +} diff --git a/src/structures/resources/examples/example_ruins.json b/src/structures/resources/examples/example_ruins.json new file mode 100644 index 0000000..1bc47cf --- /dev/null +++ b/src/structures/resources/examples/example_ruins.json @@ -0,0 +1,23 @@ +{ + "name": "example_ruins", + "enabled": false, + "template": "example_ruins.nbt", + "biomes": ["minecraft:plains", "#minecraft:is_forest"], + "placement": { + "spacing": 32, + "separation": 8, + "salt": 18372954, + "frequency": 1.0 + }, + "heightmap": "WORLD_SURFACE_WG", + "y_offset": -1, + "rotation": "random", + "include_entities": false, + "conditions": { + "min_y": 60, + "max_y": 160, + "max_terrain_difference": 6, + "avoid_fluids": true, + "ground_blocks": ["#minecraft:dirt", "minecraft:stone", "minecraft:sand"] + } +} diff --git a/src/structures/resources/examples/example_ruins.nbt b/src/structures/resources/examples/example_ruins.nbt new file mode 100644 index 0000000000000000000000000000000000000000..30160f936e618b67b27f237a3055414f9419c5ed GIT binary patch literal 315 zcmb2|=3oE;rvJUy_GYy>h&Vj7t}nmtJ6*~5UW#Jut0|e{Q#n1ZM9#^a#D4m-E9+!| zvgyh^Ruvz9nLct{A~b*Z`4=uJk-IB@K8-o~F6R5jDHSuem+AjoD!0gn@2x@>3jSq-ggZvT1+?@ F7yzGrmumn3 literal 0 HcmV?d00001 diff --git a/src/structures/resources/ww_structures.mixins.json b/src/structures/resources/ww_structures.mixins.json new file mode 100644 index 0000000..b4efd31 --- /dev/null +++ b/src/structures/resources/ww_structures.mixins.json @@ -0,0 +1,7 @@ +{ + "required": true, + "package": "org.polaris2023.wildwind.structures.mixin", + "compatibilityLevel": "JAVA_25", + "mixins": [], + "client": [] +} diff --git a/src/structures/test/org/polaris2023/wildwind/structures/config/StructureConfigTest.java b/src/structures/test/org/polaris2023/wildwind/structures/config/StructureConfigTest.java new file mode 100644 index 0000000..ca4f032 --- /dev/null +++ b/src/structures/test/org/polaris2023/wildwind/structures/config/StructureConfigTest.java @@ -0,0 +1,137 @@ +package org.polaris2023.wildwind.structures.config; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.ByteArrayOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.IntTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.NbtIo; +import net.minecraft.resources.Identifier; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +class StructureConfigTest { + @TempDir Path directory; + + private static JsonObject definition() { + return JsonParser.parseString(""" + {"name":"ruins", "template":"ruins.nbt", "biomes":["minecraft:plains", "#minecraft:is_forest"]} + """).getAsJsonObject(); + } + + @Test void defaultsAndNamespacedBiomeTags() { + var config = StructureDefinition.parse(definition()); + assertEquals(32, config.spacing()); + assertEquals(8, config.separation()); + assertEquals("#minecraft:is_forest", config.biomes().get(1)); + assertFalse(config.includeEntities()); + assertTrue(config.conditions().avoidFluids()); + } + + @Test void rejectsInvalidPlacementAndMistypedOptions() { + for (String placement : new String[]{"{\"spacing\":8,\"separation\":8}", "{\"frequency\":0}", + "{\"frequency\":1.1}", "{\"spacing\":32.5}", "{\"salt\":-1}", "{\"spacing\":\"32\"}"}) { + JsonObject json = definition(); + json.add("placement", JsonParser.parseString(placement)); + assertThrows(IllegalArgumentException.class, () -> StructureDefinition.parse(json)); + } + JsonObject typo = definition(); + typo.addProperty("y_offest", 5); + assertThrows(IllegalArgumentException.class, () -> StructureDefinition.parse(typo)); + JsonObject booleanString = definition(); + booleanString.addProperty("include_entities", "false"); + assertThrows(IllegalArgumentException.class, () -> StructureDefinition.parse(booleanString)); + } + + @Test void rejectsInvalidNamesBiomesAndHeightRanges() { + for (String name : new String[]{"../escape", "MixedCase", "a:b", ""}) { + JsonObject json = definition(); + json.addProperty("name", name); + assertThrows(IllegalArgumentException.class, () -> StructureDefinition.parse(json)); + } + JsonObject emptyBiomes = definition(); + emptyBiomes.add("biomes", JsonParser.parseString("[]")); + assertThrows(IllegalArgumentException.class, () -> StructureDefinition.parse(emptyBiomes)); + JsonObject json = definition(); + json.add("conditions", JsonParser.parseString("{\"min_y\":100,\"max_y\":80}")); + JsonObject invalidHeight = json; + assertThrows(IllegalArgumentException.class, () -> StructureDefinition.parse(invalidHeight)); + } + + @Test void templatePathsStayInsideDirectory() throws Exception { + Path templates = Files.createDirectories(directory.resolve("templates")); + Files.write(templates.resolve("ruins.nbt"), new byte[0]); + assertEquals(templates.resolve("ruins.nbt").toRealPath(), StructureDefinition.parse(definition()).resolveTemplate(templates)); + for (String path : new String[]{"../escape.nbt", "C:/escape.nbt", "/escape.nbt", "..\\escape.nbt"}) { + JsonObject json = definition(); + json.addProperty("template", path); + var parsed = StructureDefinition.parse(json); + assertThrows(IllegalArgumentException.class, () -> parsed.resolveTemplate(templates)); + } + } + + @Test void disabledStructuresKeepTemplatesButNotPlacementAndDuplicateNamesFail() throws Exception { + ConfigStructurePack.load(directory); + Path definitions = directory.resolve("definitions"); + JsonObject disabled = definition(); + disabled.addProperty("enabled", false); + Files.writeString(definitions.resolve("disabled.json"), disabled.toString()); + Files.write(directory.resolve("templates/ruins.nbt"), template(3, 2, 3)); + var disabledResources = ConfigStructurePack.load(directory); + assertEquals(3, disabledResources.size()); + assertTrue(disabledResources.containsKey(Identifier.parse("ww_structures:structure/ruins.nbt"))); + assertFalse(disabledResources.containsKey(Identifier.parse("ww_structures:worldgen/structure_set/ruins.json"))); + Files.writeString(definitions.resolve("disabled.json"), definition().toString()); + var resources = ConfigStructurePack.load(directory); + assertEquals(4, resources.size()); + Files.writeString(definitions.resolve("b.json"), definition().toString()); + var error = assertThrows(java.io.IOException.class, () -> ConfigStructurePack.load(directory)); + assertTrue(error.getMessage().contains("Duplicate name")); + } + + @Test void packContainsTemplateBiomeTagAndRandomSpreadRule() { + var resources = new HashMap(); + byte[] template = {1, 2, 3}; + ConfigStructurePack.addDefinition(resources, StructureDefinition.parse(definition()), template); + assertArrayEquals(template, resources.get(Identifier.parse("ww_structures:structure/ruins.nbt"))); + var placement = JsonParser.parseString(new String(resources.get(Identifier.parse("ww_structures:worldgen/structure_set/ruins.json")), + java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject().getAsJsonObject("placement"); + assertEquals("minecraft:random_spread", placement.get("type").getAsString()); + assertEquals(32, placement.get("spacing").getAsInt()); + } + + @Test void rejectsMalformedOrOversizeTemplate() throws Exception { + ConfigStructurePack.validateTemplate(template(3, 2, 3)); + assertThrows(java.io.IOException.class, () -> ConfigStructurePack.validateTemplate(new byte[]{1, 2, 3})); + assertThrows(java.io.IOException.class, () -> ConfigStructurePack.validateTemplate(template(65, 2, 3))); + assertThrows(java.io.IOException.class, () -> ConfigStructurePack.validateTemplate(template(3, 0, 3))); + } + + private static byte[] template(int x, int y, int z) throws Exception { + CompoundTag root = new CompoundTag(); + ListTag size = new ListTag(); + size.add(IntTag.valueOf(x)); size.add(IntTag.valueOf(y)); size.add(IntTag.valueOf(z)); + root.put("size", size); + ListTag blocks = new ListTag(); + CompoundTag block = new CompoundTag(); + block.putInt("state", 0); + ListTag pos = new ListTag(); + for (int i = 0; i < 3; i++) pos.add(IntTag.valueOf(0)); + block.put("pos", pos); + blocks.add(block); + root.put("blocks", blocks); + ListTag palette = new ListTag(); + CompoundTag stone = new CompoundTag(); + stone.putString("Name", "minecraft:stone"); + palette.add(stone); + root.put("palette", palette); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + NbtIo.writeCompressed(root, out); + return out.toByteArray(); + } +} diff --git a/src/structures/test/org/polaris2023/wildwind/structures/worldgen/GeneratedStructureIndexTest.java b/src/structures/test/org/polaris2023/wildwind/structures/worldgen/GeneratedStructureIndexTest.java new file mode 100644 index 0000000..bb4888d --- /dev/null +++ b/src/structures/test/org/polaris2023/wildwind/structures/worldgen/GeneratedStructureIndexTest.java @@ -0,0 +1,29 @@ +package org.polaris2023.wildwind.structures.worldgen; + +import com.mojang.serialization.JsonOps; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.Identifier; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class GeneratedStructureIndexTest { + @Test void nearestUsesHorizontalDistanceAndSurvivesSerialization() { + var id = Identifier.parse("ww_structures:ruins"); + var index = new GeneratedStructureIndex(); + index.record(id, new BlockPos(5, 200, 0)); + index.record(id, new BlockPos(10, 64, 0)); + index.record(Identifier.parse("ww_structures:other"), BlockPos.ZERO); + assertEquals(new BlockPos(5, 200, 0), index.nearest(id, new BlockPos(0, 64, 0)).orElseThrow()); + var json = GeneratedStructureIndex.CODEC.encodeStart(JsonOps.INSTANCE, index).getOrThrow(); + var restored = GeneratedStructureIndex.CODEC.parse(JsonOps.INSTANCE, json).getOrThrow(); + assertEquals(index.nearest(id, BlockPos.ZERO), restored.nearest(id, BlockPos.ZERO)); + assertTrue(restored.nearest(Identifier.parse("ww_structures:missing"), BlockPos.ZERO).isEmpty()); + restored.record(id, new BlockPos(5, 200, 0)); + assertFalse(restored.isDirty(), "Re-loading the same chunk must not dirty the index"); + } + + @Test void largeCoordinatesDoNotOverflow() { + assertEquals(3_600_000_000_000_000d, GeneratedStructureIndex.horizontalDistanceSquared( + new BlockPos(-30_000_000, 0, 0), new BlockPos(30_000_000, 0, 0))); + } +} diff --git a/src/templates/META-INF/neoforge.mods.toml b/src/templates/META-INF/neoforge.mods.toml index 7e7abe0..14001df 100644 --- a/src/templates/META-INF/neoforge.mods.toml +++ b/src/templates/META-INF/neoforge.mods.toml @@ -40,7 +40,7 @@ authors="baka4n,viola-Siemens,ENC-Euphony,wdog5734" # The description text for the mod (multi line!) (#mandatory) description=''' -Example mod description. +${mod_description} ''' # The [[mixins]] block allows you to declare your mixin config to FML so that it gets loaded.