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>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-11-09 16:26:30 +01:00
1 parent 0741c84077
commit 4083791944
133 files changed
+8350 -3273

No files matched your search

+88 -20
View File
@@ -3,8 +3,8 @@
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 pages are built from
this file inside the companion mod: mod/src/main/resources/assets/cobblesurvival/jei/drop_sources.json. It holds:
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
@@ -18,9 +18,8 @@ this file inside the companion mod: mod/src/main/resources/assets/cobblesurvival
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, then rebuild the mod.
Needs the Cobblemon jar in build/server/mods (any test-server run).
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
"""
@@ -30,21 +29,86 @@ import zipfile
from collections import defaultdict
from pathlib import Path
import lock
ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / "datapacks/core/data"
OUT = ROOT / "mod/src/main/resources/assets/cobblesurvival/jei/drop_sources.json"
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, absent = always), count ("a-b")}]."""
"""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 e in (drops or {}).get("entries", []):
if "item" not in e:
continue
entry = {"item": e["item"], "count": str(e.get("quantityRange", e.get("quantity", 1)))}
if "percentage" in e and float(e["percentage"]) < 100:
entry["chance"] = float(e["percentage"])
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
@@ -79,8 +143,7 @@ def root_of(sid, species):
def main():
jar = next((ROOT / "build/server/mods").glob("Cobblemon-*.jar"))
species = species_data(jar)
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", [])}
@@ -107,8 +170,13 @@ def main():
line_zones[root_of(sid, species)].setdefault(zid, sid)
table = json.loads((ROOT / "world/summons.json").read_text())
summoned = {f"cobblemon:{k}": {"zone": v["zone"], "level": table["tiers"][str(v["tier"])]["level"]} for k, v in table["summons"].items()}
summons = [{"species": sid, "name": species[sid]["name"], **s} for sid, s in summoned.items()]
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()):
@@ -163,9 +231,9 @@ def main():
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. Read by the companion "
"mod's JEI plugin (jei/DropSourcesJei.kt); checked against the live server by the self-test "
"drop_sources_match_the_server.",
"_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", []),