Adrian, 2026-11-09: legendaries, mythicals, Ultra Beasts and Paradox Pokemon come from crafted summons. 22 Sigils (Naganadel evolves from Poipole), one item told apart by custom data, from world/summons.json through tools/summons.py: late-game recipes in three tiers (Lv 70, 80, 90), each used in a fitting zone. A summoned legend fights as a boss (area attacks by level and type, arena 32, the Guardian's Ward) but can be caught; on capture it loses every boss mark. One at a time within 96 blocks. Quest book: a Legends chapter, one quest per Sigil (97 quests). JEI lists each Sigil as its own item, and Island Drops says where a legend is summoned; drops Cobblemon names but doesn't have (Ho-Oh's Sacred Ash) get no slot. Self-tests every_implemented_pokemon_is_obtainable (spawn, fossil or Sigil for every implemented species) and sigil_summons_a_catchable_legend. Self-tests and the island check pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
109 lines
5.2 KiB
Python
109 lines
5.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Write the boss fights (Phase 7; Adrian, 2026-11-02: every zone guardian gets area attacks, more and harder in
|
|
harder zones; Eternatus replaces the Ender Dragon).
|
|
|
|
For every guardian in datapacks/core/data/cobblesurvival/guardians/ (tools/zone_spawns.py) this writes
|
|
datapacks/core/data/cobblesurvival/bosses/<zone>.json, and bosses/end.json for the End. The tier comes from the
|
|
guardian's level (TIERS): which patterns it uses, how long they telegraph, how often they come; damage grows with
|
|
level; a Fire, Ice or Poison guardian burns, freezes or poisons. Placeholder numbers, see docs/balance.md.
|
|
|
|
Needs the Cobblemon jar in build/server/mods (any test-server run) for the guardians' types.
|
|
|
|
Usage: tools/zone_bosses.py
|
|
"""
|
|
import json
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
DATA = ROOT / "datapacks/core/data/cobblesurvival"
|
|
OUT = DATA / "bosses"
|
|
MODS = ROOT / "build/server/mods"
|
|
|
|
ARENA = 24
|
|
ARENA_BY_ZONE = {"basalt": 32}
|
|
END = {"species": "eternatus", "level": 80, "respawn_hours": 5, "xp_first_win": 12000, "xp": 500}
|
|
# Adrian, 2026-11-02: Eternatus was too small (0.2x, about a block). Scaled to stand this many blocks tall.
|
|
END_HEIGHT = 15
|
|
END_ARENA = 40
|
|
# A summoned legendary's fight (world/summons.json; Adrian, 2026-11-09): catchable, same rules as a boss.
|
|
SUMMON_ARENA = 32
|
|
|
|
# Up to this level: these patterns.
|
|
TIERS = [
|
|
(20, ["circle"]),
|
|
(35, ["circle", "line"]),
|
|
(50, ["circle", "line", "cone"]),
|
|
(65, ["circle", "line", "cone", "ring"]),
|
|
(999, ["circle", "line", "cone", "ring", "wide_ring"]),
|
|
]
|
|
|
|
|
|
def pattern(name, tier, damage, effect):
|
|
telegraph = 50 - 5 * tier
|
|
base = {"telegraph_ticks": telegraph, "damage": damage, "effect": effect}
|
|
if name == "circle":
|
|
return {"shape": "circle", "radius": 2.5 + 0.25 * tier, "aim": "player", **base,
|
|
"trigger": {"every_ticks": 160 - 12 * tier, "offset": 40}}
|
|
if name == "line":
|
|
return {"shape": "line", "length": 14, "width": 2, "aim": "player", **base,
|
|
"trigger": {"every_ticks": 220 - 15 * tier, "offset": 100}}
|
|
if name == "cone":
|
|
return {"shape": "cone", "length": 10, "angle": 70, "aim": "player", **base,
|
|
"trigger": {"every_ticks": 260 - 15 * tier, "offset": 160}}
|
|
if name == "ring":
|
|
return {"shape": "ring", "inner": 3, "outer": 9, "aim": "boss", **base, "trigger": {"below_health": 0.5}}
|
|
if name == "wide_ring":
|
|
return {"shape": "ring", "inner": 2, "outer": 12, "aim": "boss", **base, "trigger": {"below_health": 0.25}}
|
|
raise ValueError(name)
|
|
|
|
|
|
def boss(level, types, arena, **extra):
|
|
tier = next(i for i, (top, _) in enumerate(TIERS) if level <= top)
|
|
effect = "fire" if "fire" in types else "ice" if "ice" in types else "poison" if "poison" in types else "none"
|
|
damage = max(2, round(level / 6))
|
|
return {**extra, "arena": arena, "patterns": [pattern(name, tier, damage, effect) for name in TIERS[tier][1]]}
|
|
|
|
|
|
def species_data(jar):
|
|
"""({species: its types}, {species: how tall it stands at scale 1, in blocks})."""
|
|
types, heights = {}, {}
|
|
with zipfile.ZipFile(jar) as z:
|
|
for name in z.namelist():
|
|
if name.startswith("data/cobblemon/species/") and name.endswith(".json"):
|
|
d = json.loads(z.read(name))
|
|
types[d["name"].lower()] = {t for t in (d.get("primaryType"), d.get("secondaryType")) if t}
|
|
types[Path(name).stem] = types[d["name"].lower()]
|
|
heights[d["name"].lower()] = (d.get("hitbox") or {}).get("height", 1) * d.get("baseScale", 1)
|
|
return types, heights
|
|
|
|
|
|
def main():
|
|
jar = next(MODS.glob("Cobblemon-*.jar"))
|
|
types, heights = species_data(jar)
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
for old in OUT.glob("*.json"):
|
|
old.unlink()
|
|
comment = "Generated by tools/zone_bosses.py; do not edit by hand. See docs/design.md (Phase 7) for the format."
|
|
for path in sorted((DATA / "guardians").glob("*.json")):
|
|
guardian = json.loads(path.read_text())
|
|
species, *props = guardian["pokemon"].split()
|
|
level = int(next(p.split("=")[1] for p in props if p.startswith("level=")))
|
|
zone = guardian["zone"]
|
|
data = boss(level, types.get(species, set()), ARENA_BY_ZONE.get(zone, ARENA), guardian=zone)
|
|
(OUT / f"{zone}.json").write_text(json.dumps({"_comment": comment, **data}, indent=1) + "\n")
|
|
print(f"{zone}: {species} Lv {level}, {len(data['patterns'])} patterns")
|
|
end_def = {**END, "scale": round(END_HEIGHT / heights[END["species"]], 2)}
|
|
end = boss(END["level"], types.get(END["species"], set()), END_ARENA, end=end_def)
|
|
(OUT / "end.json").write_text(json.dumps({"_comment": comment, **end}, indent=1) + "\n")
|
|
print(f"end: {END['species']} Lv {END['level']}, {len(end['patterns'])} patterns")
|
|
table = json.loads((ROOT / "world/summons.json").read_text())
|
|
for species, s in table["summons"].items():
|
|
fight = boss(table["tiers"][str(s["tier"])]["level"], types.get(species, set()), SUMMON_ARENA, summon=species)
|
|
(OUT / f"summon_{species}.json").write_text(json.dumps({"_comment": comment, **fight}, indent=1) + "\n")
|
|
print(f"{len(table['summons'])} summoned legendaries")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|