Phase 4: veins in every chunk, drops for the mobs the island lacks

Ore veins: make_world.py loads the island again row by row until
Regenerating Ore Veins has filled every chunk (/cobblesurvival veins
unfilled), cave tiers sit at their biome's depths, small zones get denser
veins, showcase veins are full size. The world report checks surface
tiers against the zone grid, cave tiers within two biome cells of their
cave, and fails on unfilled chunks; the template passes (37,689 chunks).

Drops: tools/drop_audit.py checks every creature drop a recipe or brewing
needs (docs/pokemon-drops.md). Blaze rods, ghast tears, breeze rods,
shulker shells and nether stars now drop from Pokemon through species
additions (self-test drop_additions_apply). The field guide counts vanilla
mobs as absent, applies species additions and reads the veins as sources.

Cobblebase tried (loads, self-tests pass) and left out until Adrian
decides its scope. Phase 4 checklist with the open questions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-30 03:36:50 +01:00
1 parent 5d275e4e14
commit 1a1fb93da8
33 files changed
+1279 -126

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Phase 4 drop audit: every crafting input that comes from a creature must have a source on the island.
Only Pokémon live on the island, so whatever a vanilla mob would drop has to come from somewhere else:
a Pokémon's drop, a chest, a vein, a craft. This builds the field guide's model of the pack (no server; it
uses the last KubeJS recipe export, so run tools/fieldguide/build.py after changing KubeJS scripts) and audits
every item that a Pokémon or a vanilla mob drops and that a recipe consumes (crafting, smelting, smithing,
the pack's KubeJS recipes) or that brewing needs (brewing lives in code, so BREWING lists it).
Writes docs/pokemon-drops.md: per item, the wild Pokémon that drop it with their zones and chances, its other
sources, and what consumes it. Fails when an audited item has no source on the island, unless NO_SOURCE_OK
says why that's intended, and when the pack's species additions give drops to a Pokémon whose zone spawn has
drops of its own (a spawn's drops replace the species' drops, so the addition would never apply in the wild).
Usage: tools/drop_audit.py
"""
import json
import sys
from collections import defaultdict
from pathlib import Path
sys.dont_write_bytecode = True
sys.path.insert(0, str(Path(__file__).resolve().parent))
from fieldguide import datasource, kubejs_export # noqa: E402
from fieldguide.model import Model # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "docs/pokemon-drops.md"
POOLS = ROOT / "datapacks/core/data/cobblesurvival/spawn_pool_world"
ADDITIONS = ROOT / "datapacks/core/data/cobblesurvival/species_additions"
VEIN_SOURCES = ROOT / "world/vein-sources.json"
# Brewing ingredients and the brewing stand's fuel (vanilla code, not data).
BREWING = {
"minecraft:nether_wart": "brewing: Awkward Potion", "minecraft:blaze_powder": "brewing: Strength; stand fuel",
"minecraft:ghast_tear": "brewing: Regeneration", "minecraft:magma_cream": "brewing: Fire Resistance",
"minecraft:glistering_melon_slice": "brewing: Healing", "minecraft:rabbit_foot": "brewing: Leaping",
"minecraft:spider_eye": "brewing: Poison", "minecraft:fermented_spider_eye": "brewing: Weakness, corrupting",
"minecraft:phantom_membrane": "brewing: Slow Falling", "minecraft:pufferfish": "brewing: Water Breathing",
"minecraft:golden_carrot": "brewing: Night Vision", "minecraft:turtle_helmet": "brewing: Turtle Master",
"minecraft:sugar": "brewing: Swiftness", "minecraft:redstone": "brewing: longer potions",
"minecraft:glowstone_dust": "brewing: stronger potions", "minecraft:gunpowder": "brewing: splash potions",
"minecraft:dragon_breath": "brewing: lingering potions", "minecraft:breeze_rod": "brewing: Wind Charging",
"minecraft:slime_block": "brewing: Oozing", "minecraft:cobweb": "brewing: Weaving",
}
# Audited items that may stay without a source, and why.
NO_SOURCE_OK = {
"cold_sweat:chameleon_molt": "Cold Sweat's chameleon armour and Thermolith are not part of the pack's progression",
"minecraft:dragon_breath": "the End's dragon is the only source by design (the End is reachable)",
"minecraft:tide_armor_trim_smithing_template": "cosmetic (an armour trim); elder guardians don't live on the island",
"minecraft:wither_skeleton_skull": "cosmetic here (the skull banner pattern); the Wither can't be built",
}
def zones_by_species():
zones = defaultdict(set)
for pool in sorted(POOLS.glob("*.json")):
for spawn in json.loads(pool.read_text()).get("spawns", []):
species = spawn["pokemon"].split()[0].split(":")[-1]
zones[f"cobblemon:{species}"].add(pool.stem)
return zones
def overridden_additions():
"""Species additions with drops whose target has a zone spawn with its own drops."""
targets = {json.loads(f.read_text())["target"].split(":")[-1]: f.name for f in sorted(ADDITIONS.glob("*.json"))
if "drops" in json.loads(f.read_text())}
clashes = []
for pool in sorted(POOLS.glob("*.json")):
for spawn in json.loads(pool.read_text()).get("spawns", []):
species = spawn["pokemon"].split()[0].split(":")[-1]
if species in targets and "drops" in spawn:
clashes.append(f"{targets[species]}: spawn {spawn['id']} has drops of its own")
return clashes
def consumers(model):
used = defaultdict(set)
for rid, recipe in model.recipes.items():
for slot in recipe.get("slots") or []:
for item in (slot or {}).get("items", []):
used[item].add(recipe["result"])
return used
def main():
stack = datasource.build_stack(log=lambda msg: None)
kubejs = kubejs_export.load()
if not kubejs or kubejs["stale"]:
print("note: the KubeJS recipe export is missing or older than the pack; run tools/fieldguide/build.py to refresh it")
model = Model(stack, log=lambda msg: None, extras=[VEIN_SOURCES] if VEIN_SOURCES.exists() else [], kubejs=kubejs)
zones = zones_by_species()
used = consumers(model)
audited = {}
for key, sources in model.sources.items():
if not key.startswith("item:"):
continue
item = key[5:]
if not any(s["kind"] in ("pokemon", "mob") for s in sources):
continue
uses = sorted(model.item_name(r) for r in used.get(item, ()))
if item in BREWING:
uses.insert(0, BREWING[item])
if uses:
audited[item] = (sources, uses)
rows, missing = [], []
for item in sorted(audited, key=model.item_name):
sources, uses = audited[item]
pokemon = defaultdict(set)
for s in sources:
if s["kind"] == "pokemon" and s["status"] == "available":
where = ", ".join(sorted(zones.get(s["species"], ()))) or "?"
pokemon[f"{model.species_name(s['species'])} ({where}, {s['chance']})"].add(1)
others = sorted({s["text"] for s in sources if s["status"] == "available" and s["kind"] not in ("pokemon", "mob")})
if not pokemon and not others:
if item not in NO_SOURCE_OK:
missing.append(item)
rows.append((model.item_name(item), item, sorted(pokemon), others, uses))
lines = [
"# Pokémon drops and what they're for",
"",
"Generated by `tools/drop_audit.py` (roadmap Phase 4, \"unique drops\"): every item a Pokémon or a vanilla mob "
"drops that a recipe or brewing consumes. Only Pokémon live on the island, so a mob's drop needs another source. "
"Zones are the wild Pokémon's home zones (`docs/zone-species.md`); chances are per defeat. Other sources are cut "
"to five.",
"",
"| Item | Wild Pokémon that drop it | Other sources | Consumed by |",
"| --- | --- | --- | --- |",
]
for name, item, pokemon, others, uses in rows:
source_note = "" if pokemon or others else ("**none**" if item not in NO_SOURCE_OK else f"none ({NO_SOURCE_OK[item]})")
lines.append(f"| {name} (`{item}`) | {'; '.join(pokemon) or source_note or '–'} | "
f"{'; '.join(others[:5]) + (' …' if len(others) > 5 else '') or '–'} | {', '.join(uses[:8])}"
f"{' …' if len(uses) > 8 else ''} |")
OUT.write_text("\n".join(lines) + "\n")
print(f"audited {len(rows)} items; wrote {OUT.relative_to(ROOT)}")
clashes = overridden_additions()
if missing:
print("NO SOURCE ON THE ISLAND:")
for item in missing:
print(f" - {item}: used by {', '.join(audited[item][1][:6])}")
if clashes:
print("SPECIES ADDITIONS THAT NEVER APPLY IN THE WILD:")
for clash in clashes:
print(f" - {clash}")
if missing or clashes:
sys.exit(1)
print("every audited item has a source")
if __name__ == "__main__":
main()
+5 -2
View File
@@ -18,7 +18,8 @@ Writes build/field-guide/:
Usage: tools/fieldguide/build.py [--out DIR] [--no-icons] [--no-server] [--from-json FILE] [--extra FILE ...]
--no-server never start Minecraft: use the last KubeJS recipe export even if the pack changed since
--from-json skip extraction and render an existing fieldguide.json (e.g. one a runtime exporter wrote)
--extra add sources from a JSON file (format in docs/field-guide.md), e.g. ore veins or trades
--extra add sources from a JSON file (format in docs/field-guide.md), e.g. trades; the ore veins
(world/vein-sources.json, written by tools/zone_ores.py) are always added
"""
import argparse
import json
@@ -33,6 +34,7 @@ from fieldguide.icons import Icons # noqa: E402
from fieldguide.model import Model # noqa: E402
ROOT = Path(__file__).resolve().parent.parent.parent
VEIN_SOURCES = ROOT / "world/vein-sources.json" # the regenerating ore veins (tools/zone_ores.py), always read
def main():
@@ -52,7 +54,8 @@ def main():
else:
print("reading the pack's data")
stack = datasource.build_stack(log=_log_once())
model = Model(stack, log=lambda msg: print(" " + msg), extras=args.extra, kubejs=_kubejs(args.no_server))
extras = [VEIN_SOURCES] + args.extra if VEIN_SOURCES.exists() else args.extra
model = Model(stack, log=lambda msg: print(" " + msg), extras=extras, kubejs=_kubejs(args.no_server))
icons = {}
if not args.no_icons:
wanted = set(model.mod_items) | _vanilla_mentions(model)
+12 -1
View File
@@ -193,6 +193,14 @@ class Model:
}
for e in (d.get("drops") or {}).get("entries", []):
self.referenced.add(norm_id(e["item"]))
# Species additions (data/<ns>/species_additions/) replace the fields they set; drops is the one the guide shows.
for _, rel in sorted(self.stack.ids("species_additions").items()):
d = self.stack.json(rel) or {}
target = self.species.get(norm_id(d.get("target", "")))
if target is not None and "drops" in d:
target["drops"] = d["drops"]
for e in d["drops"].get("entries", []):
self.referenced.add(norm_id(e["item"]))
def species_name(self, sid):
return self.species.get(sid, {}).get("name") or sid.split(":")[-1].title()
@@ -563,9 +571,12 @@ class Model:
text = {"biome": "Spawns in " + _join([self.biome_name(b) for b in where]),
"structure": "Spawns in " + _join([self.structure_name(s) for s in where]),
"special": _join(where).capitalize()}[e["kind"]]
if not self.island.mob_allowed(mob):
text += " (but only Pokémon live on the island)"
self.add(f"mob:{mob}", e["kind"], text, base=e["status"])
for mob, (item, note) in self.island.item_mobs.items():
self.add(f"mob:{mob}", "item", note.capitalize(), needs=[[f"item:{item}"]])
if self.island.mob_allowed(mob):
self.add(f"mob:{mob}", "item", note.capitalize(), needs=[[f"item:{item}"]])
def _spawn_nodes(self):
self.spawn_by_species = defaultdict(list)
+10
View File
@@ -515,6 +515,16 @@ class Island:
self._add_mob(mob, "special", note, st)
elif kind == "item":
self.item_mobs[mob] = (req, note)
# The companion mod lets only Cobblemon's entities join a world (world/OnlyPokemon.kt), apart from the
# entity type tag cobblesurvival:allowed_vanilla_mobs; every other mob never appears, whatever spawns it.
self.allowed_mobs = self.tags["entity_type"].resolve("#cobblesurvival:allowed_vanilla_mobs")
for mob, entries in self.mobs.items():
if not self.mob_allowed(mob):
for e in entries:
e["status"] = "unavailable"
def mob_allowed(self, mob):
return mob.startswith("cobblemon:") or mob in self.allowed_mobs
def _add_mob(self, mob, kind, where, status):
lst = self.mobs.setdefault(mob, [])
+34 -2
View File
@@ -7,7 +7,9 @@
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
border generate when someone gets there).
3. In the same session: place the showcase ore veins (world/zones.json →
3. In the same session: load the circle again row by row until Regenerating
Ore Veins has put veins into every chunk (it only fills a new chunk while
it is loaded, 8 a tick, and Chunky is faster), place the showcase ore veins (world/zones.json →
"ores", tiers with "showcase": one vein sticking out of the ground near
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
@@ -23,9 +25,12 @@ Usage: tools/make_world.py
"""
import argparse
import json
import math
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -41,6 +46,32 @@ SKIP = shutil.ignore_patterns("playerdata", "advancements", "stats", "session.lo
SHOWCASE_OFFSETS = [(16, 0), (0, 16), (-16, 0), (0, -16), (24, 24), (-24, -24)]
def fill_veins(server, radius):
"""Keep each chunk row of the pregenerated circle loaded until the vein mod has filled it; returns chunks left."""
# A forced row also fully loads the two chunks around it (some of them new, past the circle's edge), so the wait
# counts the loaded, unfilled chunks in the row and two chunks around it.
started, left, rows = time.time(), 0, radius // 16 + 1
for cz in range(-rows, rows + 1):
half = math.isqrt(max(0, rows * rows - cz * cz))
area = f"{-half * 16} {cz * 16} {half * 16 + 15} {cz * 16 + 15}"
around = f"{-half * 16 - 32} {cz * 16 - 32} {half * 16 + 47} {cz * 16 + 47}"
server.command(f"forceload add {area}")
deadline = time.time() + 60
while True:
reply = server.command(f"cobblesurvival veins unfilled {around}")
match = re.search(r"unfilled chunks: (\d+)", reply)
if not match:
raise RuntimeError(f"veins unfilled: unexpected reply {reply!r}")
unfilled = int(match[1])
if unfilled == 0 or time.time() > deadline:
break
time.sleep(0.5)
server.command(f"forceload remove {area}")
left += unfilled
print(f"veins filled in {time.time() - started:.0f}s" + (f"; {left} chunks left unfilled" if left else ""))
return left
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())
@@ -56,7 +87,7 @@ def place_showcases(server):
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()
f"positioned ~ ~-1 ~ run regenerating_ore_veins place {tier}_showcase").strip()
server.command(f"forceload remove {x} {z}")
print(f" showcase {tier} at {x},{z}: {reply[:100]}")
@@ -101,6 +132,7 @@ def main():
print(f" {server.command(f'gamerule {rule} {str(value).lower()}').strip()}")
seconds = server.pregenerate(0, 0, recipe["pregen_radius"], shape="circle")
print(f"pregeneration finished in {seconds:.0f}s")
fill_veins(server, recipe["pregen_radius"])
place_showcases(server)
server.command("save-all flush")
cx, cz, size = border_for(world, recipe)
+5 -3
View File
@@ -8,9 +8,10 @@ instance are left alone. Close the instance in Prism before syncing.
When build/world-template exists (tools/make_world.py), it is copied into the
instance as the save "Cobblemon Survival Island" unless that save already
exists; --replace-world overwrites it.
exists; --replace-world overwrites it, --save-name puts it into another save
folder instead (e.g. next to a save someone has played).
Usage: tools/prism_instance.py [--name NAME] [--prism-dir DIR] [--memory MB] [--replace-world]
Usage: tools/prism_instance.py [--name NAME] [--prism-dir DIR] [--memory MB] [--replace-world] [--save-name NAME]
"""
import argparse
import json
@@ -64,6 +65,7 @@ def main():
parser.add_argument("--prism-dir", type=Path, help="Prism data folder (auto-detected)")
parser.add_argument("--memory", type=int, default=6144, help="max heap in MB")
parser.add_argument("--replace-world", action="store_true", help="overwrite the island save in the instance")
parser.add_argument("--save-name", default="Cobblemon Survival Island", help="save folder for the world template")
args = parser.parse_args()
prism = args.prism_dir or next((d for d in PRISM_DIRS if (d / "instances").is_dir()), None)
@@ -81,7 +83,7 @@ def main():
write_components(instance / "mmc-pack.json", pack["versions"])
sync("client", game, stage)
template, save = ROOT / "build/world-template", game / "saves" / "Cobblemon Survival Island"
template, save = ROOT / "build/world-template", game / "saves" / args.save_name
if (template / "level.dat").exists():
if save.exists() and not args.replace_world:
print(f"kept existing save {save.name} (use --replace-world to overwrite it)")
+71 -41
View File
@@ -4,11 +4,14 @@
* Ore scan: every block in every stored chunk is checked. Ore ("*_ore",
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.
blocks must lie in the zone of its ore tier (world/zones.json → "ores").
A surface tier's blocks must lie in its zone's grid cells (the mod checks
every block against the zone's boxes; at a cave border a vein can reach
into the cave biome). A cave tier's blocks must lie within two biome cells
(8 blocks) of its cave biome: the mod checks the biome only at a vein's
centre, and Minecraft blends biomes over a few blocks. Untracked ore fails
the check, and so (except with --sample) do an ore tier without any vein
and a chunk the mod never put veins into.
* Biome set: every biome stored in any chunk section (surface and caves)
must be listed in world/biomes.json ("allowed" or "water").
* End access: at least one stronghold must start inside the world border.
@@ -48,6 +51,8 @@ import zone_grid # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
ORE = re.compile(r"(_ore|:ancient_debris|:raw_(iron|copper|gold)_block)$")
CAVE_BIOMES = sorted(zone_grid.load().caves)
NEAR_CELLS = 2 # biome cells (4 blocks) around a vein block in which its cave biome may lie
# 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"]}
@@ -63,7 +68,8 @@ def scan_region(path):
floor = 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)
names, ores, chunks, present, strongholds, ore_at = {}, {}, 0, {}, [], []
names, ores, chunks, present, strongholds, ore_at, full = {}, {}, 0, {}, [], [], []
caves = {} # (chunk x, chunk z) -> 96x4x4 biome cells (y, z, x): index into CAVE_BIOMES + 1, 0 = no cave biome
loot = {"lootr": 0, "unconverted": 0, "unconverted_at": []}
for cx, cz, chunk in anvil.region_chunks(path):
for be in chunk.get("block_entities", []):
@@ -77,6 +83,7 @@ def scan_region(path):
if not str(chunk.get("Status", "")).endswith("full"):
continue
chunks += 1
full.append((cx, cz))
ox, oz = (cx - rx * 32) * 16, (cz - rz * 32) * 16
of = anvil.heightmap(chunk, "OCEAN_FLOOR")
mb = anvil.heightmap(chunk, "MOTION_BLOCKING")
@@ -88,6 +95,13 @@ def scan_region(path):
for section in sections.values():
for name in section.get("biomes", {}).get("palette", []):
present[name] = present.get(name, 0) + 1
biome_palette = section.get("biomes", {}).get("palette", [])
if any(b in CAVE_BIOMES for b in biome_palette):
biome_palette, biome_indices = anvil.section_biomes(section)
ids = np.array([CAVE_BIOMES.index(b) + 1 if b in CAVE_BIOMES else 0 for b in biome_palette], dtype=np.int8)
cells = caves.setdefault((cx, cz), np.zeros((96, 4, 4), dtype=np.int8))
y0 = (section["Y"] * 16 - anvil.MIN_Y) // 4
cells[y0:y0 + 4] = ids[biome_indices].reshape(4, 4, 4)
palette, indices = anvil.section_blocks(section)
entries = section.get("block_states", {}).get("palette", [])
hits = {i: name for i, name in enumerate(palette)
@@ -116,7 +130,20 @@ def scan_region(path):
palette, indices = decoded[y // 16]
name = palette[int(indices[((y % 16) // 4) * 16 + qz * 4 + qx])]
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, ore_at
# Which cave biomes lie within NEAR_CELLS biome cells of each vein block (chunks of this region only).
near_at = []
for x, y, z, name, biome_name in ore_at:
near = set()
qx0, qy0, qz0 = x >> 2, (y - anvil.MIN_Y) >> 2, z >> 2
for qx in range(qx0 - NEAR_CELLS, qx0 + NEAR_CELLS + 1):
for qz in range(qz0 - NEAR_CELLS, qz0 + NEAR_CELLS + 1):
cells = caves.get((qx >> 2, qz >> 2))
if cells is None:
continue
column = cells[max(0, qy0 - NEAR_CELLS):qy0 + NEAR_CELLS + 1, qz & 3, qx & 3]
near.update(CAVE_BIOMES[i - 1] for i in np.unique(column) if i)
near_at.append((x, y, z, name, biome_name, tuple(sorted(near))))
return rx, rz, floor, top, biome, list(names), ores, chunks, present, strongholds, loot, near_at, full
# Entities that aren't mobs: decorations, vehicles, and projectiles (a trial chamber's dispenser trap fires
@@ -147,11 +174,12 @@ def analyse(world):
floor = 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)
names, ores, chunks, present, strongholds, ore_at = [], {}, 0, {}, [], []
names, ores, chunks, present, strongholds, ore_at, full = [], {}, 0, {}, [], [], []
loot = {"lootr": 0, "unconverted": 0, "unconverted_at": []}
with ProcessPoolExecutor(max_workers=os.cpu_count()) as pool:
for rx, rz, f, t, b, local, o, n, pr, sh, lt, oa in pool.map(scan_region, regions):
for rx, rz, f, t, b, local, o, n, pr, sh, lt, oa, fc in pool.map(scan_region, regions):
ore_at += oa
full += fc
for k, v in lt.items():
loot[k] += v
strongholds += sh
@@ -171,19 +199,26 @@ def analyse(world):
for k, v in counts.items():
entities[k] = entities.get(k, 0) + v
return {"origin": (x0 * 512, z0 * 512), "floor": floor, "top": top, "biome": biome,
"biome_names": names, "ores": ores, "ore_at": ore_at, "chunks": chunks, "sections_per_biome": present,
"biome_names": names, "ores": ores, "ore_at": ore_at, "full_chunks": full, "chunks": chunks,
"sections_per_biome": present,
"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)."""
"""({(x, y, z): vein id}, {(chunk x, chunk z) the mod has filled}) from Regenerating Ore Veins' saved data."""
path = Path(world, "data/regenerating_ore_veins_veins.dat")
if not path.exists():
return {}
return {}, set()
import gzip
data = anvil.parse_nbt(gzip.decompress(path.read_bytes()))
data = data.get("data", data)
filled = set()
for packed in data.get("GeneratedChunks", []): # ChunkPos.toLong: x in the low 32 bits, z in the high
packed = int(packed)
x, z = packed & 0xFFFFFFFF, (packed >> 32) & 0xFFFFFFFF
filled.add((x - (1 << 32) if x >= 1 << 31 else x, z - (1 << 32) if z >= 1 << 31 else z))
veins = {}
for entry in data.get("data", data).get("Entries", []):
for entry in data.get("Entries", []):
pos = entry["Pos"]
if isinstance(pos, dict):
key = (pos["X"], pos["Y"], pos["Z"])
@@ -193,46 +228,41 @@ def read_veins(world):
else:
key = tuple(int(v) for v in pos)
veins[key] = entry.get("VeinId", "")
return veins
return veins, filled
def check_veins(world, ore_at, sample):
def check_veins(world, ore_at, full_chunks, 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)
cave_biome = {c["id"]: c["biome"] for c in plan["caves"]}
veins, filled = read_veins(world)
per_tier, untracked, stray = {}, {}, {}
for x, y, z, name, biome, near_caves in ore_at:
vein = veins.get((x, y, z))
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
tier = vein.removesuffix("_showcase").removesuffix("_deep")
zone = grid.zone_at(x, z, biome) or "none"
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]))
counts[zone] = counts.get(zone, 0) + 1
want = plan["ores"].get(tier, {}).get("zone")
if want in cave_biome:
ok = zone == want or cave_biome[want] in near_caves
else:
ok = want is not None and grid.zone_at(x, z) == want
if not ok:
stray.setdefault((vein, zone, want), []).append((x, y, z))
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)]
unfilled = [c for c in full_chunks if c not in filled]
if unfilled:
problems.append(f"{len(unfilled)} chunks never got their ore veins (e.g. chunk {unfilled[0]})")
problems += [f"{len(at)} blocks of vein {vein} in zone {zone}, not {want or '(no tier)'} (e.g. {at[0]})"
for (vein, zone, want), at in sorted(stray.items(), key=str)]
return per_tier, problems
@@ -427,7 +457,7 @@ def main():
biomes = json.loads((ROOT / "world/biomes.json").read_text())
listed = set(biomes["allowed"]) | set(biomes["water"])
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)
vein_counts, vein_problems = check_veins(args.world, data["ore_at"], data["full_chunks"], args.sample)
report = {
"chunks_scanned": data["chunks"],
"ore_blocks": {k: v for k, v in data["ores"].items() if ORE.search(k) or k in VEIN_BLOCKS},
+30 -1
View File
@@ -12,11 +12,15 @@ world/zones.json → "ores" lists the tiers. This tool writes pack/config/regene
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.
generates, and the tests place it by command. Each tier with "showcase" also gets "<tier>_showcase", a full
radius-2 vein in the same place-only area, which tools/make_world.py places by hand near the zone's sample point.
* 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.
It also writes world/vein-sources.json, the veins as item sources for the field guide (tools/fieldguide/build.py
reads it by default): what each tier's blocks drop and in which zone.
Re-run after changing the zone plan or the grid (tools/zone_layout.py) and commit the result.
Usage: tools/zone_ores.py
@@ -30,6 +34,17 @@ OUT = ROOT / "pack/config/regenerating_ore_veins"
LAYOUT = ROOT / "build/zone-layout.json"
BOTTOM, TOP = -64, 320
SELFTEST_AREA = "selftest_nowhere"
SOURCES = ROOT / "world/vein-sources.json"
# What an ore block drops, where it isn't the block itself or "<name>_ore" → "<name>".
DROPS = {"iron": "raw_iron", "copper": "raw_copper", "gold": "raw_gold", "lapis": "lapis_lazuli",
"nether_quartz": "quartz", "amethyst_block": "amethyst_block", "budding_amethyst": "amethyst_shard"}
def drop_of(block):
namespace, name = block.split(":")
base = name.removeprefix("deepslate_").removeprefix("dripstone_").removeprefix("terracotta_")
base = base.removesuffix("_ore") if base.endswith("_ore") else base
return f"{namespace}:{DROPS.get(base, base)}"
def rectangles(mask, size):
@@ -123,6 +138,10 @@ def main():
else:
sys.exit(f"ore tier {tier}: unknown zone {zone}")
low, high = spec["y"]
if spec.get("showcase"):
blocks = [(spec["stone"], 1)] if spec.get("stone") else [tuple(b) for b in spec["blocks"]]
showcase = vein(f"{tier}_showcase", blocks, spec, defaults, (0, 0), {"area_whitelist": [SELFTEST_AREA]})
veins.append({**showcase, "min_radius": 2, "max_radius": 2, "fill_factor": 0.8})
if "blocks" in spec:
veins.append(vein(tier, spec["blocks"], spec, defaults, (low, high), where))
continue
@@ -158,6 +177,16 @@ def main():
"durability": 2,
"stack_size": 64,
}, indent=2) + "\n")
names = {z["id"]: z["name"] for z in plan["zones"] + plan["caves"]}
sources = []
for tier, spec in plan["ores"].items():
blocks = [b for b, _ in spec.get("blocks", [])] + [spec[k] for k in ("stone", "deepslate") if spec.get(k)]
for item in dict.fromkeys(drop_of(b) for b in blocks):
sources.append({"item": item, "how": f"Regenerating {tier.replace('_', ' ')} vein in {names[spec['zone']]}",
"kind": "ore vein", "needs": []})
SOURCES.write_text(json.dumps({
"_comment": "Generated by tools/zone_ores.py from world/zones.json; extra item sources for the field guide.",
"sources": sources}, indent=1) + "\n")
per_zone = {}
for tier, spec in plan["ores"].items():
per_zone.setdefault(spec["zone"], []).append(tier)