Cobblemon Survival Pack: phases 0-2 and Phase 3 zones and hazards
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>
This commit is contained in:
commit
398301f0be
232 files changed
+72665
No files matched your search
@@ -0,0 +1,387 @@
|
||||
"""FTB Quests "Field Guide" chapters from fieldguide.json (reference only, nothing locks).
|
||||
|
||||
Format of FTB Quests 2101.1.37 (read from its source, BaseQuestFile/Chapter/
|
||||
Quest/TranslationManager): quests live in config/ftbquests/quests/; each
|
||||
chapter is chapters/<filename>.snbt with 16-hex-digit string ids; titles,
|
||||
subtitles and descriptions are not in the chapter file but in
|
||||
lang/<locale>.snbt under keys "<type>.<ID>.title", "quest.<ID>.quest_subtitle",
|
||||
"quest.<ID>.quest_desc" (a list of lines) and "chapter.<ID>.chapter_subtitle".
|
||||
Icons are single-item stacks {id, components}; a Pokémon icon is
|
||||
cobblemon:pokemon_model with the cobblemon:pokemon_item component
|
||||
{species, aspects} (Cobblemon 1.8.4 PokemonItemComponent).
|
||||
|
||||
Ids are derived from names (sha256), so they stay the same between runs and
|
||||
Phase 8's generator can place its own chapters next to these.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
from .world import RANK
|
||||
|
||||
CHAPTERS = ("field_guide_wild", "field_guide_other", "field_guide_items")
|
||||
BIOME_ICONS = {
|
||||
"minecraft:plains": "minecraft:short_grass", "minecraft:forest": "minecraft:oak_sapling",
|
||||
"minecraft:cherry_grove": "minecraft:cherry_sapling", "minecraft:mangrove_swamp": "minecraft:mangrove_propagule",
|
||||
"minecraft:jungle": "minecraft:jungle_sapling", "minecraft:desert": "minecraft:cactus",
|
||||
"minecraft:badlands": "minecraft:red_sand", "minecraft:windswept_hills": "minecraft:gravel",
|
||||
"minecraft:jagged_peaks": "minecraft:snow_block", "minecraft:lush_caves": "minecraft:moss_block",
|
||||
"minecraft:dripstone_caves": "minecraft:pointed_dripstone", "minecraft:deep_dark": "minecraft:sculk",
|
||||
"minecraft:basalt_deltas": "minecraft:basalt", "minecraft:ocean": "minecraft:kelp",
|
||||
"minecraft:deep_ocean": "minecraft:prismarine", "minecraft:lukewarm_ocean": "minecraft:seagrass",
|
||||
"minecraft:deep_lukewarm_ocean": "minecraft:sea_pickle", "minecraft:warm_ocean": "minecraft:brain_coral",
|
||||
"minecraft:river": "minecraft:water_bucket", "minecraft:beach": "minecraft:sand",
|
||||
}
|
||||
COLUMNS = 14
|
||||
|
||||
|
||||
def stable_id(name):
|
||||
"""64-bit id from a name. FTB parses ids with Long.parseLong(hex, 16), so the top bit must be clear."""
|
||||
v = int(hashlib.sha256(("cobblesurvival-fieldguide/" + name).encode()).hexdigest()[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF
|
||||
return f"{max(v, 2):016X}"
|
||||
|
||||
|
||||
# --- SNBT in FTB Library's style (tabs, newline-separated, sorted keys) -------------------------
|
||||
|
||||
def _key(k):
|
||||
return k if all(c.isalnum() or c in "._-+" for c in k) else _str(k)
|
||||
|
||||
|
||||
def _str(s):
|
||||
out = s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\t", "\\t").replace("\r", "")
|
||||
return f'"{out}"'
|
||||
|
||||
|
||||
def snbt(value, indent=0):
|
||||
tab = "\t" * indent
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
if isinstance(value, float):
|
||||
return f"{value:g}d" if "e" not in f"{value:g}" else f"{value:.6f}d"
|
||||
if isinstance(value, str):
|
||||
return _str(value)
|
||||
if isinstance(value, list):
|
||||
if not value:
|
||||
return "[ ]"
|
||||
return "[\n" + "\n".join(tab + "\t" + snbt(v, indent + 1) for v in value) + "\n" + tab + "]"
|
||||
if isinstance(value, dict):
|
||||
if not value:
|
||||
return "{ }"
|
||||
return "{\n" + "\n".join(f"{tab}\t{_key(k)}: {snbt(v, indent + 1)}" for k, v in sorted(value.items())) + "\n" + tab + "}"
|
||||
raise TypeError(type(value))
|
||||
|
||||
|
||||
def parse_snbt(text):
|
||||
"""Read SNBT the way FTB Library 2101's SNBTParser does (used to check our own output).
|
||||
|
||||
Lines whose trimmed text starts with // or # are comments; entries are separated by commas
|
||||
or newlines; numbers carry b/s/l/f/d suffixes; true/false are bytes.
|
||||
"""
|
||||
lines = [ln for ln in text.split("\n") if not ln.strip().startswith(("//", "#"))]
|
||||
src, pos = "\n".join(lines), 0
|
||||
simple = lambda c: c.isalnum() or c in "._-+\u221e" # noqa: E731
|
||||
|
||||
def ws():
|
||||
nonlocal pos
|
||||
while pos < len(src) and src[pos] in " \t\r\n,":
|
||||
pos += 1
|
||||
|
||||
def quoted():
|
||||
nonlocal pos
|
||||
pos += 1
|
||||
out = []
|
||||
esc = {"n": "\n", "t": "\t", "b": "\b", "r": "\r", "f": "\f", '"': '"', "\\": "\\"}
|
||||
while True:
|
||||
c = src[pos]
|
||||
pos += 1
|
||||
if c == "\n":
|
||||
raise ValueError(f"newline inside a string at {pos}")
|
||||
if c == "\\":
|
||||
out.append(esc.get(src[pos], ""))
|
||||
pos += 1
|
||||
elif c == '"':
|
||||
return "".join(out)
|
||||
else:
|
||||
out.append(c)
|
||||
|
||||
def word():
|
||||
nonlocal pos
|
||||
start = pos
|
||||
while pos < len(src) and simple(src[pos]):
|
||||
pos += 1
|
||||
return src[start:pos]
|
||||
|
||||
def value():
|
||||
nonlocal pos
|
||||
ws()
|
||||
c = src[pos]
|
||||
if c == "{":
|
||||
pos += 1
|
||||
out = {}
|
||||
while True:
|
||||
ws()
|
||||
if src[pos] == "}":
|
||||
pos += 1
|
||||
return out
|
||||
key = quoted() if src[pos] == '"' else word()
|
||||
ws()
|
||||
if src[pos] != ":":
|
||||
raise ValueError(f"expected ':' after {key!r} at {pos}")
|
||||
pos += 1
|
||||
out[key] = value()
|
||||
if c == "[":
|
||||
pos += 1
|
||||
out = []
|
||||
while True:
|
||||
ws()
|
||||
if src[pos] == "]":
|
||||
pos += 1
|
||||
if len({type(v) for v in out}) > 1:
|
||||
raise ValueError("mixed list")
|
||||
return out
|
||||
out.append(value())
|
||||
if c == '"':
|
||||
return quoted()
|
||||
w = word()
|
||||
if w in ("true", "false"):
|
||||
return w == "true"
|
||||
if w[-1:] in "dD" and _num(w[:-1]):
|
||||
return float(w[:-1])
|
||||
if _num(w):
|
||||
return int(w) if w.lstrip("-").isdigit() else float(w)
|
||||
return w
|
||||
|
||||
result = value()
|
||||
ws()
|
||||
if pos != len(src):
|
||||
raise ValueError(f"trailing data at {pos}")
|
||||
return result
|
||||
|
||||
|
||||
def _num(s):
|
||||
try:
|
||||
float(s)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
# --- chapter building --------------------------------------------------------------------------------
|
||||
|
||||
class Builder:
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
self.lang = {}
|
||||
self.chapters = []
|
||||
|
||||
def chapter(self, filename, title, subtitle, icon, order):
|
||||
cid = stable_id("chapter/" + filename)
|
||||
self.lang[f"chapter.{cid}.title"] = title
|
||||
self.lang[f"chapter.{cid}.chapter_subtitle"] = subtitle
|
||||
ch = {"id": cid, "filename": filename, "group": "", "order_index": order, "icon": icon,
|
||||
"default_quest_shape": "", "default_hide_dependency_lines": False,
|
||||
"quests": [], "quest_links": [], "images": []}
|
||||
self.chapters.append(ch)
|
||||
return ch
|
||||
|
||||
def quest(self, ch, name, x, y, title, icon, subtitle=None, desc=(), shape=None, size=None):
|
||||
qid = stable_id(f"{ch['filename']}/{name}")
|
||||
q = {"id": qid, "x": float(x), "y": float(y), "icon": icon,
|
||||
"tasks": [{"id": stable_id(f"{ch['filename']}/{name}/task"), "type": "checkmark"}]}
|
||||
if shape:
|
||||
q["shape"] = shape
|
||||
if size:
|
||||
q["size"] = float(size)
|
||||
self.lang[f"quest.{qid}.title"] = title
|
||||
if subtitle:
|
||||
self.lang[f"quest.{qid}.quest_subtitle"] = subtitle
|
||||
if desc:
|
||||
self.lang[f"quest.{qid}.quest_desc"] = [line for line in desc if not line.startswith("{")]
|
||||
ch["quests"].append(q)
|
||||
return q
|
||||
|
||||
|
||||
def item_icon(iid):
|
||||
return {"id": iid}
|
||||
|
||||
|
||||
def pokemon_icon(sid):
|
||||
return {"id": "cobblemon:pokemon_model", "components": {"cobblemon:pokemon_item": {"species": sid, "aspects": []}}}
|
||||
|
||||
|
||||
def species_lines(data, sid, limit=10):
|
||||
sp = data["species"][sid]
|
||||
lines = [f"#{sp['dex']} · " + " / ".join(t.capitalize() for t in sp["types"])]
|
||||
if sp["locations"]:
|
||||
lines.append("Found in:")
|
||||
for loc in sp["locations"][:limit]:
|
||||
if loc.get("special"):
|
||||
lines.append(f"- {loc['name']}: only near particular blocks (e.g. buildings)")
|
||||
continue
|
||||
zones = f" ({', '.join(loc['zones'])})" if loc.get("zones") else ""
|
||||
lines.append(f"- {loc['name']}{zones}: Lv {loc['level'][0]}-{loc['level'][1]}, "
|
||||
+ ", ".join("alpha" if b == "boss" else b for b in loc["buckets"]))
|
||||
if len(sp["locations"]) > limit:
|
||||
lines.append(f"- and {len(sp['locations']) - limit} more places")
|
||||
best = sorted((data["spawns"][i] for i in sp["spawns"] if data["spawns"][i]["status"] != "unavailable"),
|
||||
key=lambda s: -(s["weight"] or 0))[:2]
|
||||
conds = {" · ".join(c["text"]) for s in best for c in s["conditions"] if c["text"] and c["biomes"]}
|
||||
for c in sorted(conds)[:2]:
|
||||
lines.append(f"Usually: {c}")
|
||||
other = [s for s in sp["sources"] if s["status"] != "unavailable"]
|
||||
if other:
|
||||
lines.append("Also:" if sp["locations"] else "Not found in the wild here. Get it by:")
|
||||
lines += [f"- {s['text']}" for s in other[:4]]
|
||||
for ev in sp["evolutions"][:4]:
|
||||
lines.append(f"Evolves into {ev['name']}" + (f" ({', '.join(ev['how'])})" if ev["how"] else ""))
|
||||
if sp["drops"]:
|
||||
names = [f"{_item_name(data, d['item'])} {d['chance']}" for d in sp["drops"][:5]]
|
||||
lines.append("Drops: " + ", ".join(names))
|
||||
return [ln for ln in lines if ln is not None]
|
||||
|
||||
|
||||
def _item_name(data, iid):
|
||||
return (data["items"].get(iid) or data["vanilla"].get(iid) or {}).get("name") or iid.split(":")[1].replace("_", " ")
|
||||
|
||||
|
||||
def primary_location(data, sid):
|
||||
"""Location where the species is easiest to meet: most common bucket, then highest weight."""
|
||||
best, key = None, None
|
||||
order = {"common": 0, "uncommon": 1, "rare": 2, "ultra-rare": 3, "boss": 4}
|
||||
for i in data["species"][sid]["spawns"]:
|
||||
s = data["spawns"][i]
|
||||
if s["status"] != "available":
|
||||
continue
|
||||
for b in s["biomes"]:
|
||||
k = (s.get("needs_blocks", False), order.get(s["bucket"], 9), -(s["weight"] or 0))
|
||||
if key is None or k < key:
|
||||
best, key = b, k
|
||||
return best
|
||||
|
||||
|
||||
def build(data):
|
||||
b = Builder(data)
|
||||
species = data["species"]
|
||||
wild = b.chapter("field_guide_wild", "Field Guide: Wild Pokémon",
|
||||
["Where each Pokémon lives on this island. Reference only: nothing here locks anything.",
|
||||
"Generated from the pack's spawn data by tools/fieldguide."], item_icon("cobblemon:poke_ball"), 900)
|
||||
by_loc = {}
|
||||
for sid, sp in species.items():
|
||||
if sp["wild"] == "available":
|
||||
loc = primary_location(data, sid)
|
||||
if loc:
|
||||
by_loc.setdefault(loc, []).append(sid)
|
||||
y = 0.0
|
||||
for loc in data["locations"]:
|
||||
if loc["kind"] == "end":
|
||||
continue
|
||||
mons = sorted(by_loc.get(loc["id"], []), key=lambda s: species[s]["dex"])
|
||||
everyone = sorted({m["species"] for i in loc["spawns"] for m in data["spawns"][i]["members"]},
|
||||
key=lambda s: species.get(s, {}).get("dex", 0))
|
||||
desc = [f"{len(everyone)} Pokémon can appear here. Each one's own entry lists all its places.", ""]
|
||||
desc += [", ".join(species[s]["name"] for s in everyone if s in species)]
|
||||
b.quest(wild, "loc/" + loc["id"], 0, y, loc["name"], item_icon(BIOME_ICONS.get(loc["id"], "minecraft:grass_block")),
|
||||
subtitle=f"{len(everyone)} Pokémon", desc=desc, shape="square", size=1.5)
|
||||
for n, sid in enumerate(mons):
|
||||
b.quest(wild, "mon/" + sid, 2 + (n % COLUMNS), y + (n // COLUMNS), species[sid]["name"], pokemon_icon(sid),
|
||||
desc=species_lines(data, sid), shape="circle")
|
||||
y += max(1, (len(mons) + COLUMNS - 1) // COLUMNS) + 1.5
|
||||
|
||||
other = b.chapter("field_guide_other", "Field Guide: Other Pokémon",
|
||||
["Pokémon that do not spawn in the wild on this island, and how to get them anyway."],
|
||||
item_icon("cobblemon:old_amber_fossil"), 901)
|
||||
obtainable = sorted((s for s, sp in species.items() if sp["wild"] != "available" and sp["status"] != "unavailable"),
|
||||
key=lambda s: species[s]["dex"])
|
||||
for n, sid in enumerate(obtainable):
|
||||
b.quest(other, "mon/" + sid, n % COLUMNS, n // COLUMNS, species[sid]["name"], pokemon_icon(sid),
|
||||
desc=species_lines(data, sid), shape="circle")
|
||||
none = sorted((s for s, sp in species.items() if sp["status"] == "unavailable" and sp["implemented"]),
|
||||
key=lambda s: species[s]["dex"])
|
||||
rows = (len(obtainable) + COLUMNS - 1) // COLUMNS + 1
|
||||
chunks = [none[i:i + 40] for i in range(0, len(none), 40)]
|
||||
for n, chunk in enumerate(chunks):
|
||||
b.quest(other, f"none/{n}", n * 2, rows + 0.5, "Not obtainable here" + (f" ({n + 1})" if len(chunks) > 1 else ""),
|
||||
item_icon("minecraft:barrier"), subtitle=f"{len(chunk)} species",
|
||||
desc=["These have no wild spawn, evolution, fossil or starter route on this island:", ""]
|
||||
+ [", ".join(species[s]["name"] for s in chunk)], shape="square")
|
||||
|
||||
items = b.chapter("field_guide_items", "Field Guide: Items",
|
||||
["How to get each Cobblemon item on this island and what it is for.",
|
||||
"Red text: no source on the island."], item_icon("cobblemon:water_stone"), 902)
|
||||
cats = {}
|
||||
for iid, it in data["items"].items():
|
||||
if not it["technical"] and (it["sources"] or it["uses"]):
|
||||
cats.setdefault(it["category"], []).append(iid)
|
||||
y = 0.0
|
||||
for cat in sorted(cats):
|
||||
ids = sorted(cats[cat], key=lambda i: data["items"][i]["name"])
|
||||
for n, iid in enumerate(ids):
|
||||
it = data["items"][iid]
|
||||
lines = list(it["tooltip"][:2])
|
||||
if it["status"] == "unavailable":
|
||||
lines.append("&cNo source on this island.")
|
||||
elif it["status"] == "conditional":
|
||||
lines.append("&6Only under a condition (e.g. the End).")
|
||||
srcs = [s for s in it["sources"] if RANK[s["status"]]] or it["sources"]
|
||||
if srcs:
|
||||
lines += ["", "How to get it:"] + [f"- {s['text']}" + (f" ({s['chance']})" if s.get("chance") else "")
|
||||
for s in srcs[:6]]
|
||||
if len(srcs) > 6:
|
||||
lines.append(f"- and {len(srcs) - 6} more")
|
||||
if it["uses"]:
|
||||
lines += ["", "Used for:"] + [f"- {u['text']}" for u in it["uses"][:6]]
|
||||
if len(it["uses"]) > 6:
|
||||
lines.append(f"- and {len(it['uses']) - 6} more")
|
||||
b.quest(items, "item/" + iid, n % COLUMNS, y + n // COLUMNS, it["name"], item_icon(iid),
|
||||
subtitle=cat, desc=lines, shape="hexagon" if it["status"] == "available" else "octagon")
|
||||
y += (len(ids) + COLUMNS - 1) // COLUMNS + 1
|
||||
return b
|
||||
|
||||
|
||||
README = """# Field Guide chapters for FTB Quests (generated, not installed)
|
||||
|
||||
Made by `python3 tools/fieldguide/build.py` from `build/field-guide/fieldguide.json`.
|
||||
Format: FTB Quests {version} for NeoForge 1.21.1, read from its source.
|
||||
|
||||
Contents (paths relative to the game or server folder):
|
||||
|
||||
config/ftbquests/quests/chapters/field_guide_wild.snbt wild Pokémon by location
|
||||
config/ftbquests/quests/chapters/field_guide_other.snbt Pokémon you get another way
|
||||
config/ftbquests/quests/chapters/field_guide_items.snbt Cobblemon items: how to get, what for
|
||||
config/ftbquests/quests/lang/en_us.snbt titles and descriptions (only these chapters' keys)
|
||||
|
||||
All entries are reference quests with one checkmark task, no dependencies and no rewards, so nothing locks.
|
||||
Ids are hashes of names and stay the same between runs.
|
||||
|
||||
## Installing (your decision)
|
||||
|
||||
1. Stop the game or server (FTB Quests rewrites these files when it saves).
|
||||
2. Copy the three chapter files into `config/ftbquests/quests/chapters/`.
|
||||
3. Titles and descriptions: if `config/ftbquests/quests/lang/en_us.snbt` does not exist yet, copy ours.
|
||||
If it exists (for example after Phase 8), merge: put every line of ours inside the existing top-level
|
||||
`{{ }}`. Keys never collide with other chapters' keys because they use these chapters' ids.
|
||||
4. For the pack: the same files go under `pack/config/ftbquests/quests/` and are refreshed with packwiz.
|
||||
5. Start the game and open the quest book. `/ftbquests reload` re-reads the files on a running server.
|
||||
|
||||
When regenerating, replace the three chapter files and our keys in the lang file; FTB Quests keeps
|
||||
manual edits (positions, extra quests) only in its own saved copy, so regenerate before editing by hand.
|
||||
"""
|
||||
|
||||
|
||||
def write(data, out, ftb_version="2101.1.37"):
|
||||
b = build(data)
|
||||
base = out / "ftbquests"
|
||||
qdir = base / "config/ftbquests/quests"
|
||||
(qdir / "chapters").mkdir(parents=True, exist_ok=True)
|
||||
(qdir / "lang").mkdir(parents=True, exist_ok=True)
|
||||
for ch in b.chapters:
|
||||
text = snbt(ch) + "\n"
|
||||
if parse_snbt(text) != ch: # round trip through an FTB-style reader
|
||||
raise SystemExit(f"SNBT round trip failed for {ch['filename']}")
|
||||
(qdir / "chapters" / f"{ch['filename']}.snbt").write_text(text)
|
||||
text = snbt(b.lang) + "\n"
|
||||
if parse_snbt(text) != b.lang:
|
||||
raise SystemExit("SNBT round trip failed for lang/en_us.snbt")
|
||||
(qdir / "lang/en_us.snbt").write_text(text)
|
||||
(base / "README.md").write_text(README.format(version=ftb_version))
|
||||
return {ch["filename"]: len(ch["quests"]) for ch in b.chapters}
|
||||
Reference in new issue
Block a user