Phase 4: regenerating ore veins, each tier in one zone

Regenerating Ore Veins 1.4.0 (CurseForge only, MIT). tools/zone_ores.py
writes its config from world/zones.json: surface zones as boxes from the
zone grid, cave tiers by cave biome, 21 tiers including Cobblemon's
evolution stones (their natural ores are off). The world report now
allows ore only in tracked veins inside their tier's zone. Self-tests:
a mined vein regrows after its chunk unloads and after a restart (new
after_restart pass in selftest.py and quick_check.py). make_world places
showcase veins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-30 02:30:34 +01:00
1 parent 82f540aa51
commit 5d275e4e14
19 files changed
+10842 -36

No files matched your search

+2 -2
View File
@@ -51,7 +51,7 @@ JEI declares Minecraft `[1.21, 1.21.1)` in every 1.21.1 build, which a strict re
| Ore Generation Configuration | 1 (not needed) | 1.3.0 (CF `8153928`) | CF only | n/a. Natural ore is removed with data instead (see `world/README.md`) | | Ore Generation Configuration | 1 (not needed) | 1.3.0 (CF `8153928`) | CF only | n/a. Natural ore is removed with data instead (see `world/README.md`) |
| Chunky | 1 (build tool only, not in the pack) | 1.4.23 (MR `LuFhm4eU`, CF project `485681`) | MR + CF | n/a. `tools/make_world.py` adds it to the test server only while pregenerating | | Chunky | 1 (build tool only, not in the pack) | 1.4.23 (MR `LuFhm4eU`, CF project `485681`) | MR + CF | n/a. `tools/make_world.py` adds it to the test server only while pregenerating |
| Cold Sweat | 3 | 2.4.4 (MR `UGoodVYD`, CF `9215368`) | MR + CF | **In the pack** (2026-10-29); same jar on both platforms | | Cold Sweat | 3 | 2.4.4 (MR `UGoodVYD`, CF `9215368`) | MR + CF | **In the pack** (2026-10-29); same jar on both platforms |
| Regenerating Ore Veins | 4 | 1.4.0 (CF `8066055`) | CF only | n/a | | Regenerating Ore Veins | 4 | 1.4.0 (CF `8066055`) | **CF only** | **In the pack** (2026-10-30); n/a (no Cobblemon dependency). Left out of the Modrinth export |
| Cobblebase | 4 | 2.0.1+1.8.0 (MR `pFYNQ6W2`) | MR has the 1.8 build; **CF only has 2.0.0+1.7.0** | ✅ 2.0.1 requires ≥ 1.8.0 and fixes a startup crash on 1.8. Modrinth lists Xaero's Minimap, Cobbreeding and Cloth Config as required, but the jar declares only Cobblemon and Kotlin for Forge; verify when adding | | Cobblebase | 4 | 2.0.1+1.8.0 (MR `pFYNQ6W2`) | MR has the 1.8 build; **CF only has 2.0.0+1.7.0** | ✅ 2.0.1 requires ≥ 1.8.0 and fixes a startup crash on 1.8. Modrinth lists Xaero's Minimap, Cobbreeding and Cloth Config as required, but the jar declares only Cobblemon and Kotlin for Forge; verify when adding |
| Cobblemon Poke Power | 4 (or own generator, Phase 6) | 1.3.1 (MR `vGpaTxmN`) | **MR only** | ✅ Declares `[1.8.0,)`. Needs Matthiesen Core | | Cobblemon Poke Power | 4 (or own generator, Phase 6) | 1.3.1 (MR `vGpaTxmN`) | **MR only** | ✅ Declares `[1.8.0,)`. Needs Matthiesen Core |
| Cobblemon Quests (FTB Quests integration) | 8 | 1.2.0 (MR `8KeptEg2`; CF project `922979`) | MR + CF | ❌ Targets 1.7. Its own changelog says it does not work with FTB Quests ≥ 2101.1.19; the pack has 2101.1.37. Blocked until updated | | Cobblemon Quests (FTB Quests integration) | 8 | 1.2.0 (MR `8KeptEg2`; CF project `922979`) | MR + CF | ❌ Targets 1.7. Its own changelog says it does not work with FTB Quests ≥ 2101.1.19; the pack has 2101.1.37. Blocked until updated |
@@ -76,4 +76,4 @@ No single platform hosts everything the roadmap names:
- **CurseForge only:** FTB Quests, FTB Library, FTB Teams, Ore Generation Configuration, Regenerating Ore Veins (and the 1.8 build of Cobblebase is Modrinth only). - **CurseForge only:** FTB Quests, FTB Library, FTB Teams, Ore Generation Configuration, Regenerating Ore Veins (and the 1.8 build of Cobblebase is Modrinth only).
- **Modrinth only:** CobbleHardcoreMon, Matthiesen Core, Cobblemon Poke Power, Cobblebase 2.0.1. - **Modrinth only:** CobbleHardcoreMon, Matthiesen Core, Cobblemon Poke Power, Cobblebase 2.0.1.
`tools/build.py` leaves each mod out of the export for the platform that doesn't host it and lists what it left out. Today the `.mrpack` lacks the three FTB mods. Once Phases 1–4 land, the CurseForge zip will lack permadeath, and the Modrinth pack will lack quests and ore veins. This needs a decision (see `docs/design.md`, open decisions). `tools/build.py` leaves each mod out of the export for the platform that doesn't host it and lists what it left out. Today the `.mrpack` lacks the three FTB mods and Regenerating Ore Veins. Once Phases 1–4 land, the CurseForge zip will lack permadeath, and the Modrinth pack will lack quests and ore veins. This needs a decision (see `docs/design.md`, open decisions).
+1
View File
@@ -40,6 +40,7 @@ Read each license before adding a mod, and add a row here in the same change.
| Cobblemon TP-Pastures | see Modrinth page | All Rights Reserved | [Modrinth](https://modrinth.com/mod/cobblemon_tp_pastures) | Phase 2 | | Cobblemon TP-Pastures | see Modrinth page | All Rights Reserved | [Modrinth](https://modrinth.com/mod/cobblemon_tp_pastures) | Phase 2 |
| Lootr | noobanidus | MIT | [Modrinth](https://modrinth.com/mod/lootr) | Phase 2 | | Lootr | noobanidus | MIT | [Modrinth](https://modrinth.com/mod/lootr) | Phase 2 |
| Cold Sweat | Momo Softworks (Mikul) | GPL-3.0-or-later | [Modrinth](https://modrinth.com/mod/cold-sweat) / [CurseForge](https://www.curseforge.com/minecraft/mc-mods/cold-sweat) | Phase 3: heat and cold as zone hazards (both) | | Cold Sweat | Momo Softworks (Mikul) | GPL-3.0-or-later | [Modrinth](https://modrinth.com/mod/cold-sweat) / [CurseForge](https://www.curseforge.com/minecraft/mc-mods/cold-sweat) | Phase 3: heat and cold as zone hazards (both) |
| Regenerating Ore Veins | Stargazer | MIT (jar metadata) | [CurseForge](https://www.curseforge.com/minecraft/mc-mods/regenerating-ore-veins) | Phase 4: the only ore source besides chest loot (both). Not on Modrinth |
| WorldGen Editor | waterflane | CC BY-NC-SA 4.0 | [Modrinth](https://modrinth.com/mod/worldgen-editor) / [CurseForge](https://www.curseforge.com/minecraft/mc-mods/worldgen-editor), [source](https://github.com/waterflane/WorldGen-Editor) | Non-commercial, share-alike. See the derived files below | | WorldGen Editor | waterflane | CC BY-NC-SA 4.0 | [Modrinth](https://modrinth.com/mod/worldgen-editor) / [CurseForge](https://www.curseforge.com/minecraft/mc-mods/worldgen-editor), [source](https://github.com/waterflane/WorldGen-Editor) | Non-commercial, share-alike. See the derived files below |
Licenses of mods planned for later phases are in `docs/addon-status.md` and move here when the mod is added. **Cobblemon TP-Pastures**, **CobbleStrike** and the **FTB** mods are All Rights Reserved: reference only, never redistribute or modify. Licenses of mods planned for later phases are in `docs/addon-status.md` and move here when the mod is added. **Cobblemon TP-Pastures**, **CobbleStrike** and the **FTB** mods are All Rights Reserved: reference only, never redistribute or modify.
@@ -23,7 +23,8 @@ import net.neoforged.neoforge.event.tick.ServerTickEvent
* One in-game test: [body] sets it up through its [Scenario] and must end in a pass or a failure within [maxTicks]. * One in-game test: [body] sets it up through its [Scenario] and must end in a pass or a failure within [maxTicks].
* [islandOnly] tests need the island world and are skipped elsewhere (tools/selftest.py uses a flat world). * [islandOnly] tests need the island world and are skipped elsewhere (tools/selftest.py uses a flat world).
* Tests of one [batch] run side by side; batches run one after another (a test that changes the world around it, * Tests of one [batch] run side by side; batches run one after another (a test that changes the world around it,
* like the biome or the time of day, gets a batch of its own). * like the biome or the time of day, gets a batch of its own). [afterRestart] tests are the second half of a case
* that spans a server restart: they only run with `/cobblesurvival selftest run after_restart`.
*/ */
class SelfTestCase( class SelfTestCase(
val name: String, val name: String,
@@ -31,24 +32,26 @@ class SelfTestCase(
val body: (Scenario) -> Unit, val body: (Scenario) -> Unit,
val islandOnly: Boolean = false, val islandOnly: Boolean = false,
val batch: String = CobbleSurvival.MOD_ID, val batch: String = CobbleSurvival.MOD_ID,
val afterRestart: Boolean = false,
) )
/** /**
* In-game tests on a real server with all of the pack's mods and configs: `/cobblesurvival selftest run` starts them * In-game tests on a real server with all of the pack's mods and configs: `/cobblesurvival selftest run` starts them
* on a platform high above spawn, `/cobblesurvival selftest status` reports. They use the vanilla GameTest * on a platform high above spawn, `/cobblesurvival selftest status` reports. They use the vanilla GameTest
* framework, which NeoForge only switches on in a development environment, so this object creates and ticks the * framework, which NeoForge only switches on in a development environment, so this object creates and ticks the
* tests itself. Only there when the server runs with `-Dcobblesurvival.selftest=true` (tools/quick_check.py); * tests itself. `/cobblesurvival selftest run after_restart` runs the tests that check what survived a restart. Only there when the server runs with `-Dcobblesurvival.selftest=true` (tools/quick_check.py);
* players never see the command. * players never see the command.
*/ */
object SelfTest { object SelfTest {
val enabled: Boolean = java.lang.Boolean.getBoolean("cobblesurvival.selftest") val enabled: Boolean = java.lang.Boolean.getBoolean("cobblesurvival.selftest")
private const val TEMPLATE = "cobblesurvival:selftest/empty" private const val TEMPLATE = "cobblesurvival:selftest/empty"
private const val PLATFORM_Y = 200 const val PLATFORM_Y = 200
private const val TESTS_PER_ROW = 4 private const val TESTS_PER_ROW = 4
private const val FORCED_CHUNKS = 9 private const val FORCED_CHUNKS = 9
private val cases: List<SelfTestCase> get() = private val cases: List<SelfTestCase> get() =
PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all() + GuardianTests.all() PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all() + GuardianTests.all() +
VeinTests.all()
private var running: List<Scenario> = emptyList() private var running: List<Scenario> = emptyList()
private var forced: List<ChunkPos> = emptyList() private var forced: List<ChunkPos> = emptyList()
@@ -61,11 +64,15 @@ object SelfTest {
fun command(): LiteralArgumentBuilder<CommandSourceStack> = fun command(): LiteralArgumentBuilder<CommandSourceStack> =
Commands.literal("selftest") Commands.literal("selftest")
.requires { it.hasPermission(4) } .requires { it.hasPermission(4) }
.then(Commands.literal("run").executes { context -> .then(Commands.literal("run")
val reply = start(context.source.server) .executes { context ->
context.source.sendSuccess({ Component.literal(reply) }, false) context.source.sendSuccess({ Component.literal(start(context.source.server, afterRestart = false)) }, false)
1 1
}) }
.then(Commands.literal("after_restart").executes { context ->
context.source.sendSuccess({ Component.literal(start(context.source.server, afterRestart = true)) }, false)
1
}))
.then(Commands.literal("status").executes { context -> .then(Commands.literal("status").executes { context ->
val done = running.count { it.result != null } val done = running.count { it.result != null }
val reply = if (running.isNotEmpty()) "selftest running: $done/${running.size} done" else summary val reply = if (running.isNotEmpty()) "selftest running: $done/${running.size} done" else summary
@@ -73,7 +80,7 @@ object SelfTest {
1 1
}) })
private fun start(server: MinecraftServer): String { private fun start(server: MinecraftServer, afterRestart: Boolean): String {
if (running.isNotEmpty()) return "selftest already running" if (running.isNotEmpty()) return "selftest already running"
val level = server.overworld() val level = server.overworld()
val spawn = level.sharedSpawnPos val spawn = level.sharedSpawnPos
@@ -84,7 +91,7 @@ object SelfTest {
// Only the island world type puts basalt deltas into the overworld. // Only the island world type puts basalt deltas into the overworld.
val island = level.chunkSource.generator.biomeSource.possibleBiomes().any { it.`is`(Biomes.BASALT_DELTAS) } val island = level.chunkSource.generator.biomeSource.possibleBiomes().any { it.`is`(Biomes.BASALT_DELTAS) }
val (runnable, skipped) = cases.partition { island || !it.islandOnly } val (runnable, skipped) = cases.filter { it.afterRestart == afterRestart }.partition { island || !it.islandOnly }
val scenarios = runnable.mapIndexed { index, case -> Scenario(case, index) } val scenarios = runnable.mapIndexed { index, case -> Scenario(case, index) }
val infos = scenarios.map { scenario -> val infos = scenarios.map { scenario ->
val function = TestFunction(scenario.case.batch, "${CobbleSurvival.MOD_ID}.${scenario.case.name}", TEMPLATE, val function = TestFunction(scenario.case.batch, "${CobbleSurvival.MOD_ID}.${scenario.case.name}", TEMPLATE,
@@ -0,0 +1,122 @@
package dev.cobblesurvival.selftest
import net.minecraft.core.BlockPos
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.resources.ResourceLocation
import net.minecraft.server.level.ServerLevel
import net.minecraft.server.level.ServerPlayer
import net.minecraft.world.level.ChunkPos
import net.minecraft.world.level.GameType
import net.minecraft.world.level.block.Blocks
import net.minecraft.world.phys.Vec3
/**
* Roadmap Phase 4: ore veins regrow after their chunk was unloaded and after a server restart (Regenerating Ore
* Veins). The tests place the pack's "selftest" vein (one coal block that takes an hour to regrow and never generates
* by itself) in the air away from the test platform, mine it, and wind its clock back in the mod's saved data
* instead of waiting the hour. The restart case has two halves: the first runs with the other tests, then
* tools/quick_check.py restarts the server and runs the second with `/cobblesurvival selftest run after_restart`.
*/
object VeinTests {
private const val VEIN = "selftest"
/** Blocks east (unload) and west (restart) of spawn: far beyond view distance from the platform. */
private const val UNLOAD_OFFSET = 320
private const val RESTART_OFFSET = -320
private val REGENERATOR = ResourceLocation.fromNamespaceAndPath("regenerating_ore_veins", "regenerator_block")
fun all() = listOf(
SelfTestCase("vein_regrows_after_unload", 1200, ::afterUnload),
SelfTestCase("vein_mined_before_restart", 400, ::beforeRestart),
SelfTestCase("vein_regrows_after_restart", 400, ::afterRestart, afterRestart = true),
)
private fun afterUnload(s: Scenario) {
val level = s.level
val pos = spot(level, UNLOAD_OFFSET)
val chunk = ChunkPos(pos)
val player = s.player()
s.afterTest { level.setChunkForced(chunk.x, chunk.z, false) }
s.helper.startSequence()
.thenExecute {
level.setChunkForced(chunk.x, chunk.z, true)
placeAndMine(s, player, pos)
}
.thenWaitUntil { s.check(isRegenerator(level, pos)) { "the mined vein block is ${level.getBlockState(pos)}, not a regenerator" } }
.thenExecute { level.setChunkForced(chunk.x, chunk.z, false) }
.thenWaitUntil { s.check(!level.hasChunk(chunk.x, chunk.z)) { "the vein's chunk is still loaded" } }
.thenExecute {
rewind(level, pos)
level.setChunkForced(chunk.x, chunk.z, true)
}
.thenWaitUntil {
s.check(level.getBlockState(pos).`is`(Blocks.COAL_ORE)) { "the vein didn't regrow once its chunk was back: ${level.getBlockState(pos)}" }
}
.thenExecute { remove(player, pos) }
.thenWaitUntil { s.check(level.getBlockState(pos).isAir) { "the vein block wasn't removed: ${level.getBlockState(pos)}" } }
.thenSucceed()
}
private fun beforeRestart(s: Scenario) {
val level = s.level
val pos = spot(level, RESTART_OFFSET)
val chunk = ChunkPos(pos)
val player = s.player()
s.afterTest { level.setChunkForced(chunk.x, chunk.z, false) }
s.helper.startSequence()
.thenExecute {
level.setChunkForced(chunk.x, chunk.z, true)
placeAndMine(s, player, pos)
}
.thenWaitUntil { s.check(isRegenerator(level, pos)) { "the mined vein block is ${level.getBlockState(pos)}, not a regenerator" } }
.thenSucceed()
}
private fun afterRestart(s: Scenario) {
val level = s.level
val pos = spot(level, RESTART_OFFSET)
val chunk = ChunkPos(pos)
val player = s.player()
s.afterTest { level.setChunkForced(chunk.x, chunk.z, false) }
s.helper.startSequence()
.thenExecute { level.setChunkForced(chunk.x, chunk.z, true) }
.thenWaitUntil {
s.check(isRegenerator(level, pos)) { "no regenerator where the vein was mined before the restart: ${level.getBlockState(pos)}" }
}
.thenExecute { rewind(level, pos) }
.thenWaitUntil {
s.check(level.getBlockState(pos).`is`(Blocks.COAL_ORE)) { "the vein didn't regrow after the restart: ${level.getBlockState(pos)}" }
}
.thenExecute { remove(player, pos) }
.thenWaitUntil { s.check(level.getBlockState(pos).isAir) { "the vein block wasn't removed: ${level.getBlockState(pos)}" } }
.thenSucceed()
}
private fun spot(level: ServerLevel, offset: Int): BlockPos =
level.sharedSpawnPos.let { BlockPos(it.x + offset, SelfTest.PLATFORM_Y, it.z) }
/** Places the vein with the mod's own command and mines its block as a survival player. */
private fun placeAndMine(s: Scenario, player: ServerPlayer, pos: BlockPos) {
val level = s.level
val server = level.server
val source = server.createCommandSourceStack().withLevel(level).withPosition(Vec3.atCenterOf(pos)).withSuppressedOutput()
server.commands.performPrefixedCommand(source, "regenerating_ore_veins place $VEIN")
s.check(level.getBlockState(pos).`is`(Blocks.COAL_ORE)) { "the vein was not placed: ${level.getBlockState(pos)}" }
s.check(player.gameMode.destroyBlock(pos)) { "the vein block could not be mined" }
}
/** Breaking a regenerator in creative mode removes the vein for good, so the test world keeps no stray ore. */
private fun remove(player: ServerPlayer, pos: BlockPos) {
player.setGameMode(GameType.CREATIVE)
player.gameMode.destroyBlock(pos)
}
private fun isRegenerator(level: ServerLevel, pos: BlockPos) =
BuiltInRegistries.BLOCK.getKey(level.getBlockState(pos).block) == REGENERATOR
/** Moves the vein block's "last mined" time far into the past; the mod keeps it in its saved data. */
private fun rewind(level: ServerLevel, pos: BlockPos) {
val data = Class.forName("net.stargazer.regenerating_ore_veins.VeinSavedData")
val saved = data.getMethod("get", ServerLevel::class.java).invoke(null, level)
data.getMethod("markMined", BlockPos::class.java, Long::class.javaPrimitiveType).invoke(saved, pos, 1L)
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,12 @@
{
"allow_breaking": false,
"break_hardness": 5.0,
"default_regeneration_seconds": 3600,
"default_jitter_interval": {
"range_min": 0,
"range_max": 0
},
"regeneration_smoke_particles": true,
"destroyed_by_explosives": false,
"default_fill_factor": 0.7
}
@@ -0,0 +1,13 @@
{
"allow_crafting": true,
"allow_use": true,
"crafting_recipe": [
"minecraft:compass",
"minecraft:copper_ingot"
],
"craft_any_order": true,
"chance_to_break": 0.75,
"use_durability": false,
"durability": 2,
"stack_size": 64
}
File diff suppressed because it is too large. Load diff
+21
View File
@@ -28,6 +28,22 @@ hash = "ca348d022ad97e74ec51a896060e8fcfc079cf0d34ef116a967e72f60bdb3381"
file = "config/matthiesen_core/metrics.properties" file = "config/matthiesen_core/metrics.properties"
hash = "e3833b9a5d62612cc9f8886b8bbc4a6fbb0f5c531aede06b1a580ba522f576b4" hash = "e3833b9a5d62612cc9f8886b8bbc4a6fbb0f5c531aede06b1a580ba522f576b4"
[[files]]
file = "config/regenerating_ore_veins/areas.json"
hash = "79a3e379b5d9a142930acc1b599f3bf773c2dd6ee578239181f9720676f876ad"
[[files]]
file = "config/regenerating_ore_veins/global.json"
hash = "412be923552eedcde822199f8fca7af273775b3ed36d58539f031d31ad933133"
[[files]]
file = "config/regenerating_ore_veins/vein_locator.json"
hash = "a6e9d717e2f73e463b25b1eacfbff43304fd8993937d8c002f9629f24ebcc221"
[[files]]
file = "config/regenerating_ore_veins/veins.json"
hash = "a671bcfc92c200a93f16bc92d46426b8d5f2bfd21e57af463a821c4fa3755d2c"
[[files]] [[files]]
file = "config/worldgen_editor/presets/cobblesurvival_island.json" file = "config/worldgen_editor/presets/cobblesurvival_island.json"
hash = "73cfb1e126bf49dc6c9fa52fb137e8fe9bfb945535701fd542639202ed24704f" hash = "73cfb1e126bf49dc6c9fa52fb137e8fe9bfb945535701fd542639202ed24704f"
@@ -203,6 +219,11 @@ file = "mods/mouse-tweaks.pw.toml"
hash = "39cee4df36d04a7530cde51b55154f76fb8954341728be08c3b2ee24b07ed742" hash = "39cee4df36d04a7530cde51b55154f76fb8954341728be08c3b2ee24b07ed742"
metafile = true metafile = true
[[files]]
file = "mods/regenerating-ore-veins.pw.toml"
hash = "e3be05bd89ad1091014025fe45abd2dcb4318930486597ee3daf02d9eb30648d"
metafile = true
[[files]] [[files]]
file = "mods/rhino.pw.toml" file = "mods/rhino.pw.toml"
hash = "8be16680efb71ff64ebd5881943d58f7ef6ce36a95c7a4a856d7dc62b82231ef" hash = "8be16680efb71ff64ebd5881943d58f7ef6ce36a95c7a4a856d7dc62b82231ef"
+13
View File
@@ -0,0 +1,13 @@
name = "Regenerating Ore Veins"
filename = "regenerating_ore_veins-neoforge-1.21.1-1.4.0.jar"
side = "both"
[download]
hash-format = "sha1"
hash = "cb1edab284593a48d0b0ee22614c0f786d427158"
mode = "metadata:curseforge"
[update]
[update.curseforge]
file-id = 8066055
project-id = 1535476
+1 -1
View File
@@ -6,7 +6,7 @@ pack-format = "packwiz:1.1.0"
[index] [index]
file = "index.toml" file = "index.toml"
hash-format = "sha256" hash-format = "sha256"
hash = "bdca52cbc4ab217c9ced202856d5be6a5af4fb4f21c07ac5ae2516d4b44ed037" hash = "37b01e238f83c754e3eb7093432815272645949ebcbc623ae5cde213a30e2155"
[versions] [versions]
minecraft = "1.21.1" minecraft = "1.21.1"
+30 -3
View File
@@ -7,9 +7,11 @@
type and gamerules, and pregenerate a circle of `pregen_radius` around type and gamerules, and pregenerate a circle of `pregen_radius` around
0,0 (the island plus a strip of sea; the sea corners inside the square 0,0 (the island plus a strip of sea; the sea corners inside the square
border generate when someone gets there). border generate when someone gets there).
3. In the same session: save, measure where the land ends 3. In the same session: place the showcase ore veins (world/zones.json →
(tools/world_report.py) and set a square world border `border_margin` "ores", tiers with "showcase": one vein sticking out of the ground near
blocks outside the outermost coast. its zone's sample point, so players see what a regenerating vein looks
like), save, measure where the land ends (tools/world_report.py) and set
a square world border `border_margin` blocks outside the outermost coast.
4. Copy the world to build/world-template without player data, then run 4. Copy the world to build/world-template without player data, then run
the world checks and render the map (build/island-map.png). the world checks and render the map (build/island-map.png).
@@ -29,10 +31,34 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
from mcserver import BUILD, ROOT, SERVER, Server, chunky, eula_accepted, prepare, scan_log # noqa: E402 from mcserver import BUILD, ROOT, SERVER, Server, chunky, eula_accepted, prepare, scan_log # noqa: E402
import world_report # noqa: E402 import world_report # noqa: E402
import zone_grid # noqa: E402
from numpy import hypot as np_hypot # noqa: E402 from numpy import hypot as np_hypot # noqa: E402
TEMPLATE = BUILD / "world-template" TEMPLATE = BUILD / "world-template"
SKIP = shutil.ignore_patterns("playerdata", "advancements", "stats", "session.lock") SKIP = shutil.ignore_patterns("playerdata", "advancements", "stats", "session.lock")
# A showcase sits this far from its zone's sample point (where guardians and the self-tests stand), first offset
# that is still inside the zone wins.
SHOWCASE_OFFSETS = [(16, 0), (0, 16), (-16, 0), (0, -16), (24, 24), (-24, -24)]
def place_showcases(server):
"""One hand-placed vein per showcase tier, half sunk into the ground near the zone's sample point."""
plan = json.loads((ROOT / "world/zones.json").read_text())
grid = zone_grid.load()
samples = json.loads(zone_grid.ISLAND.read_text())["samples"]
for tier, spec in plan["ores"].items():
if not spec.get("showcase"):
continue
sx, _, sz = samples[spec["zone"]]
spot = next(((sx + dx, sz + dz) for dx, dz in SHOWCASE_OFFSETS if grid.zone_at(sx + dx, sz + dz) == spec["zone"]), None)
if spot is None:
raise RuntimeError(f"no showcase spot inside {spec['zone']} near its sample point {sx},{sz}")
x, z = spot
server.command(f"forceload add {x} {z}")
reply = server.command(f"execute in minecraft:overworld positioned {x} 0 {z} positioned over world_surface "
f"positioned ~ ~-1 ~ run regenerating_ore_veins place {tier}").strip()
server.command(f"forceload remove {x} {z}")
print(f" showcase {tier} at {x},{z}: {reply[:100]}")
def border_for(world, recipe): def border_for(world, recipe):
@@ -75,6 +101,7 @@ def main():
print(f" {server.command(f'gamerule {rule} {str(value).lower()}').strip()}") print(f" {server.command(f'gamerule {rule} {str(value).lower()}').strip()}")
seconds = server.pregenerate(0, 0, recipe["pregen_radius"], shape="circle") seconds = server.pregenerate(0, 0, recipe["pregen_radius"], shape="circle")
print(f"pregeneration finished in {seconds:.0f}s") print(f"pregeneration finished in {seconds:.0f}s")
place_showcases(server)
server.command("save-all flush") server.command("save-all flush")
cx, cz, size = border_for(world, recipe) cx, cz, size = border_for(world, recipe)
print(f" {server.command(f'worldborder center {cx} {cz}').strip()}") print(f" {server.command(f'worldborder center {cx} {cz}').strip()}")
+6 -3
View File
@@ -227,9 +227,12 @@ class Server:
failures.append(f"{label}: {check['command']!r} did not match {check['expect']!r}") failures.append(f"{label}: {check['command']!r} did not match {check['expect']!r}")
return failures return failures
def run_selftest(self, timeout=300): def run_selftest(self, timeout=300, after_restart=False):
"""Run the companion mod's in-game tests (needs SELFTEST_JVM_ARGS); returns the failures.""" """Run the companion mod's in-game tests (needs SELFTEST_JVM_ARGS); returns the failures.
reply = self.command("cobblesurvival selftest run").strip()
after_restart runs the second halves of the cases that span a restart (stop, start again, then call this).
"""
reply = self.command("cobblesurvival selftest run" + (" after_restart" if after_restart else "")).strip()
if "started" not in reply: if "started" not in reply:
return [f"selftest did not start: {reply!r}"] return [f"selftest did not start: {reply!r}"]
print(f" {reply}") print(f" {reply}")
+23 -3
View File
@@ -12,9 +12,13 @@
square of structure_radius around each, plus spawn_radius around spawn. square of structure_radius around each, plus spawn_radius around spawn.
5. Force-load the chunks around the sampled village for a few seconds, as a 5. Force-load the chunks around the sampled village for a few seconds, as a
nearby player would, so Lootr converts its loot chests. nearby player would, so Lootr converts its loot chests.
6. Stop, scan the server log, and run tools/world_report.py --sample on the 6. Stop, scan the server log, boot the same world again and run the second
sampled world: no ore, no vanilla mobs or mob sources, only listed biomes, halves of the self-tests that span a restart (`selftest run after_restart`,
gamerules as in the recipe, and no vanilla loot chest left near the village. e.g. a mined ore vein regrows), stop and scan that log too.
7. Run tools/world_report.py --sample on the sampled world: ore only in
regenerating veins inside their tier's zone, no vanilla mobs or mob sources,
only listed biomes, gamerules as in the recipe, and no vanilla loot chest
left near the village.
The border, strongholds-inside-border and the map need the whole island: The border, strongholds-inside-border and the map need the whole island:
tools/make_world.py checks those when the template is rebuilt. tools/make_world.py checks those when the template is rebuilt.
@@ -93,6 +97,7 @@ def main():
failures.append(f"{type(e).__name__}: {e}") failures.append(f"{type(e).__name__}: {e}")
failures += [f"log: {line}" for line in scan_log(server.log)] failures += [f"log: {line}" for line in scan_log(server.log)]
failures += restart_selftest(args_file, props)
report = subprocess.run([sys.executable, "-I", str(ROOT / "tools/world_report.py"), str(SERVER / LEVEL), report = subprocess.run([sys.executable, "-I", str(ROOT / "tools/world_report.py"), str(SERVER / LEVEL),
"--sample", "--json", str(ROOT / "build/quick-check-report.json")]) "--sample", "--json", str(ROOT / "build/quick-check-report.json")])
if report.returncode: if report.returncode:
@@ -114,5 +119,20 @@ def main():
print("QUICK CHECK PASSED") print("QUICK CHECK PASSED")
def restart_selftest(args_file, props):
"""Boot the quick-check world again and run the self-tests' after-restart halves; returns the failures."""
failures = []
with Server(args_file, props, heap="8G", stdout_name="quick-check-restart-stdout.log",
jvm_args=SELFTEST_JVM_ARGS) as server:
try:
print(f"server restarted in {server.wait_ready():.0f}s")
failures += server.run_selftest(after_restart=True)
server.command("save-all flush")
server.stop()
except Exception as e: # report; the context manager still kills the process
failures.append(f"{type(e).__name__}: {e}")
return failures + [f"log after restart: {line}" for line in scan_log(server.log)]
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+11 -1
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Run the companion mod's in-game tests on their own: one server boot on a fresh flat """Run the companion mod's in-game tests on their own: one server boot on a fresh flat
world, `/cobblesurvival selftest run`, wait for the results, stop, scan the log. world, `/cobblesurvival selftest run`, wait for the results, stop, scan the log; then boot
the world again for the second halves of the tests that span a restart
(`/cobblesurvival selftest run after_restart`).
The tests (mod/src/main/kotlin/dev/cobblesurvival/selftest/) play real situations on the The tests (mod/src/main/kotlin/dev/cobblesurvival/selftest/) play real situations on the
live server with every mod and config of the pack, e.g. the permadeath matrix: Pokémon live server with every mod and config of the pack, e.g. the permadeath matrix: Pokémon
@@ -47,6 +49,14 @@ def main():
except Exception as e: # report; the context manager still kills the process except Exception as e: # report; the context manager still kills the process
failures.append(f"{type(e).__name__}: {e}") failures.append(f"{type(e).__name__}: {e}")
failures += [f"log: {line}" for line in scan_log(server.log)] failures += [f"log: {line}" for line in scan_log(server.log)]
with Server(args_file, props, stdout_name="selftest-restart-stdout.log", jvm_args=SELFTEST_JVM_ARGS) as server:
try:
print(f"server restarted in {server.wait_ready():.0f}s")
failures += server.run_selftest(after_restart=True)
server.stop()
except Exception as e: # report; the context manager still kills the process
failures.append(f"{type(e).__name__}: {e}")
failures += [f"log after restart: {line}" for line in scan_log(server.log)]
print(f"\nselftest took {time.time() - started:.0f}s") print(f"\nselftest took {time.time() - started:.0f}s")
if failures: if failures:
print("SELFTEST FAILED") print("SELFTEST FAILED")
+96 -12
View File
@@ -1,8 +1,14 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Check and picture a generated world (default: build/world-template). """Check and picture a generated world (default: build/world-template).
* Ore scan: every block in every stored chunk is checked; any ore block * Ore scan: every block in every stored chunk is checked. Ore ("*_ore",
("*_ore", ancient debris, raw ore blocks from large veins) fails the check. ancient debris, raw ore blocks from large veins) is only allowed as part of
a regenerating vein (Regenerating Ore Veins' saved data), and each vein's
blocks must lie in the zone of its ore tier (world/zones.json → "ores"),
or at most two radii from a block of the same vein that does: the mod
checks the biome only at a vein's centre, so its edge can reach over the
border of a cave zone. Untracked ore fails the check, and so (except with
--sample) does an ore tier without any vein.
* Biome set: every biome stored in any chunk section (surface and caves) * Biome set: every biome stored in any chunk section (surface and caves)
must be listed in world/biomes.json ("allowed" or "water"). must be listed in world/biomes.json ("allowed" or "water").
* End access: at least one stronghold must start inside the world border. * End access: at least one stronghold must start inside the world border.
@@ -38,9 +44,13 @@ import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
import anvil # noqa: E402 import anvil # noqa: E402
import zone_grid # noqa: E402
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
ORE = re.compile(r"(_ore|:ancient_debris|:raw_(iron|copper|gold)_block)$") ORE = re.compile(r"(_ore|:ancient_debris|:raw_(iron|copper|gold)_block)$")
# Blocks the regenerating veins are made of (amethyst isn't "ore" by name but is a tier).
VEIN_BLOCKS = {b for v in json.loads((ROOT / "pack/config/regenerating_ore_veins/veins.json").read_text())
for b in v["blocks"]}
MOB_BLOCK = re.compile(r":(spawner|trial_spawner|infested_\w+)$") MOB_BLOCK = re.compile(r":(spawner|trial_spawner|infested_\w+)$")
LOOT_CONTAINERS = {"minecraft:chest", "minecraft:trapped_chest", "minecraft:barrel", "minecraft:shulker_box"} LOOT_CONTAINERS = {"minecraft:chest", "minecraft:trapped_chest", "minecraft:barrel", "minecraft:shulker_box"}
SEA_LEVEL = 63 SEA_LEVEL = 63
@@ -53,7 +63,7 @@ def scan_region(path):
floor = np.full((512, 512), -10_000, dtype=np.int16) floor = np.full((512, 512), -10_000, dtype=np.int16)
top = np.full((512, 512), -10_000, dtype=np.int16) top = np.full((512, 512), -10_000, dtype=np.int16)
biome = np.full((128, 128), -1, dtype=np.int16) biome = np.full((128, 128), -1, dtype=np.int16)
names, ores, chunks, present, strongholds = {}, {}, 0, {}, [] names, ores, chunks, present, strongholds, ore_at = {}, {}, 0, {}, [], []
loot = {"lootr": 0, "unconverted": 0, "unconverted_at": []} loot = {"lootr": 0, "unconverted": 0, "unconverted_at": []}
for cx, cz, chunk in anvil.region_chunks(path): for cx, cz, chunk in anvil.region_chunks(path):
for be in chunk.get("block_entities", []): for be in chunk.get("block_entities", []):
@@ -80,13 +90,20 @@ def scan_region(path):
present[name] = present.get(name, 0) + 1 present[name] = present.get(name, 0) + 1
palette, indices = anvil.section_blocks(section) palette, indices = anvil.section_blocks(section)
entries = section.get("block_states", {}).get("palette", []) entries = section.get("block_states", {}).get("palette", [])
hits = {i: name for i, name in enumerate(palette) if ORE.search(name) or MOB_BLOCK.search(name)} hits = {i: name for i, name in enumerate(palette)
if ORE.search(name) or MOB_BLOCK.search(name) or name in VEIN_BLOCKS}
hits.update({i: "minecraft:sculk_shrieker[can_summon=true]" for i, e in enumerate(entries) hits.update({i: "minecraft:sculk_shrieker[can_summon=true]" for i, e in enumerate(entries)
if e["Name"] == "minecraft:sculk_shrieker" and e.get("Properties", {}).get("can_summon") == "true"}) if e["Name"] == "minecraft:sculk_shrieker" and e.get("Properties", {}).get("can_summon") == "true"})
for i, name in hits.items(): for i, name in hits.items():
count = int(np.count_nonzero(indices == i)) count = int(np.count_nonzero(indices == i))
if count: if count:
ores[name] = ores.get(name, 0) + count ores[name] = ores.get(name, 0) + count
if count and (ORE.search(name) or name in VEIN_BLOCKS):
biome_palette, biome_indices = anvil.section_biomes(section)
for idx in np.flatnonzero(indices == i).tolist():
y, z, x = idx // 256, (idx // 16) % 16, idx % 16
biome_name = biome_palette[int(biome_indices[(y // 4) * 16 + (z // 4) * 4 + x // 4])]
ore_at.append((cx * 16 + x, section["Y"] * 16 + y, cz * 16 + z, name, biome_name))
decoded = {} decoded = {}
for qz in range(4): for qz in range(4):
for qx in range(4): for qx in range(4):
@@ -99,7 +116,7 @@ def scan_region(path):
palette, indices = decoded[y // 16] palette, indices = decoded[y // 16]
name = palette[int(indices[((y % 16) // 4) * 16 + qz * 4 + qx])] name = palette[int(indices[((y % 16) // 4) * 16 + qz * 4 + qx])]
biome[oz // 4 + qz, ox // 4 + qx] = names.setdefault(name, len(names)) biome[oz // 4 + qz, ox // 4 + qx] = names.setdefault(name, len(names))
return rx, rz, floor, top, biome, list(names), ores, chunks, present, strongholds, loot return rx, rz, floor, top, biome, list(names), ores, chunks, present, strongholds, loot, ore_at
# Entities that aren't mobs: decorations, vehicles, and projectiles (a trial chamber's dispenser trap fires # Entities that aren't mobs: decorations, vehicles, and projectiles (a trial chamber's dispenser trap fires
@@ -130,10 +147,11 @@ def analyse(world):
floor = np.full((h, w), -10_000, dtype=np.int16) floor = np.full((h, w), -10_000, dtype=np.int16)
top = np.full((h, w), -10_000, dtype=np.int16) top = np.full((h, w), -10_000, dtype=np.int16)
biome = np.full((h // 4, w // 4), -1, dtype=np.int16) biome = np.full((h // 4, w // 4), -1, dtype=np.int16)
names, ores, chunks, present, strongholds = [], {}, 0, {}, [] names, ores, chunks, present, strongholds, ore_at = [], {}, 0, {}, [], []
loot = {"lootr": 0, "unconverted": 0, "unconverted_at": []} loot = {"lootr": 0, "unconverted": 0, "unconverted_at": []}
with ProcessPoolExecutor(max_workers=os.cpu_count()) as pool: with ProcessPoolExecutor(max_workers=os.cpu_count()) as pool:
for rx, rz, f, t, b, local, o, n, pr, sh, lt in pool.map(scan_region, regions): for rx, rz, f, t, b, local, o, n, pr, sh, lt, oa in pool.map(scan_region, regions):
ore_at += oa
for k, v in lt.items(): for k, v in lt.items():
loot[k] += v loot[k] += v
strongholds += sh strongholds += sh
@@ -153,10 +171,71 @@ def analyse(world):
for k, v in counts.items(): for k, v in counts.items():
entities[k] = entities.get(k, 0) + v entities[k] = entities.get(k, 0) + v
return {"origin": (x0 * 512, z0 * 512), "floor": floor, "top": top, "biome": biome, return {"origin": (x0 * 512, z0 * 512), "floor": floor, "top": top, "biome": biome,
"biome_names": names, "ores": ores, "chunks": chunks, "sections_per_biome": present, "biome_names": names, "ores": ores, "ore_at": ore_at, "chunks": chunks, "sections_per_biome": present,
"strongholds": strongholds, "entities": entities, "loot": loot} "strongholds": strongholds, "entities": entities, "loot": loot}
def read_veins(world):
"""{(x, y, z): vein id} from Regenerating Ore Veins' saved data (empty if the mod never ran)."""
path = Path(world, "data/regenerating_ore_veins_veins.dat")
if not path.exists():
return {}
import gzip
data = anvil.parse_nbt(gzip.decompress(path.read_bytes()))
veins = {}
for entry in data.get("data", data).get("Entries", []):
pos = entry["Pos"]
if isinstance(pos, dict):
key = (pos["X"], pos["Y"], pos["Z"])
elif isinstance(pos, int): # BlockPos.asLong
x, z, y = pos >> 38, (pos << 26) >> 38, ((pos & 0xFFF) ^ 0x800) - 0x800
key = (x, y, ((z + (1 << 25)) % (1 << 26)) - (1 << 25))
else:
key = tuple(int(v) for v in pos)
veins[key] = entry.get("VeinId", "")
return veins
def check_veins(world, ore_at, sample):
"""Every ore block belongs to a tracked vein in its tier's zone; returns (blocks per tier and zone, problems)."""
plan = json.loads((ROOT / "world/zones.json").read_text())
grid = zone_grid.load()
tier_zone = {}
for tier, spec in plan["ores"].items():
tier_zone[tier] = tier_zone[f"{tier}_deep"] = (spec["zone"], spec["radius"][1])
veins = read_veins(world)
per_tier, untracked, stray = {}, {}, []
blocks = {(x, y, z): (name, biome) for x, y, z, name, biome in ore_at}
zone_of = {pos: grid.zone_at(pos[0], pos[2], biome) for pos, (_, biome) in blocks.items()}
for pos, (name, _) in blocks.items():
vein = veins.get(pos)
if vein is None:
if ORE.search(name): # amethyst outside a vein is a natural geode
untracked[name] = untracked.get(name, 0) + 1
continue
tier = vein[:-5] if vein.endswith("_deep") else vein
counts = per_tier.setdefault(tier, {})
counts[zone_of[pos] or "none"] = counts.get(zone_of[pos] or "none", 0) + 1
want, radius = tier_zone.get(vein, (None, 0))
if zone_of[pos] == want:
continue
reach = 2 * radius
near = any(veins.get(q) == vein and zone_of.get(q) == want
for q in ((pos[0] + dx, pos[1] + dy, pos[2] + dz) for dx in range(-reach, reach + 1)
for dy in range(-reach, reach + 1) for dz in range(-reach, reach + 1)))
if not near:
stray.append((vein, pos, zone_of[pos]))
problems = [f"{n} {name} outside any regenerating vein" for name, n in sorted(untracked.items())]
if not sample:
problems += [f"ore tier {tier} has no vein in the world" for tier in plan["ores"] if tier not in per_tier]
by_vein = {}
for vein, pos, zone in stray:
by_vein.setdefault((vein, zone), []).append(pos)
problems += [f"{len(at)} blocks of vein {vein} in zone {zone}, not {tier_zone.get(vein, ('(no tier)',))[0]} "
f"(e.g. {at[0]})" for (vein, zone), at in sorted(by_vein.items(), key=str)]
return per_tier, problems
def border_of(level): def border_of(level):
return level.get("BorderCenterX", 0.0), level.get("BorderCenterZ", 0.0), level.get("BorderSize", 5.9999968e7) return level.get("BorderCenterX", 0.0), level.get("BorderCenterZ", 0.0), level.get("BorderSize", 5.9999968e7)
@@ -348,10 +427,12 @@ def main():
biomes = json.loads((ROOT / "world/biomes.json").read_text()) biomes = json.loads((ROOT / "world/biomes.json").read_text())
listed = set(biomes["allowed"]) | set(biomes["water"]) listed = set(biomes["allowed"]) | set(biomes["water"])
unlisted = {b: n for b, n in data["sections_per_biome"].items() if b not in listed} unlisted = {b: n for b, n in data["sections_per_biome"].items() if b not in listed}
vein_counts, vein_problems = check_veins(args.world, data["ore_at"], args.sample)
report = { report = {
"chunks_scanned": data["chunks"], "chunks_scanned": data["chunks"],
"ore_blocks": {k: v for k, v in data["ores"].items() if ORE.search(k)}, "ore_blocks": {k: v for k, v in data["ores"].items() if ORE.search(k) or k in VEIN_BLOCKS},
"mob_source_blocks": {k: v for k, v in data["ores"].items() if not ORE.search(k)}, "vein_blocks_per_tier_and_zone": vein_counts,
"mob_source_blocks": {k: v for k, v in data["ores"].items() if MOB_BLOCK.search(k) or "shrieker" in k},
"unlisted_biomes": unlisted, "unlisted_biomes": unlisted,
"gamerules_not_as_recipe": wrong_rules, "gamerules_not_as_recipe": wrong_rules,
"vanilla_mobs_in_world": vanilla_mobs, "vanilla_mobs_in_world": vanilla_mobs,
@@ -368,7 +449,9 @@ def main():
args.json.write_text(json.dumps(report, indent=2) + "\n") args.json.write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps({k: v for k, v in report.items() if k != "land_biomes_percent"}, indent=2)) print(json.dumps({k: v for k, v in report.items() if k != "land_biomes_percent"}, indent=2))
failures = ([f"{n} {name}" for name, n in data["ores"].items()] + border["problems"] failures = ([f"{n} {name}" for name, n in data["ores"].items() if MOB_BLOCK.search(name) or "shrieker" in name]
+ vein_problems
+ border["problems"]
+ [f"unlisted biome {b} in {n} chunk sections" for b, n in unlisted.items()] + [f"unlisted biome {b} in {n} chunk sections" for b, n in unlisted.items()]
+ [f"gamerule {r} is {v}, recipe says {recipe['gamerules'][r]}" for r, v in wrong_rules.items()] + [f"gamerule {r} is {v}, recipe says {recipe['gamerules'][r]}" for r, v in wrong_rules.items()]
+ [f"{n} vanilla {mob} in the world" for mob, n in vanilla_mobs.items()]) + [f"{n} vanilla {mob} in the world" for mob, n in vanilla_mobs.items()])
@@ -378,7 +461,8 @@ def main():
sys.exit(1) sys.exit(1)
if report["missing_listed_biomes"] and not args.sample: if report["missing_listed_biomes"] and not args.sample:
print(f"note: listed biomes not on this island: {', '.join(report['missing_listed_biomes'])}") print(f"note: listed biomes not on this island: {', '.join(report['missing_listed_biomes'])}")
print(f"\nWORLD CHECK PASSED: {data['chunks']} chunks, no ore, no vanilla mobs or mob sources, only listed biomes" print(f"\nWORLD CHECK PASSED: {data['chunks']} chunks, ore only in regenerating veins in their zones, "
f"no vanilla mobs or mob sources, only listed biomes"
+ ("" if args.sample else ", border fits the coast")) + ("" if args.sample else ", border fits the coast"))
+33
View File
@@ -0,0 +1,33 @@
"""The zone grid as the companion mod sees it (datapacks/core/data/cobblesurvival/zones/island.json).
zone_at() answers like zone/Zones.kt: a cave biome is its cave zone; otherwise the grid cell's zone.
"""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ISLAND = ROOT / "datapacks/core/data/cobblesurvival/zones/island.json"
class Grid:
def __init__(self, data):
self.radius, self.step, self.size = data["radius"], data["step"], data["size"]
self.ids = [z["id"] for z in data["zones"]]
self.caves = {c["biome"]: c["id"] for c in data["caves"]}
self.cells = [value for value, count in data["grid_rle"] for _ in range(count)]
if len(self.cells) != self.size * self.size:
raise ValueError(f"zone grid has {len(self.cells)} cells, expected {self.size ** 2}")
def zone_at(self, x, z, biome=None):
if biome in self.caves:
return self.caves[biome]
col = (x + self.radius + self.step // 2) // self.step
row = (z + self.radius + self.step // 2) // self.step
if not (0 <= col < self.size and 0 <= row < self.size):
return None
index = self.cells[row * self.size + col]
return self.ids[index] if 0 <= index < len(self.ids) else None
def load(path=ISLAND):
return Grid(json.loads(Path(path).read_text()))
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Write Regenerating Ore Veins' config from the zone plan, so every ore tier only has veins in its zone.
world/zones.json → "ores" lists the tiers. This tool writes pack/config/regenerating_ore_veins/:
* areas.json: each surface zone that has a tier, as boxes from the zone grid (build/zone-layout.json,
24-block cells; rows of cells merged into rectangles), named <zone>_<n>, reaching from bedrock to
the build limit. The mod's boxes are centre plus half-size, so a box covers its cells except their
last row and column of blocks: no box reaches into a neighbouring zone.
* veins.json: one vein per tier, or two when the tier has "stone" and "deepslate" blocks (an upper vein
from y 0 up, "<tier>_deep" below). A surface tier's veins are limited to its zone's boxes and kept out
of the cave biomes, which are zones of their own; a cave tier's veins to its cave biome. The mod checks
the area for every block and the biome at a vein's centre. One vein only for the self-tests comes
last ("selftest", a single coal block); its area lies far outside the world border, so it never
generates, and the tests place it by command.
* global.json: regenerators can't be broken by players (allow_breaking false), so nobody can remove a
vein for good; explosions don't destroy veins.
* vein_locator.json: the Vein Locator from a compass and a copper ingot (mid-game) instead of netherite.
Re-run after changing the zone plan or the grid (tools/zone_layout.py) and commit the result.
Usage: tools/zone_ores.py
"""
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "pack/config/regenerating_ore_veins"
LAYOUT = ROOT / "build/zone-layout.json"
BOTTOM, TOP = -64, 320
SELFTEST_AREA = "selftest_nowhere"
def rectangles(mask, size):
"""Cover the True cells of a size×size row-major mask with rectangles (col0, row0, col1, row1)."""
open_runs, done = {}, []
for row in range(size + 1):
runs = set()
col = 0
while row < size and col < size:
if mask[row * size + col]:
start = col
while col < size and mask[row * size + col]:
col += 1
runs.add((start, col - 1))
col += 1
for run in list(open_runs):
if run not in runs:
done.append((run[0], open_runs.pop(run), run[1], row - 1))
for run in runs:
open_runs.setdefault(run, row)
return sorted(done, key=lambda r: (r[1], r[0]))
def zone_areas(layout, zone_id):
size, step, radius = layout["size"], layout["step"], layout["radius"]
index = layout["zones"].index(zone_id)
mask = [value == index for value in layout["grid"]]
areas = []
for n, (c0, r0, c1, r1) in enumerate(rectangles(mask, size)):
# Cell i covers -radius - step/2 + i*step ... +step-1 (as in zone/Zones.kt).
x0, z0 = c0 * step - radius - step // 2, r0 * step - radius - step // 2
w, h = (c1 - c0 + 1) * step, (r1 - r0 + 1) * step
areas.append({"dimension": "minecraft:overworld", "name": f"{zone_id}_{n}", "type": "box",
"x": x0 + w // 2 - 1, "y": (BOTTOM + TOP) // 2, "z": z0 + h // 2 - 1,
"dimx": w // 2 - 1, "dimy": (TOP - BOTTOM) // 2, "dimz": h // 2 - 1})
return areas
def vein(vein_id, blocks, spec, defaults, y_range, where):
seconds = spec["regen_minutes"] * 60
jitter = round(seconds * spec.get("jitter", defaults["jitter"]))
return {
"id": vein_id,
"blocks": [b for b, _ in blocks],
"weights": [w for _, w in blocks],
"dimension_whitelist": ["minecraft:overworld"],
**where,
"shape": "sphere",
"min_radius": spec["radius"][0],
"max_radius": spec["radius"][1],
"attempts": spec.get("attempts", defaults["attempts"]),
"min_y": y_range[0],
"max_y": y_range[1],
"chunk_minimum_generation_separation": spec["spacing"],
"fill_factor": spec.get("fill", defaults["fill"]),
"regeneration_interval_seconds": seconds,
"regeneration_interval_jitter": {"range_min": -jitter, "range_max": jitter},
}
def selftest_vein():
return {"id": "selftest", "blocks": ["minecraft:coal_ore"], "dimension_whitelist": ["minecraft:overworld"],
"area_whitelist": [SELFTEST_AREA], "shape": "sphere", "min_radius": 0, "max_radius": 0,
"attempts": 1, "min_y": 0, "max_y": 0, "chunk_minimum_generation_separation": 64,
"fill_factor": 1.0, "regeneration_interval_seconds": 3600,
"regeneration_interval_jitter": {"range_min": 0, "range_max": 0}}
def main():
if not LAYOUT.exists():
sys.exit(f"{LAYOUT.relative_to(ROOT)} is missing: run tools/zone_layout.py first.")
layout = json.loads(LAYOUT.read_text())
plan = json.loads((ROOT / "world/zones.json").read_text())
defaults = plan["ore_defaults"]
caves = {c["id"]: c["biome"] for c in plan["caves"]}
surface = {z["id"] for z in plan["zones"]}
areas, area_names, veins = [], {}, []
for tier, spec in plan["ores"].items():
zone = spec["zone"]
if zone in caves:
where = {"biome_whitelist": [caves[zone]]}
elif zone in surface:
if zone not in area_names:
boxes = zone_areas(layout, zone)
if not boxes:
sys.exit(f"zone {zone} (ore tier {tier}) has no cells in the grid")
areas += boxes
area_names[zone] = [a["name"] for a in boxes]
where = {"area_whitelist": area_names[zone], "biome_blacklist": sorted(caves.values())}
else:
sys.exit(f"ore tier {tier}: unknown zone {zone}")
low, high = spec["y"]
if "blocks" in spec:
veins.append(vein(tier, spec["blocks"], spec, defaults, (low, high), where))
continue
if spec.get("stone") and high >= 0:
veins.append(vein(tier, [(spec["stone"], 1)], spec, defaults, (max(low, 0), high), where))
if spec.get("deepslate") and low < 0:
deep_id = f"{tier}_deep" if spec.get("stone") and high >= 0 else tier
veins.append(vein(deep_id, [(spec["deepslate"], 1)], spec, defaults, (low, min(high, -1)), where))
areas.append({"dimension": "minecraft:overworld", "name": SELFTEST_AREA, "type": "box",
"x": 29_000_000, "y": 0, "z": 29_000_000, "dimx": 0, "dimy": 0, "dimz": 0})
veins.append(selftest_vein())
OUT.mkdir(parents=True, exist_ok=True)
(OUT / "areas.json").write_text(json.dumps(areas, indent=1) + "\n")
(OUT / "veins.json").write_text(json.dumps(veins, indent=1) + "\n")
(OUT / "global.json").write_text(json.dumps({
"allow_breaking": False,
"break_hardness": 5.0,
"default_regeneration_seconds": 3600,
"default_jitter_interval": {"range_min": 0, "range_max": 0},
"regeneration_smoke_particles": True,
"destroyed_by_explosives": False,
"default_fill_factor": defaults["fill"],
}, indent=2) + "\n")
(OUT / "vein_locator.json").write_text(json.dumps({
"allow_crafting": True,
"allow_use": True,
"crafting_recipe": ["minecraft:compass", "minecraft:copper_ingot"],
"craft_any_order": True,
"chance_to_break": 0.75,
"use_durability": False,
"durability": 2,
"stack_size": 64,
}, indent=2) + "\n")
per_zone = {}
for tier, spec in plan["ores"].items():
per_zone.setdefault(spec["zone"], []).append(tier)
print(f"wrote {len(veins)} veins ({len(plan['ores'])} tiers) and {len(areas)} areas to {OUT.relative_to(ROOT)}")
for zone, tiers in per_zone.items():
boxes = len(area_names.get(zone, []))
print(f" {zone}: {', '.join(tiers)}" + (f" ({boxes} boxes)" if boxes else " (cave biome)"))
if __name__ == "__main__":
main()
+25 -1
View File
@@ -1,9 +1,33 @@
{ {
"_comment": "Zone plan for Phase 3 (model approved by Adrian 2026-10-29; levels are placeholders, see docs/balance.md). tools/zone_layout.py assigns each surface cell of the island to the first zone whose rule matches, in this order, and renders build/zone-map.png. Rules: 'within' = distance from 0,0 in blocks; 'biomes' = cells of these biomes, clusters smaller than min_cells dropped, plus 'absorb' = cells of other biomes within that many blocks of the cluster; 'region' = what is left, by half-plane (x/z bounds in blocks); 'water' = sea cells of these biomes. Biomes in 'inherit' (rivers) take the zone of the land around them. Cave zones follow the cave biome everywhere under the island and are not drawn. 'lines' = how many evolution lines tools/zone_spawns.py aims to give the zone (scaled to the lines available); 'species' pins a line to a zone by hand. 'guardian': the zone's Alpha guardian (tools/zone_spawns.py picks the strongest Pokémon spawning in the zone unless 'species' is given, at the band's top level + 5) and its drops, which help with the next zone; it stands at the zone's sample point and returns guardian_respawn_hours after it is beaten (Adrian, 2026-10-29: fixed spots, 5 hours). 'level_pressure': a Pokémon more than grace_levels above the band of the zone its trainer stands in gets per_level less experience per extra level, down to minimum (Exp. Candy and commands excepted).", "_comment": "Zone plan for Phase 3 (model approved by Adrian 2026-10-29; levels are placeholders, see docs/balance.md). tools/zone_layout.py assigns each surface cell of the island to the first zone whose rule matches, in this order, and renders build/zone-map.png. Rules: 'within' = distance from 0,0 in blocks; 'biomes' = cells of these biomes, clusters smaller than min_cells dropped, plus 'absorb' = cells of other biomes within that many blocks of the cluster; 'region' = what is left, by half-plane (x/z bounds in blocks); 'water' = sea cells of these biomes. Biomes in 'inherit' (rivers) take the zone of the land around them. Cave zones follow the cave biome everywhere under the island and are not drawn. 'lines' = how many evolution lines tools/zone_spawns.py aims to give the zone (scaled to the lines available); 'species' pins a line to a zone by hand. 'guardian': the zone's Alpha guardian (tools/zone_spawns.py picks the strongest Pokémon spawning in the zone unless 'species' is given, at the band's top level + 5) and its drops, which help with the next zone; it stands at the zone's sample point and returns guardian_respawn_hours after it is beaten (Adrian, 2026-10-29: fixed spots, 5 hours). 'ores': Phase 4 ore tiers for Regenerating Ore Veins (tools/zone_ores.py writes the mod's config from them). Each tier has veins in exactly one zone, higher tiers in higher zones; Cobblemon's evolution stones are tiers too, since their natural ores are off. 'stone'/'deepslate' split a tier into an upper vein (y >= 0) and a lower one; 'blocks' is a weighted list for one vein. 'radius' in blocks, 'y' range, 'spacing' in chunks between candidate chunks, 'regen_minutes' of real time, plus 'jitter' (share of the interval), 'fill' and 'attempts' from 'ore_defaults'. 'showcase': the template gets one hand-placed vein of the tier at its zone's sample point. 'level_pressure': a Pokémon more than grace_levels above the band of the zone its trainer stands in gets per_level less experience per extra level, down to minimum (Exp. Candy and commands excepted).",
"min_cells": 6, "min_cells": 6,
"inherit": ["minecraft:river"], "inherit": ["minecraft:river"],
"guardian_respawn_hours": 5, "guardian_respawn_hours": 5,
"level_pressure": {"grace_levels": 5, "per_level": 0.1, "minimum": 0.1}, "level_pressure": {"grace_levels": 5, "per_level": 0.1, "minimum": 0.1},
"ore_defaults": {"fill": 0.7, "attempts": 1, "jitter": 0.1},
"ores": {
"coal": {"zone": "button_isle", "stone": "minecraft:coal_ore", "deepslate": "minecraft:deepslate_coal_ore", "radius": [1, 2], "y": [-32, 100], "spacing": 4, "regen_minutes": 20, "attempts": 2, "showcase": true},
"copper": {"zone": "southwold", "stone": "minecraft:copper_ore", "deepslate": "minecraft:deepslate_copper_ore", "radius": [1, 2], "y": [-32, 100], "spacing": 5, "regen_minutes": 30, "attempts": 2, "showcase": true},
"iron": {"zone": "westmarch", "stone": "minecraft:iron_ore", "deepslate": "minecraft:deepslate_iron_ore", "radius": [1, 2], "y": [-48, 90], "spacing": 5, "regen_minutes": 40, "attempts": 2, "showcase": true},
"redstone": {"zone": "jungle", "stone": "minecraft:redstone_ore", "deepslate": "minecraft:deepslate_redstone_ore", "radius": [1, 2], "y": [-56, 60], "spacing": 6, "regen_minutes": 45, "showcase": true},
"lapis": {"zone": "highlands", "stone": "minecraft:lapis_ore", "deepslate": "minecraft:deepslate_lapis_ore", "radius": [1, 2], "y": [-56, 80], "spacing": 6, "regen_minutes": 45, "showcase": true},
"gold": {"zone": "sunscorch", "stone": "minecraft:gold_ore", "deepslate": "minecraft:deepslate_gold_ore", "radius": [1, 2], "y": [-48, 120], "spacing": 6, "regen_minutes": 60, "showcase": true},
"emerald": {"zone": "frostpeak", "stone": "minecraft:emerald_ore", "deepslate": "minecraft:deepslate_emerald_ore", "radius": [1, 1], "y": [-16, 320], "spacing": 7, "regen_minutes": 75, "showcase": true},
"amethyst": {"zone": "dripstone", "blocks": [["minecraft:amethyst_block", 3], ["minecraft:budding_amethyst", 1]], "radius": [1, 1], "y": [-64, 64], "spacing": 8, "regen_minutes": 120},
"diamond": {"zone": "deep_dark", "deepslate": "minecraft:deepslate_diamond_ore", "radius": [1, 1], "y": [-64, 0], "spacing": 7, "regen_minutes": 90},
"quartz": {"zone": "basalt", "blocks": [["minecraft:nether_quartz_ore", 1]], "radius": [1, 2], "y": [-64, 64], "spacing": 6, "regen_minutes": 60},
"netherite": {"zone": "basalt", "blocks": [["minecraft:ancient_debris", 1]], "radius": [0, 1], "y": [-64, 32], "spacing": 10, "regen_minutes": 180},
"thunder_stone": {"zone": "westmarch", "stone": "cobblemon:thunder_stone_ore", "deepslate": "cobblemon:deepslate_thunder_stone_ore", "radius": [0, 1], "y": [-48, 100], "spacing": 8, "regen_minutes": 60},
"shiny_stone": {"zone": "lush_caves", "stone": "cobblemon:shiny_stone_ore", "deepslate": "cobblemon:deepslate_shiny_stone_ore", "radius": [0, 1], "y": [-64, 64], "spacing": 8, "regen_minutes": 60},
"leaf_stone": {"zone": "jungle", "stone": "cobblemon:leaf_stone_ore", "deepslate": "cobblemon:deepslate_leaf_stone_ore", "radius": [0, 1], "y": [-48, 100], "spacing": 8, "regen_minutes": 60},
"water_stone": {"zone": "mire", "stone": "cobblemon:water_stone_ore", "deepslate": "cobblemon:deepslate_water_stone_ore", "radius": [0, 1], "y": [-48, 100], "spacing": 8, "regen_minutes": 60, "showcase": true},
"moon_stone": {"zone": "dripstone", "blocks": [["cobblemon:dripstone_moon_stone_ore", 1], ["cobblemon:moon_stone_ore", 1], ["cobblemon:deepslate_moon_stone_ore", 1]], "radius": [0, 1], "y": [-64, 64], "spacing": 8, "regen_minutes": 60},
"sun_stone": {"zone": "highlands", "stone": "cobblemon:sun_stone_ore", "deepslate": "cobblemon:deepslate_sun_stone_ore", "radius": [0, 1], "y": [-48, 100], "spacing": 8, "regen_minutes": 60},
"fire_stone": {"zone": "sunscorch", "stone": "cobblemon:fire_stone_ore", "deepslate": "cobblemon:deepslate_fire_stone_ore", "radius": [0, 1], "y": [-48, 120], "spacing": 8, "regen_minutes": 60},
"ice_stone": {"zone": "frostpeak", "stone": "cobblemon:ice_stone_ore", "deepslate": "cobblemon:deepslate_ice_stone_ore", "radius": [0, 1], "y": [-16, 320], "spacing": 8, "regen_minutes": 60},
"dawn_stone": {"zone": "frostpeak", "stone": "cobblemon:dawn_stone_ore", "deepslate": "cobblemon:deepslate_dawn_stone_ore", "radius": [0, 1], "y": [-16, 320], "spacing": 9, "regen_minutes": 75},
"dusk_stone": {"zone": "deep_dark", "deepslate": "cobblemon:deepslate_dusk_stone_ore", "radius": [0, 1], "y": [-64, 0], "spacing": 9, "regen_minutes": 75}
},
"species": {"durant": "basalt", "torkoal": "basalt", "heatmor": "basalt"}, "species": {"durant": "basalt", "torkoal": "basalt", "heatmor": "basalt"},
"zones": [ "zones": [
{ {