Files
cobblemon-survival-pack/tools/jei_sources.py
T
claudeandClaude Opus 5.5 4083791944 Phase 9: reproducible builds, balance from the server, spark check, play feedback
Build: every tool pinned (JDK, packwiz, Gradle plugins, Python packages), exports
normalised, tools/check_reproducible.py proves two clean builds are byte-identical.

Server-side balance: a server can load config/cobblesurvival/datapack.zip in place of
the jar's data, /reload applies it, tools/server_update.py packages it and refuses
when a client change is needed. JEI drop pages are sent by the server. Code-held
balance numbers moved to tuning/server.json.

Performance: spark in the pack (server only), tools/perf_test.py with fake players
whose chunks really are active, docs/performance.md.

Adrian's feedback: default keys R, U, J, §; drop pages link to Cobblemon Info and show
real chances; bosses only hurt in battles; battle XP halved; channels between the
islands low level; temperature ×3 with insulation doubled; hazards start at once; no
item dots on the map; a wild attack starts a battle; the starter only faints; quest
book on join with a basics page and an HMs page; regional forms obtainable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 16:26:30 +01:00

251 lines
12 KiB
Python

#!/usr/bin/env python3
"""Write the data behind the pack's own JEI pages (roadmap Phase 8; Adrian, 2026-11-02: every item that can drop gets
a JEI page naming its sources; 2026-11-03: alongside the Cobblemon Info add-on, which shows spawns and drops but not
the island's zones).
JEI runs on the client, which on a dedicated server doesn't have the pack's server data, so the server sends each player
this file (dev.cobblesurvival.jei.DropSources): datapacks/core/data/cobblesurvival/drop_sources/pages.json. It holds:
pokemon every species of a line that lives on the island: its drops (as the Drop Synthesizer rolls them)
and the zones it spawns in, or the zones where its line lives
(legendaries: where a Sigil summons them, world/summons.json; that line's evolutions too)
summons each Sigil: species, name, zone, level, so JEI can list it as an item of its own
spawn_drops zone spawns that drop something else than their species (e.g. Beheeyem in the End)
guardians each zone guardian: species, level, drops, return time
end_boss the End boss and what beating it gives
altar the Sacrifice Altar's level tiers and type specials; rebirth: the Rebirth Chamber's extra
ore_veins per zone, what its regenerating veins give and how fast they regrow
pasture_finds per Cobblebase skill and zone, what a pasture's Pokémon can find (the pack's zone-bound tables)
The self-test drop_sources_match_the_server compares it with the live server, so run this after changing species
drops, spawns, guardians, the End boss, the altar, ore veins or Cobblebase's tables (tools/build.py and
tools/server_update.py run it).
Usage: tools/jei_sources.py
"""
import json
import re
import zipfile
from collections import defaultdict
from pathlib import Path
import lock
ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / "datapacks/core/data"
OUT = DATA / "cobblesurvival/drop_sources/pages.json"
SKILLS = {"mining": "mining", "finder_ore": "ore_finder", "finder_evo": "evolution_item_finder"}
def amount_range(drops):
a, _, b = str(drops.get("amount", 1)).partition("-")
return range(int(a), int(b or a) + 1)
def yield_chance(entry):
"""The chance a picked entry gives at least one item: a count of 0 gives nothing."""
low, _, high = str(entry.get("quantityRange", entry.get("quantity", 1))).partition("-")
low, high = int(low), int(high or low)
return 1.0 if low > 0 else high / (high + 1)
def selection_odds(entries, chosen):
"""{item: the chance one drop roll for [chosen] picks gives at least one}, as DropTable.getDrops works: in table
order, the first entry that passes its percentage is picked; a round with none uses up a pick. A table can list
one item twice (Snorlax's apples), so this follows items, not entries."""
percent = [float(e.get("percentage", 100)) / 100 for e in entries]
quantity = [int(e.get("quantity", 1)) for e in entries]
limit = [int(e.get("maxSelectableTimes", 1)) for e in entries]
start = (tuple(i for i in range(len(entries)) if quantity[i] <= chosen), 0, tuple(0 for _ in entries), frozenset())
states, odds = {start: 1.0}, defaultdict(float)
while states:
following = defaultdict(float)
def advance(possible, count, times, have, p):
if count < chosen and possible:
following[(possible, count, times, have)] += p
for (possible, count, times, have), p in states.items():
miss = 1.0
for i in possible:
hit = miss * percent[i]
miss *= 1 - percent[i]
if hit <= 0:
continue
now = count + quantity[i]
picked = times[:i] + (times[i] + 1,) + times[i + 1:]
left = tuple(j for j in possible if not (j == i and picked[j] >= limit[j]) and quantity[j] <= chosen - now)
item, gives = entries[i].get("item"), yield_chance(entries[i])
if item is None or item in have:
advance(left, now, picked, have, p * hit)
continue
odds[item] += p * hit * gives
advance(left, now, picked, have | {item}, p * hit * gives)
if gives < 1:
advance(left, now, picked, have, p * hit * (1 - gives))
if miss > 0:
advance(possible, count + 1, times, have, p * miss)
states = following
return odds
def drop_entries(drops):
"""Cobblemon drop table → [{item, chance (percent that a roll gives at least one, absent = always), count ("a-b")}]."""
drops = drops or {}
entries = drops.get("entries", [])
picks = amount_range(drops)
odds = defaultdict(float)
if entries:
for chosen in picks:
for item, chance in selection_odds(entries, chosen).items():
odds[item] += chance / len(picks)
counts = {}
for e in entries:
if "item" in e:
low, _, high = str(e.get("quantityRange", e.get("quantity", 1))).partition("-")
low, high = max(int(low), 1), int(high or low)
a, b = counts.get(e["item"], (low, high))
counts[e["item"]] = (min(a, low), max(b, high))
out = []
for item, (low, high) in counts.items():
entry = {"item": item, "count": str(low) if low == high else f"{low}-{high}"}
if odds[item] < 0.9995:
entry["chance"] = round(odds[item] * 100, 1)
out.append(entry)
return out
def species_data(jar):
species = {}
with zipfile.ZipFile(jar) as z:
for name in z.namelist():
m = re.fullmatch(r"data/cobblemon/species/[^/]+/([^/]+)\.json", name)
if not m:
continue
d = json.loads(z.read(name))
pre = d.get("preEvolution")
species[f"cobblemon:{m[1]}"] = {
"name": d["name"], "implemented": d.get("implemented", False), "drops": d.get("drops"),
"pre": f"cobblemon:{pre.split()[0].lower()}" if pre else None,
}
# The pack's species additions replace a species' drops (datapacks/core/data/cobblesurvival/species_additions).
for path in sorted((DATA / "cobblesurvival/species_additions").glob("*.json")):
d = json.loads(path.read_text())
if "drops" in d and d["target"] in species:
species[d["target"]]["drops"] = d["drops"]
return species
def root_of(sid, species):
seen = set()
while species.get(sid, {}).get("pre") and sid not in seen:
seen.add(sid)
sid = species[sid]["pre"]
return sid
def main():
species = species_data(lock.mod_jar("cobblemon"))
plan = json.loads((ROOT / "world/zones.json").read_text())
zones = {z["id"]: z for z in plan["zones"] + plan["caves"] + plan.get("dimensions", [])}
# Where each species spawns, and spawns with drops of their own.
where = defaultdict(dict)
spawn_drops = []
for zid in zones:
pool = DATA / f"cobblesurvival/spawn_pool_world/{zid}.json"
if not pool.exists():
continue
for spawn in json.loads(pool.read_text())["spawns"]:
sid = f"cobblemon:{spawn['pokemon'].split()[0].lower()}"
a, _, b = str(spawn["level"]).partition("-")
low, high = where[sid].get(zid, (999, 0))
where[sid][zid] = (min(low, int(a)), max(high, int(b or a)))
if "drops" in spawn:
entry = {"species": sid, "zone": zid, "drops": drop_entries(spawn["drops"])}
if entry not in spawn_drops:
spawn_drops.append(entry)
line_zones = defaultdict(dict)
for sid, zs in where.items():
for zid, levels in zs.items():
line_zones[root_of(sid, species)].setdefault(zid, sid)
table = json.loads((ROOT / "world/summons.json").read_text())
level_of = lambda v: table["tiers"][str(v["tier"])]["level"]
summoned = {f"cobblemon:{k}": {"zone": v["zone"], "level": level_of(v)} for k, v in table["summons"].items() if "form" not in v}
def sigil(key, v):
base = f"cobblemon:{v.get('species', key)}"
name = f"{v['form'].title()} {species[base]['name']}" if "form" in v else species[base]["name"]
return {"species": f"cobblemon:{key}", "pokemon": base, "name": name, "zone": v["zone"], "level": level_of(v)}
summons = [sigil(k, v) for k, v in table["summons"].items()]
pokemon = []
for sid, s in sorted(species.items()):
drops = drop_entries(s["drops"])
root = root_of(sid, species)
if not drops or not s["implemented"] or not (where.get(sid) or line_zones.get(root) or root in summoned):
continue
entry = {"species": sid, "drops": drops,
"zones": [{"zone": z, "levels": f"{lo}-{hi}"} for z, (lo, hi) in sorted(where.get(sid, {}).items())]}
if root in summoned:
entry["summon"] = summoned[root]
elif not entry["zones"]:
entry["line"] = [{"zone": z, "species": member} for z, member in sorted(line_zones[root].items())]
pokemon.append(entry)
guardians = []
for path in sorted((DATA / "cobblesurvival/guardians").glob("*.json")):
g = json.loads(path.read_text())
name, *props = g["pokemon"].split()
guardians.append({"zone": g["zone"], "species": f"cobblemon:{name}",
"level": int(next(p.split("=")[1] for p in props if p.startswith("level="))),
"drops": g["drops"], "respawn_hours": g["respawn_hours"]})
end = json.loads((DATA / "cobblesurvival/bosses/end.json").read_text())["end"]
end_boss = {"species": f"cobblemon:{end['species']}", "level": end["level"], "xp_first_win": end["xp_first_win"],
"xp": end["xp"], "respawn_hours": end["respawn_hours"],
"first_win": [{"item": "minecraft:dragon_egg", "count": 1}]}
machines = DATA / "cobblesurvival/machines"
altar = json.loads((machines / "altar_core.json").read_text())["altar"]
rebirth = json.loads((machines / "rebirth_core.json").read_text())["rebirth"]
ore_veins = defaultdict(list)
for ore, spec in plan["ores"].items():
ore_veins[spec["zone"]].append({"item": spec["item"], "regen_minutes": spec["regen_minutes"]})
finds = defaultdict(lambda: defaultdict(set))
for path in sorted((DATA / "cobblebase/loot_table").glob("*.json")):
prefix, _, tier = path.stem.rpartition("_")
skill = SKILLS.get(prefix)
if not skill:
continue
for pool in json.loads(path.read_text()).get("pools", []):
for e in pool.get("entries", []):
if e.get("type") != "minecraft:item":
continue
zone_conditions = [c for c in e.get("conditions", []) if c.get("condition") == "cobblesurvival:in_zone"]
for zid in (zone_conditions[0]["zones"] if zone_conditions else ["*"]):
finds[(skill, zid)][e["name"]].add(tier)
pasture_finds = [{"skill": skill, "zone": None if zid == "*" else zid,
"items": [{"item": item, "tiers": sorted(tiers)} for item, tiers in sorted(items.items())]}
for (skill, zid), items in sorted(finds.items(), key=lambda kv: (kv[0][0], kv[0][1] != "*", kv[0][1]))]
data = {
"_comment": "Generated by tools/jei_sources.py from the pack's data; do not edit by hand. The server sends it to "
"players for the companion mod's JEI pages (jei/DropSources.kt); checked against the live server by the "
"self-test drop_sources_match_the_server.",
"zones": {zid: {"name": z["name"], "levels": z["levels"]} for zid, z in zones.items()},
"pokemon": pokemon, "summons": summons, "spawn_drops": spawn_drops, "guardians": guardians, "end_boss": end_boss,
"altar": {"tiers": altar["tiers"], "specials": altar.get("specials", [])}, "rebirth": rebirth.get("extra", []),
"ore_veins": [{"zone": z, "items": items} for z, items in sorted(ore_veins.items())],
"pasture_finds": pasture_finds,
}
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(data, indent=1, ensure_ascii=False) + "\n")
print(f"{len(pokemon)} Pokémon, {len(spawn_drops)} spawns with own drops, {len(guardians)} guardians, "
f"{len(ore_veins)} zones with veins, {len(pasture_finds)} pasture find lists; wrote {OUT.relative_to(ROOT)}")
if __name__ == "__main__":
main()