Free NeoForge 1.21.1 modpack on Cobblemon 1.8.4 built with packwiz, plus the Kotlin companion mod (cobblesurvival) and its built-in data pack. - Phase 0: packwiz pack with Modrinth/CurseForge cross-links, build and export tooling, Prism instance sync, test server with smoke checks. - Phase 1: Poke Ball shaped island (WorldGen Editor preset, seed isle-26), Adrian's biome list, no natural ore, structures without ore or vanilla mobs, no Nether portals, world template builder and world report. - Phase 2: permadeath (CobbleHardcoreMon plus the companion mod's gap fixes), Revival Charm, expensive Healing Machine, Lootr refreshing loot, in-game self-tests on Minecraft's GameTest framework. - Phase 3 so far: 14 zones with their own spawn pools and level bands (tools/zone_layout.py, tools/zone_spawns.py, "zone" spawn condition), Cold Sweat heat and cold as gates with boss-drop insulation recipes, spawn survey and hazard self-tests in tools/quick_check.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
165 lines
7.4 KiB
Python
165 lines
7.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Compare world seeds by their island's biomes, without generating chunks.
|
|
|
|
Boots the test server once (world "seed-search"), runs
|
|
`/cobblesurvival biomepreview <seed> <radius> <step>` for every candidate, and
|
|
scores each island:
|
|
|
|
* coverage: every surface biome from world/biomes.json reaches its target
|
|
share of the land (TARGETS below);
|
|
* patches: few, large patches per biome instead of many crumbs;
|
|
* balance: no biome takes more than MAX_SHARE of the land;
|
|
* start: the land around 0,0 is gentle (plains, forest, cherry grove);
|
|
* caves: lush caves, dripstone caves, deep dark and basalt deltas all occur
|
|
under land.
|
|
|
|
Writes build/seed-search/report.json and candidates.png (best seeds side by
|
|
side). The coastline in the preview is approximate (see BiomePreview.kt).
|
|
|
|
Usage: tools/seed_search.py [--count N] [--prefix P] [--apply]
|
|
--apply writes the best seed into world/world.json
|
|
"""
|
|
import argparse
|
|
import json
|
|
import shutil
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from mcserver import BUILD, ROOT, SERVER, Server, eula_accepted, prepare # noqa: E402
|
|
from world_report import colour # noqa: E402
|
|
|
|
LEVEL = "seed-search"
|
|
OUT = BUILD / "seed-search"
|
|
RADIUS, STEP = 1700, 24
|
|
SURFACE = ["minecraft:plains", "minecraft:forest", "minecraft:cherry_grove", "minecraft:mangrove_swamp",
|
|
"minecraft:jungle", "minecraft:desert", "minecraft:badlands", "minecraft:windswept_hills",
|
|
"minecraft:jagged_peaks"]
|
|
TARGETS = {b: 0.05 for b in SURFACE} | {"minecraft:cherry_grove": 0.03, "minecraft:mangrove_swamp": 0.03,
|
|
"minecraft:jagged_peaks": 0.03, "minecraft:windswept_hills": 0.03}
|
|
CAVES = ["minecraft:lush_caves", "minecraft:dripstone_caves", "minecraft:deep_dark", "minecraft:basalt_deltas"]
|
|
GENTLE = {"minecraft:plains", "minecraft:forest", "minecraft:cherry_grove"}
|
|
MAX_SHARE = 0.35
|
|
START_RADIUS = 350
|
|
|
|
|
|
def patches(mask):
|
|
"""Number of 4-connected patches of at least 3 cells."""
|
|
seen = np.zeros_like(mask)
|
|
count = 0
|
|
for start in zip(*np.nonzero(mask)):
|
|
if seen[start]:
|
|
continue
|
|
seen[start] = True
|
|
stack, size = [start], 0
|
|
while stack:
|
|
z, x = stack.pop()
|
|
size += 1
|
|
for nz, nx in ((z + 1, x), (z - 1, x), (z, x + 1), (z, x - 1)):
|
|
if 0 <= nz < mask.shape[0] and 0 <= nx < mask.shape[1] and mask[nz, nx] and not seen[nz, nx]:
|
|
seen[nz, nx] = True
|
|
stack.append((nz, nx))
|
|
count += size >= 3
|
|
return count
|
|
|
|
|
|
def score(preview):
|
|
n = preview["size"]
|
|
names = preview["biomes"]
|
|
surface = np.array(preview["surface"]).reshape(n, n)
|
|
land = np.array(preview["height"]).reshape(n, n) >= preview["sea_level"]
|
|
total = land.sum() or 1
|
|
shares, pieces = {}, {}
|
|
for b in SURFACE:
|
|
mask = land & (surface == names.index(b)) if b in names else np.zeros_like(land)
|
|
shares[b] = float(mask.sum() / total)
|
|
pieces[b] = patches(mask)
|
|
coverage = sum(min(shares[b] / TARGETS[b], 1.0) for b in SURFACE)
|
|
fragmentation = sum(max(p - 1, 0) for p in pieces.values()) / len(SURFACE)
|
|
balance = max(0.0, max(shares.values()) - MAX_SHARE) * 10
|
|
coords = (np.arange(n) * preview["step"] - preview["radius"])
|
|
near = (np.hypot(*np.meshgrid(coords, coords)) < START_RADIUS) & land
|
|
gentle_ids = [names.index(b) for b in GENTLE if b in names]
|
|
start = float(np.isin(surface[near], gentle_ids).mean()) if near.any() else 0.0
|
|
caves = {b: 0 for b in CAVES}
|
|
for grid in preview["caves"].values():
|
|
layer = np.array(grid).reshape(n, n)[land]
|
|
for b in CAVES:
|
|
if b in names:
|
|
caves[b] += int((layer == names.index(b)).sum())
|
|
complete = all(shares[b] >= 0.01 for b in SURFACE) and all(caves.values())
|
|
total_score = coverage + 2 * start - 0.15 * fragmentation - balance + (5 if complete else 0)
|
|
return {"seed": preview["seed"], "score": round(total_score, 3), "complete": complete,
|
|
"land_cells": int(total), "start_gentle": round(start, 2),
|
|
"shares": {b.split(":")[1]: round(v, 3) for b, v in shares.items()},
|
|
"patches": {b.split(":")[1]: v for b, v in pieces.items()},
|
|
"cave_cells": {b.split(":")[1]: v for b, v in caves.items()}}
|
|
|
|
|
|
def tile(preview, label, size=300):
|
|
from PIL import Image, ImageDraw
|
|
n = preview["size"]
|
|
surface = np.array(preview["surface"]).reshape(n, n)
|
|
height = np.array(preview["height"]).reshape(n, n)
|
|
lut = np.array([colour(b) for b in preview["biomes"]], dtype=np.uint8)
|
|
rgb = lut[surface]
|
|
rgb[height < preview["sea_level"]] = (20, 50, 120)
|
|
img = Image.fromarray(rgb).resize((size, size), Image.NEAREST)
|
|
ImageDraw.Draw(img).text((6, 6), label, fill=(255, 255, 255), stroke_width=2, stroke_fill=(0, 0, 0))
|
|
return img
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--count", type=int, default=60)
|
|
parser.add_argument("--prefix", default="isle")
|
|
parser.add_argument("--apply", action="store_true")
|
|
args = parser.parse_args()
|
|
if not eula_accepted():
|
|
sys.exit("build/server/eula.txt does not accept the Minecraft EULA yet; run tools/smoke_test.py --accept-eula first.")
|
|
|
|
recipe = json.loads((ROOT / "world/world.json").read_text())
|
|
seeds = [recipe["seed"]] + [f"{args.prefix}-{i}" for i in range(1, args.count + 1)]
|
|
args_file = prepare()
|
|
shutil.rmtree(SERVER / LEVEL, ignore_errors=True)
|
|
shutil.rmtree(OUT, ignore_errors=True)
|
|
OUT.mkdir(parents=True)
|
|
results, previews = [], {}
|
|
props = {"level-name": LEVEL, "level-seed": recipe["seed"], "level-type": recipe["level_type"]}
|
|
with Server(args_file, props, stdout_name="seed-search-stdout.log") as server:
|
|
server.wait_ready()
|
|
for i, seed in enumerate(seeds, 1):
|
|
started = time.time()
|
|
reply = server.command(f"cobblesurvival biomepreview {seed} {RADIUS} {STEP}")
|
|
if "written" not in reply:
|
|
raise RuntimeError(f"preview failed for {seed}: {reply}")
|
|
preview = json.loads((SERVER / LEVEL / "cobblesurvival/biome_preview" / f"{seed}.json").read_text())
|
|
previews[seed] = preview
|
|
results.append(score(preview))
|
|
r = results[-1]
|
|
print(f"[{i}/{len(seeds)}] {seed:16} score {r['score']:6.2f} complete={r['complete']!s:5} "
|
|
f"start={r['start_gentle']:.2f} ({time.time() - started:.1f}s)")
|
|
server.stop()
|
|
|
|
results.sort(key=lambda r: -r["score"])
|
|
(OUT / "report.json").write_text(json.dumps(results, indent=2) + "\n")
|
|
from PIL import Image
|
|
best = results[:8]
|
|
sheet = Image.new("RGB", (4 * 300, 2 * 300), (24, 24, 24))
|
|
for k, r in enumerate(best):
|
|
sheet.paste(tile(previews[r["seed"]], f"#{k + 1} {r['seed']} {r['score']}"), ((k % 4) * 300, (k // 4) * 300))
|
|
sheet.save(OUT / "candidates.png")
|
|
print(f"\nbest: {best[0]['seed']} (score {best[0]['score']}); report: {(OUT / 'report.json').relative_to(ROOT)}")
|
|
print(json.dumps(best[0], indent=2))
|
|
if args.apply:
|
|
recipe["seed"] = best[0]["seed"]
|
|
(ROOT / "world/world.json").write_text(json.dumps(recipe, indent=2) + "\n")
|
|
print(f"world/world.json seed set to {best[0]['seed']}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|