Big, tough bosses; working machines show it; JEI drop pages on the roadmap

Bosses (Adrian, 2026-11-02: Eternatus too small and too easy; guardians way
bigger and tougher; only defensive stats up; no attack takes more than 25 %):
- Guardians scale to about 8 blocks tall (1.5x-12x), Eternatus to 15 blocks
  (2.5x, was 0.2x). Cave guardians look for a spot with room above them.
  Bosses already in a world grow on the next check outside battle.
- Perfect IVs and 170 EVs each in HP, Defense and Sp. Def, nothing offensive.
- The Guardian's Ward, a Showdown-only held item (held_items/guardiansward.js)
  handed to every boss by a HeldItemProvider: Defense and Sp. Def x2 while
  defending, and one attack (all hits together) takes at most 25 % of max HP.
- Area attacks that start at the boss reach half its width further.

Machines (Adrian, 2026-11-02): a working core is active: it glows, its front
animates (flickering fire on the furnace core, pulsing emblems elsewhere), and
particles show in the chamber and at the core (client side). The Pokemon
inside flinches about every 15 s (red flash, Cobblemon's hit sound, no damage).

Roadmap Phase 8: every item that can drop gets a JEI page naming its sources.

Self-tests: practice_bosses_spawn checks size and training,
boss_takes_a_quarter_per_attack runs a real battle (each Lv 100 Bullet Seed
takes exactly a quarter), heat_core_cooks_with_hoppers checks the active state
and the flinch. Self-tests and the island check pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-11-03 10:00:57 +01:00
1 parent 4f0bc03919
commit 120c664cb0
58 files changed
+681 -86

No files matched your search

+58 -8
View File
@@ -5,10 +5,15 @@ Each core is an "orientable" block (front, side, top) facing the player who plac
here, from a palette and an emblem per machine, so they can be redrawn after a change. Our own art (pack license).
The cores are see-through (Adrian, 2026-11-02: the Pokémon in the chamber behind the core must be visible): a solid
frame around tinted glass, the emblem faint on the front; the models render translucent.
A working core (block state active=true; Adrian, 2026-11-02: show that something is happening, like fire in a furnace)
switches to an animated front: the emblem glows and pulses, sparks drift up the glass, and the furnace's flame
flickers. The core also gives off light then, and particles show (mod: MachineEffects).
Usage: tools/machine_assets.py
"""
import json
import math
import random
from pathlib import Path
from PIL import Image
@@ -87,6 +92,10 @@ EMBLEMS = {
GLASS_ALPHA = 70
EMBLEM_ALPHA = 150
# The working front: this many frames, each shown this many ticks, blended into one another.
FRAMES = 8
FRAME_TICKS = 3
FLAME_COLORS = [(255, 70, 20), (255, 140, 30), (255, 200, 60), (255, 240, 150)]
def frame(image, dark, mid, light):
@@ -136,6 +145,43 @@ def front(dark, mid, light, accent, emblem):
return image
def lerp(a, b, t):
return tuple(round(x + (y - x) * t) for x, y in zip(a, b))
def working_front(dark, mid, light, accent, emblem, name):
"""The animated front of a working core: FRAMES frames stacked top to bottom."""
rng = random.Random(name)
rows = EMBLEMS[emblem]
top_row = (16 - len(rows)) // 2
glow = tuple(min(255, c + 60) for c in accent)
white = (255, 255, 240)
strip = Image.new("RGBA", (16, 16 * FRAMES), (0, 0, 0, 0))
sparks = [(rng.randint(3, 12), rng.randint(0, 15)) for _ in range(3)]
for f in range(FRAMES):
pulse = 0.5 + 0.5 * math.sin(2 * math.pi * f / FRAMES)
image = glass(dark, mid, light, accent)
for y, row in enumerate(rows):
for x, c in enumerate(row):
if not (2 <= x <= 13 and 2 <= top_row + y <= 13 and c in "#+"):
continue
if emblem == "flame":
# Flicker: each pixel picks a flame colour, hotter towards the core; the tips come and go.
if y < 3 and rng.random() < 0.35:
continue
heat = (1 if c == "+" else 0) + rng.choice((0, 0, 1))
color = FLAME_COLORS[min(len(FLAME_COLORS) - 1, heat + (1 if pulse > 0.6 else 0))]
else:
color = lerp(accent if c == "#" else glow, white, pulse * (0.6 if c == "+" else 0.35))
image.putpixel((x, top_row + y), color + (round(EMBLEM_ALPHA + (255 - EMBLEM_ALPHA) * (0.5 + 0.5 * pulse)),))
# Sparks drifting up the glass, two pixels a frame.
for sx, sy in sparks:
y = 13 - (sy + 2 * f) % 12
image.putpixel((sx, y), glow + (230,))
strip.paste(image, (0, 16 * f))
return strip
def write_json(path, data):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2) + "\n")
@@ -148,17 +194,21 @@ def main():
side(dark, mid, light, accent).save(textures / f"{name}_side.png")
top(dark, mid, light, accent).save(textures / f"{name}_top.png")
front(dark, mid, light, accent, emblem).save(textures / f"{name}_front.png")
write_json(ASSETS / f"models/block/{name}.json", {
"parent": "minecraft:block/orientable",
"render_type": "minecraft:translucent",
"textures": {"top": f"cobblesurvival:block/{name}_top", "front": f"cobblesurvival:block/{name}_front",
"side": f"cobblesurvival:block/{name}_side"},
})
working_front(dark, mid, light, accent, emblem, name).save(textures / f"{name}_front_on.png")
write_json(textures / f"{name}_front_on.png.mcmeta", {"animation": {"frametime": FRAME_TICKS, "interpolate": True}})
for model, front_texture in ((name, f"{name}_front"), (f"{name}_on", f"{name}_front_on")):
write_json(ASSETS / f"models/block/{model}.json", {
"parent": "minecraft:block/orientable",
"render_type": "minecraft:translucent",
"textures": {"top": f"cobblesurvival:block/{name}_top", "front": f"cobblesurvival:block/{front_texture}",
"side": f"cobblesurvival:block/{name}_side"},
})
write_json(ASSETS / f"models/item/{name}.json", {"parent": f"cobblesurvival:block/{name}"})
rotation = {"north": 0, "east": 90, "south": 180, "west": 270}
write_json(ASSETS / f"blockstates/{name}.json", {
"variants": {f"facing={facing}": {"model": f"cobblesurvival:block/{name}", **({"y": y} if y else {})}
for facing, y in rotation.items()},
"variants": {f"active={str(active).lower()},facing={facing}": {
"model": f"cobblesurvival:block/{name}{'_on' if active else ''}", **({"y": y} if y else {})}
for active in (False, True) for facing, y in rotation.items()},
})
print(f"wrote {len(CORES)} cores to {ASSETS.relative_to(ROOT)}")
+11 -6
View File
@@ -22,7 +22,9 @@ MODS = ROOT / "build/server/mods"
ARENA = 24
ARENA_BY_ZONE = {"basalt": 32}
END = {"species": "eternatus", "level": 80, "scale": 0.2, "respawn_hours": 5, "xp_first_win": 12000, "xp": 500}
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
# Up to this level: these patterns.
@@ -61,19 +63,21 @@ def boss(level, types, arena, **extra):
return {**extra, "arena": arena, "patterns": [pattern(name, tier, damage, effect) for name in TIERS[tier][1]]}
def species_types(jar):
types = {}
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}
return types
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 = species_types(jar)
types, heights = species_data(jar)
OUT.mkdir(parents=True, exist_ok=True)
for old in OUT.glob("*.json"):
old.unlink()
@@ -86,7 +90,8 @@ def main():
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 = boss(END["level"], types.get(END["species"], set()), END_ARENA, end=END)
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")
+27 -5
View File
@@ -30,8 +30,8 @@ datapacks/core/data/cobblesurvival/spawn_pool_world/<zone>.json:
of day, excluded biomes and the "natural" ground preset are dropped (there's no sky or day underground).
5. Strong flyers (STRONG_FLYER_*) only spawn in zones whose band starts at HIGH_ZONE_LEVEL or more.
6. Each zone with a "guardian" gets datapacks/core/data/cobblesurvival/guardians/<zone>.json: the
strongest Pokémon spawning in the zone (or "species"), uncatchable, half again as big (or "scale"), five levels
above the band, with the zone's boss drops and world/zones.json → guardian_respawn_hours.
strongest Pokémon spawning in the zone (or "species"), uncatchable, scaled to stand about GUARDIAN_HEIGHT blocks
tall (or "scale"; Adrian, 2026-11-02: way bigger), five levels above the band, with the zone's boss drops and world/zones.json → guardian_respawn_hours.
7. world/zones.json → "dimensions" are zones that are a whole other dimension (the End; Adrian, 2026-11-02: it was
barren). Every line Cobblemon spawns in their "biome_tags" spawns there too, on top of its island home, with
its stages fitted to the band as in 2. The entries go into "bucket" (Cobblemon rolls the bucket first, and its
@@ -58,7 +58,11 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "datapacks/core/data/cobblesurvival/spawn_pool_world"
GUARDIANS = ROOT / "datapacks/core/data/cobblesurvival/guardians"
GUARDIAN_SCALE = 1.5 # "Alpha": half again as big as a normal one
# Adrian, 2026-11-02: guardians way bigger. Each is scaled to stand about this many blocks tall (its Cobblemon hitbox
# height times base scale), but at least GUARDIAN_SCALE_MIN and at most GUARDIAN_SCALE_MAX times its normal size.
GUARDIAN_HEIGHT = 8
GUARDIAN_SCALE_MIN = 1.5
GUARDIAN_SCALE_MAX = 12
GUARDIAN_LEVELS_ABOVE_BAND = 5
REPORT = ROOT / "docs/zone-species.md"
EXCLUDED_LABELS = {"legendary", "mythical", "ultra_beast", "paradox", "restricted"}
@@ -118,6 +122,22 @@ def zone_biomes(plan, layout, preview):
return shares
def natural_heights(jar):
"""{species id: how tall its entity stands at scale 1, in blocks} from Cobblemon's species files."""
heights = {}
with zipfile.ZipFile(jar) as z:
for name in z.namelist():
m = re.fullmatch(r"data/cobblemon/species/[^/]+/([^/]+)\.json", name)
if m:
data = json.loads(z.read(name))
heights[f"cobblemon:{m[1]}"] = (data.get("hitbox") or {}).get("height", 1) * data.get("baseScale", 1)
return heights
def guardian_scale(height):
return round(min(GUARDIAN_SCALE_MAX, max(GUARDIAN_SCALE_MIN, GUARDIAN_HEIGHT / height)), 2)
def species_data(jar):
"""({species id: base-stat total}, {species ids that are strong flyers}) from Cobblemon's species files."""
totals, flyers = {}, set()
@@ -369,6 +389,7 @@ def main():
for old in GUARDIANS.glob("*.json"):
old.unlink()
guardians = {}
heights = natural_heights(jar)
for zid, zone in zones.items():
config = zone.get("guardian")
if not config:
@@ -383,7 +404,7 @@ def main():
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
"zone": zid,
"pokemon": f"{species_name} level={level} uncatchable=yes",
"scale": config.get("scale", GUARDIAN_SCALE),
"scale": config.get("scale") or guardian_scale(heights.get(f"cobblemon:{species_name}", 1)),
"drops": [{"item": item, "count": count} for item, count in config["drops"]],
"respawn_hours": plan["guardian_respawn_hours"],
}, indent=1) + "\n")
@@ -398,7 +419,8 @@ def main():
lines_out += [f"## {zone['name']} (Lv {zone['levels'][0]}–{zone['levels'][1]}, {len(rows)} lines)", ""]
lines_out += [", ".join(f"{root}" + (f" ({', '.join(n)})" if n != [root] else "") for root, n in rows) or "none", ""]
lines_out += ["## Alpha guardians", "",
f"One per zone at its sample point, uncatchable, five levels above the band; it returns "
f"One per zone at its sample point, uncatchable, about {GUARDIAN_HEIGHT} blocks tall, five levels above the "
f"band, with full HP, Defense and Sp. Def training and the Guardian's Ward in battle; it returns "
f"{plan['guardian_respawn_hours']} hours after it is beaten. Its drops help with the next zone.", "",
"| Zone | Guardian | Level | Drops |", "| --- | --- | --- | --- |"]
for zid, species_name in guardians.items():