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:
1 parent
0741c84077
commit
4083791944
133 files changed
+8350
-3273
No files matched your search
+88
-20
@@ -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", []),
|
||||
|
||||
Reference in new issue
Block a user