diff --git a/README.md b/README.md index 88be512..7379ad3 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,12 @@ python3 tools/zone_spawns.py ``` Writes each zone's spawn pool from Cobblemon's spawn data (every evolution line on the island gets one home zone; levels follow the zone's band) and its Alpha guardian, and lists both in `docs/zone-species.md`. Run after `tools/zone_layout.py` and the field guide; pin a line to a zone in `world/zones.json` → `species`. +```bash +python3 tools/zone_bosses.py +``` + +Writes the boss fights (Phase 7): every guardian's area attacks by its level's tier, and the End boss. Run after `tools/zone_spawns.py`. + ```bash python3 tools/zone_ores.py ``` diff --git a/datapacks/core/data/cobblesurvival/bosses/basalt.json b/datapacks/core/data/cobblesurvival/bosses/basalt.json new file mode 100644 index 0000000..0cabd73 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/bosses/basalt.json @@ -0,0 +1,69 @@ +{ + "_comment": "Generated by tools/zone_bosses.py; do not edit by hand. See docs/design.md (Phase 7) for the format.", + "guardian": "basalt", + "arena": 32, + "patterns": [ + { + "shape": "circle", + "radius": 3.5, + "aim": "player", + "telegraph_ticks": 30, + "damage": 12, + "effect": "fire", + "trigger": { + "every_ticks": 112, + "offset": 40 + } + }, + { + "shape": "line", + "length": 14, + "width": 2, + "aim": "player", + "telegraph_ticks": 30, + "damage": 12, + "effect": "fire", + "trigger": { + "every_ticks": 160, + "offset": 100 + } + }, + { + "shape": "cone", + "length": 10, + "angle": 70, + "aim": "player", + "telegraph_ticks": 30, + "damage": 12, + "effect": "fire", + "trigger": { + "every_ticks": 200, + "offset": 160 + } + }, + { + "shape": "ring", + "inner": 3, + "outer": 9, + "aim": "boss", + "telegraph_ticks": 30, + "damage": 12, + "effect": "fire", + "trigger": { + "below_health": 0.5 + } + }, + { + "shape": "ring", + "inner": 2, + "outer": 12, + "aim": "boss", + "telegraph_ticks": 30, + "damage": 12, + "effect": "fire", + "trigger": { + "below_health": 0.25 + } + } + ] +} diff --git a/datapacks/core/data/cobblesurvival/bosses/button_isle.json b/datapacks/core/data/cobblesurvival/bosses/button_isle.json new file mode 100644 index 0000000..157d845 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/bosses/button_isle.json @@ -0,0 +1,19 @@ +{ + "_comment": "Generated by tools/zone_bosses.py; do not edit by hand. See docs/design.md (Phase 7) for the format.", + "guardian": "button_isle", + "arena": 24, + "patterns": [ + { + "shape": "circle", + "radius": 2.5, + "aim": "player", + "telegraph_ticks": 50, + "damage": 2, + "effect": "none", + "trigger": { + "every_ticks": 160, + "offset": 40 + } + } + ] +} diff --git a/datapacks/core/data/cobblesurvival/bosses/deep_dark.json b/datapacks/core/data/cobblesurvival/bosses/deep_dark.json new file mode 100644 index 0000000..2ddc771 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/bosses/deep_dark.json @@ -0,0 +1,57 @@ +{ + "_comment": "Generated by tools/zone_bosses.py; do not edit by hand. See docs/design.md (Phase 7) for the format.", + "guardian": "deep_dark", + "arena": 24, + "patterns": [ + { + "shape": "circle", + "radius": 3.25, + "aim": "player", + "telegraph_ticks": 35, + "damage": 11, + "effect": "none", + "trigger": { + "every_ticks": 124, + "offset": 40 + } + }, + { + "shape": "line", + "length": 14, + "width": 2, + "aim": "player", + "telegraph_ticks": 35, + "damage": 11, + "effect": "none", + "trigger": { + "every_ticks": 175, + "offset": 100 + } + }, + { + "shape": "cone", + "length": 10, + "angle": 70, + "aim": "player", + "telegraph_ticks": 35, + "damage": 11, + "effect": "none", + "trigger": { + "every_ticks": 215, + "offset": 160 + } + }, + { + "shape": "ring", + "inner": 3, + "outer": 9, + "aim": "boss", + "telegraph_ticks": 35, + "damage": 11, + "effect": "none", + "trigger": { + "below_health": 0.5 + } + } + ] +} diff --git a/datapacks/core/data/cobblesurvival/bosses/deep_sea.json b/datapacks/core/data/cobblesurvival/bosses/deep_sea.json new file mode 100644 index 0000000..b8d8802 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/bosses/deep_sea.json @@ -0,0 +1,57 @@ +{ + "_comment": "Generated by tools/zone_bosses.py; do not edit by hand. See docs/design.md (Phase 7) for the format.", + "guardian": "deep_sea", + "arena": 24, + "patterns": [ + { + "shape": "circle", + "radius": 3.25, + "aim": "player", + "telegraph_ticks": 35, + "damage": 9, + "effect": "none", + "trigger": { + "every_ticks": 124, + "offset": 40 + } + }, + { + "shape": "line", + "length": 14, + "width": 2, + "aim": "player", + "telegraph_ticks": 35, + "damage": 9, + "effect": "none", + "trigger": { + "every_ticks": 175, + "offset": 100 + } + }, + { + "shape": "cone", + "length": 10, + "angle": 70, + "aim": "player", + "telegraph_ticks": 35, + "damage": 9, + "effect": "none", + "trigger": { + "every_ticks": 215, + "offset": 160 + } + }, + { + "shape": "ring", + "inner": 3, + "outer": 9, + "aim": "boss", + "telegraph_ticks": 35, + "damage": 9, + "effect": "none", + "trigger": { + "below_health": 0.5 + } + } + ] +} diff --git a/datapacks/core/data/cobblesurvival/bosses/end.json b/datapacks/core/data/cobblesurvival/bosses/end.json new file mode 100644 index 0000000..8d03a4b --- /dev/null +++ b/datapacks/core/data/cobblesurvival/bosses/end.json @@ -0,0 +1,76 @@ +{ + "_comment": "Generated by tools/zone_bosses.py; do not edit by hand. See docs/design.md (Phase 7) for the format.", + "end": { + "species": "eternatus", + "level": 80, + "scale": 0.2, + "respawn_hours": 5, + "xp_first_win": 12000, + "xp": 500 + }, + "arena": 40, + "patterns": [ + { + "shape": "circle", + "radius": 3.5, + "aim": "player", + "telegraph_ticks": 30, + "damage": 13, + "effect": "poison", + "trigger": { + "every_ticks": 112, + "offset": 40 + } + }, + { + "shape": "line", + "length": 14, + "width": 2, + "aim": "player", + "telegraph_ticks": 30, + "damage": 13, + "effect": "poison", + "trigger": { + "every_ticks": 160, + "offset": 100 + } + }, + { + "shape": "cone", + "length": 10, + "angle": 70, + "aim": "player", + "telegraph_ticks": 30, + "damage": 13, + "effect": "poison", + "trigger": { + "every_ticks": 200, + "offset": 160 + } + }, + { + "shape": "ring", + "inner": 3, + "outer": 9, + "aim": "boss", + "telegraph_ticks": 30, + "damage": 13, + "effect": "poison", + "trigger": { + "below_health": 0.5 + } + }, + { + "shape": "ring", + "inner": 2, + "outer": 12, + "aim": "boss", + "telegraph_ticks": 30, + "damage": 13, + "effect": "poison", + "trigger": { + "below_health": 0.25 + } + } + ] +} diff --git a/datapacks/core/data/cobblesurvival/bosses/frostpeak.json b/datapacks/core/data/cobblesurvival/bosses/frostpeak.json new file mode 100644 index 0000000..f4ca058 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/bosses/frostpeak.json @@ -0,0 +1,57 @@ +{ + "_comment": "Generated by tools/zone_bosses.py; do not edit by hand. See docs/design.md (Phase 7) for the format.", + "guardian": "frostpeak", + "arena": 24, + "patterns": [ + { + "shape": "circle", + "radius": 3.25, + "aim": "player", + "telegraph_ticks": 35, + "damage": 10, + "effect": "ice", + "trigger": { + "every_ticks": 124, + "offset": 40 + } + }, + { + "shape": "line", + "length": 14, + "width": 2, + "aim": "player", + "telegraph_ticks": 35, + "damage": 10, + "effect": "ice", + "trigger": { + "every_ticks": 175, + "offset": 100 + } + }, + { + "shape": "cone", + "length": 10, + "angle": 70, + "aim": "player", + "telegraph_ticks": 35, + "damage": 10, + "effect": "ice", + "trigger": { + "every_ticks": 215, + "offset": 160 + } + }, + { + "shape": "ring", + "inner": 3, + "outer": 9, + "aim": "boss", + "telegraph_ticks": 35, + "damage": 10, + "effect": "ice", + "trigger": { + "below_health": 0.5 + } + } + ] +} diff --git a/datapacks/core/data/cobblesurvival/bosses/highlands.json b/datapacks/core/data/cobblesurvival/bosses/highlands.json new file mode 100644 index 0000000..239128b --- /dev/null +++ b/datapacks/core/data/cobblesurvival/bosses/highlands.json @@ -0,0 +1,45 @@ +{ + "_comment": "Generated by tools/zone_bosses.py; do not edit by hand. See docs/design.md (Phase 7) for the format.", + "guardian": "highlands", + "arena": 24, + "patterns": [ + { + "shape": "circle", + "radius": 3.0, + "aim": "player", + "telegraph_ticks": 40, + "damage": 7, + "effect": "none", + "trigger": { + "every_ticks": 136, + "offset": 40 + } + }, + { + "shape": "line", + "length": 14, + "width": 2, + "aim": "player", + "telegraph_ticks": 40, + "damage": 7, + "effect": "none", + "trigger": { + "every_ticks": 190, + "offset": 100 + } + }, + { + "shape": "cone", + "length": 10, + "angle": 70, + "aim": "player", + "telegraph_ticks": 40, + "damage": 7, + "effect": "none", + "trigger": { + "every_ticks": 230, + "offset": 160 + } + } + ] +} diff --git a/datapacks/core/data/cobblesurvival/bosses/jungle.json b/datapacks/core/data/cobblesurvival/bosses/jungle.json new file mode 100644 index 0000000..bc12dc6 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/bosses/jungle.json @@ -0,0 +1,32 @@ +{ + "_comment": "Generated by tools/zone_bosses.py; do not edit by hand. See docs/design.md (Phase 7) for the format.", + "guardian": "jungle", + "arena": 24, + "patterns": [ + { + "shape": "circle", + "radius": 2.75, + "aim": "player", + "telegraph_ticks": 45, + "damage": 6, + "effect": "none", + "trigger": { + "every_ticks": 148, + "offset": 40 + } + }, + { + "shape": "line", + "length": 14, + "width": 2, + "aim": "player", + "telegraph_ticks": 45, + "damage": 6, + "effect": "none", + "trigger": { + "every_ticks": 205, + "offset": 100 + } + } + ] +} diff --git a/datapacks/core/data/cobblesurvival/bosses/mire.json b/datapacks/core/data/cobblesurvival/bosses/mire.json new file mode 100644 index 0000000..266c30f --- /dev/null +++ b/datapacks/core/data/cobblesurvival/bosses/mire.json @@ -0,0 +1,45 @@ +{ + "_comment": "Generated by tools/zone_bosses.py; do not edit by hand. See docs/design.md (Phase 7) for the format.", + "guardian": "mire", + "arena": 24, + "patterns": [ + { + "shape": "circle", + "radius": 3.0, + "aim": "player", + "telegraph_ticks": 40, + "damage": 7, + "effect": "none", + "trigger": { + "every_ticks": 136, + "offset": 40 + } + }, + { + "shape": "line", + "length": 14, + "width": 2, + "aim": "player", + "telegraph_ticks": 40, + "damage": 7, + "effect": "none", + "trigger": { + "every_ticks": 190, + "offset": 100 + } + }, + { + "shape": "cone", + "length": 10, + "angle": 70, + "aim": "player", + "telegraph_ticks": 40, + "damage": 7, + "effect": "none", + "trigger": { + "every_ticks": 230, + "offset": 160 + } + } + ] +} diff --git a/datapacks/core/data/cobblesurvival/bosses/southwold.json b/datapacks/core/data/cobblesurvival/bosses/southwold.json new file mode 100644 index 0000000..18184ab --- /dev/null +++ b/datapacks/core/data/cobblesurvival/bosses/southwold.json @@ -0,0 +1,32 @@ +{ + "_comment": "Generated by tools/zone_bosses.py; do not edit by hand. See docs/design.md (Phase 7) for the format.", + "guardian": "southwold", + "arena": 24, + "patterns": [ + { + "shape": "circle", + "radius": 2.75, + "aim": "player", + "telegraph_ticks": 45, + "damage": 4, + "effect": "none", + "trigger": { + "every_ticks": 148, + "offset": 40 + } + }, + { + "shape": "line", + "length": 14, + "width": 2, + "aim": "player", + "telegraph_ticks": 45, + "damage": 4, + "effect": "none", + "trigger": { + "every_ticks": 205, + "offset": 100 + } + } + ] +} diff --git a/datapacks/core/data/cobblesurvival/bosses/sunscorch.json b/datapacks/core/data/cobblesurvival/bosses/sunscorch.json new file mode 100644 index 0000000..602e58c --- /dev/null +++ b/datapacks/core/data/cobblesurvival/bosses/sunscorch.json @@ -0,0 +1,45 @@ +{ + "_comment": "Generated by tools/zone_bosses.py; do not edit by hand. See docs/design.md (Phase 7) for the format.", + "guardian": "sunscorch", + "arena": 24, + "patterns": [ + { + "shape": "circle", + "radius": 3.0, + "aim": "player", + "telegraph_ticks": 40, + "damage": 8, + "effect": "none", + "trigger": { + "every_ticks": 136, + "offset": 40 + } + }, + { + "shape": "line", + "length": 14, + "width": 2, + "aim": "player", + "telegraph_ticks": 40, + "damage": 8, + "effect": "none", + "trigger": { + "every_ticks": 190, + "offset": 100 + } + }, + { + "shape": "cone", + "length": 10, + "angle": 70, + "aim": "player", + "telegraph_ticks": 40, + "damage": 8, + "effect": "none", + "trigger": { + "every_ticks": 230, + "offset": 160 + } + } + ] +} diff --git a/datapacks/core/data/cobblesurvival/bosses/westmarch.json b/datapacks/core/data/cobblesurvival/bosses/westmarch.json new file mode 100644 index 0000000..0966fe7 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/bosses/westmarch.json @@ -0,0 +1,32 @@ +{ + "_comment": "Generated by tools/zone_bosses.py; do not edit by hand. See docs/design.md (Phase 7) for the format.", + "guardian": "westmarch", + "arena": 24, + "patterns": [ + { + "shape": "circle", + "radius": 2.75, + "aim": "player", + "telegraph_ticks": 45, + "damage": 5, + "effect": "none", + "trigger": { + "every_ticks": 148, + "offset": 40 + } + }, + { + "shape": "line", + "length": 14, + "width": 2, + "aim": "player", + "telegraph_ticks": 45, + "damage": 5, + "effect": "none", + "trigger": { + "every_ticks": 205, + "offset": 100 + } + } + ] +} diff --git a/datapacks/core/data/cobblesurvival/guardians/basalt.json b/datapacks/core/data/cobblesurvival/guardians/basalt.json new file mode 100644 index 0000000..35a1c4c --- /dev/null +++ b/datapacks/core/data/cobblesurvival/guardians/basalt.json @@ -0,0 +1,17 @@ +{ + "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", + "zone": "basalt", + "pokemon": "moltres level=75 uncatchable=yes", + "scale": 1.5, + "drops": [ + { + "item": "cobblemon:exp_candy_xl", + "count": 3 + }, + { + "item": "minecraft:netherite_scrap", + "count": 2 + } + ], + "respawn_hours": 5 +} diff --git a/datapacks/core/data/cobblesurvival/guardians/deep_dark.json b/datapacks/core/data/cobblesurvival/guardians/deep_dark.json index 12c971c..40ec277 100644 --- a/datapacks/core/data/cobblesurvival/guardians/deep_dark.json +++ b/datapacks/core/data/cobblesurvival/guardians/deep_dark.json @@ -1,8 +1,8 @@ { "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "zone": "deep_dark", - "pokemon": "crobat level=65 uncatchable=yes", - "scale": 1.5, + "pokemon": "regigigas level=65 uncatchable=yes", + "scale": 1.0, "drops": [ { "item": "cobblemon:icy_rock", diff --git a/datapacks/core/data/cobblesurvival/guardians/frostpeak.json b/datapacks/core/data/cobblesurvival/guardians/frostpeak.json index 8628a18..3a34619 100644 --- a/datapacks/core/data/cobblesurvival/guardians/frostpeak.json +++ b/datapacks/core/data/cobblesurvival/guardians/frostpeak.json @@ -1,7 +1,7 @@ { "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "zone": "frostpeak", - "pokemon": "tyranitar level=60 uncatchable=yes", + "pokemon": "abomasnow level=60 uncatchable=yes", "scale": 1.5, "drops": [ { diff --git a/datapacks/core/data/cobblesurvival/guardians/highlands.json b/datapacks/core/data/cobblesurvival/guardians/highlands.json index e59c98f..8c059d8 100644 --- a/datapacks/core/data/cobblesurvival/guardians/highlands.json +++ b/datapacks/core/data/cobblesurvival/guardians/highlands.json @@ -1,7 +1,7 @@ { "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "zone": "highlands", - "pokemon": "cinderace level=40 uncatchable=yes", + "pokemon": "mudsdale level=40 uncatchable=yes", "scale": 1.5, "drops": [ { diff --git a/datapacks/core/data/cobblesurvival/guardians/sunscorch.json b/datapacks/core/data/cobblesurvival/guardians/sunscorch.json index 20a9877..5f7fb2c 100644 --- a/datapacks/core/data/cobblesurvival/guardians/sunscorch.json +++ b/datapacks/core/data/cobblesurvival/guardians/sunscorch.json @@ -1,7 +1,7 @@ { "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", "zone": "sunscorch", - "pokemon": "delphox level=50 uncatchable=yes", + "pokemon": "flygon level=50 uncatchable=yes", "scale": 1.5, "drops": [ { diff --git a/datapacks/core/data/cobblesurvival/tags/entity_type/allowed_vanilla_mobs.json b/datapacks/core/data/cobblesurvival/tags/entity_type/allowed_vanilla_mobs.json index 1012a96..86dde3d 100644 --- a/datapacks/core/data/cobblesurvival/tags/entity_type/allowed_vanilla_mobs.json +++ b/datapacks/core/data/cobblesurvival/tags/entity_type/allowed_vanilla_mobs.json @@ -1,5 +1,3 @@ { - "values": [ - "minecraft:ender_dragon" - ] + "values": [] } diff --git a/docs/balance.md b/docs/balance.md index 7a08671..3fe724f 100644 --- a/docs/balance.md +++ b/docs/balance.md @@ -24,7 +24,7 @@ Every tunable lives in a config file or in this table. **Placeholder** means gue | Sewn-in insulation | goat fur 2 cold, hoglin hide 2 heat, wool 0.5 cold, leather 0.5/0.5 (Cold Sweat: wool 1.5, leather 1/1, rabbit hide 1.5 heat) | Decided (Adrian, 2026-10-30) | `config/coldsweat/item.toml` | | | Goat Fur / Hoglin Hide recipes | 8 wool + 1 Heat Rock → 8 Goat Fur; 8 leather + 1 Icy Rock → 8 Hoglin Hide (a set takes 24) | Decided (Adrian, 2026-10-30) | `kubejs/server_scripts/insulation.js` | Heat and Icy Rocks come from the guardians before Frostpeak and Sunscorch (archaeology finds too) | | Guardian respawn | 5 hours of game time after being beaten | Decided (Adrian, 2026-10-29) | `world/zones.json` → `guardian_respawn_hours` | Fixed spots | -| Guardian strength | strongest Pokémon spawning in the zone, band top + 5 levels, 1.5× size, uncatchable | Decided (Adrian, 2026-10-30) | `tools/zone_spawns.py` (`GUARDIAN_*`), `world/zones.json` → `guardian.species` | Southwold (Pinsir) and Frostpeak (Tyranitar) are pinned: they tie on base stats with Hawlucha and Archaludon | +| Guardian strength | strongest Pokémon spawning in the zone, band top + 5 levels, 1.5× size, uncatchable | Decided (Adrian, 2026-10-30) | `tools/zone_spawns.py` (`GUARDIAN_*`), `world/zones.json` → `guardian.species` | Pinned by hand: Southwold Pinsir, Highland Meadows Mudsdale, Sunscorch Flygon, Frostpeak Abomasnow, Deep Dark Regigigas (normal size), Basalt Furnace Moltres (Lv 75; drops 3 Exp. Candy XL + 2 netherite scrap, placeholder) (Adrian, 2026-11-02: guardians fit their zone, a legendary for the Deep Dark) | | Guardian drops | Button Isle 10 Poké Balls + 4 Potions; Southwold 6 Great Balls + 4 Exp. Candy S; Westmarch and Jungle Mire Waders + 4 Pecha Berries + 2 Exp. Candy M; Mire and Highlands 3 Icy Rocks + 4 Super Potions; Sunscorch 3 Heat Rocks + 2 Exp. Candy L; Frostpeak and Deep Dark 3 Icy Rocks + 2 Exp. Candy L; Deep Sea Mystic Water + 3 Exp. Candy M | Decided (Adrian, 2026-10-30) | `world/zones.json` → `guardian.drops` | 3 rocks make one full fur or hide set | | Mire poison | Poison I for 7 s, renewed every 5 s while in the Mire without Mire Waders | Decided (Adrian, 2026-10-30) | `datapacks/core/data/cobblesurvival/zone_hazards/mire.json` | Vanilla Poison stops at half a heart | | Field moves (Cobblemon HMs) | mod defaults: all 39 abilities on, no level minimum, no fullness or friendship cost; Rock Smash breaks up to 32 connected ore blocks with no cooldown | Placeholder | `config/cobblemon_hms.json` (generated by the mod; the pack ships none yet) | Rock Smash mines any ore, whatever pickaxe the player has. HMs' `minLevel` is the player's XP level, not the Pokémon's | @@ -36,6 +36,9 @@ Every tunable lives in a config file or in this table. **Placeholder** means gue | Drop Synthesizer | 2,000,000 FE per drop roll, takes up to 32,000 FE/t (one roll a minute at most), stores 4,000,000 FE; looting by casing tier: iron 0, gold I, diamond II, netherite III (+0–1 per drop per level; 25 % per level for a second roll); 9 output stacks | Placeholder | `machines/synthesis_core.json` | | | Sacrifice Altar | from Lv 20: 3 Exp. Candy S; 35: 3 M; 50: 3 L; 65: 2 XL + Ability Capsule; 80: 3 rare candies + 2 XL; 90: 5 rare candies + Ability Patch; 100: 8 rare candies + Ability Patch + nether star; Lv 100 Dragon type: + dragon egg; confirm within 5 s | Placeholder | `machines/altar_core.json` | Rare candies above the highest natural level (Lv 75 guardians) | | Rebirth Chamber | 144,000 ticks (2 hours of server time); 3 IVs raised to 31; an Ability Patch comes with it | Placeholder | `machines/rebirth_core.json` | | +| Boss attacks (guardians) | by guardian level: up to Lv 20 a circle under the player; up to 35 + a line from the boss; up to 50 + a cone; up to 65 + a ring around the boss at half HP; above + a wide ring at a quarter HP. Telegraph 2.5 s at the lowest tier down to 1.5 s; damage level/6 health points (Morpeko 3, Moltres 12); circle every 7.4 s down to 5.6 s, line and cone less often; Fire types burn, Ice types freeze, Poison types poison | Placeholder | `tools/zone_bosses.py` → `datapacks/core/data/cobblesurvival/bosses/*.json` | Adrian, 2026-11-02: every guardian, harder in harder zones | +| Boss arena | 24 blocks around where the boss stood; Basalt Furnace 32; the End 40; leaving it or dying loses the fight | Placeholder | same files, `arena` | | +| End boss | Eternatus Lv 80, 0.2× size (its model is 20 m tall), back 5 hours after a win; 12,000 XP the first time, 500 after | Placeholder | `bosses/end.json` (`tools/zone_bosses.py` → `END`) | Stands in for Giratina, which Cobblemon 1.8.4 has no model for | | Level pressure | full experience up to 5 levels above the zone's band, then 10 % less per level, at least 10 % (Exp. Candy and commands exempt) | Decided (Adrian, 2026-10-30) | `world/zones.json` → `level_pressure` | E.g. on Button Isle (up to Lv 10): Lv 15 full, Lv 20 half, Lv 24+ a tenth | | Strong flyer | ridable through the air with top speed ≥ 60 or top stamina ≥ 85 (Cobblemon ride stats) | Decided (Adrian, 2026-10-30) | `tools/zone_spawns.py` (`STRONG_FLYER_*`) | 29 species | | High zone for strong flyers | band starts at Lv 30 or more (Sunscorch, Frostpeak, Deep Sea, Deep Dark, Basalt Furnace) | Decided (Adrian, 2026-10-30) | `tools/zone_spawns.py` (`HIGH_ZONE_LEVEL`) | | diff --git a/docs/checklists/phase-7.md b/docs/checklists/phase-7.md new file mode 100644 index 0000000..f69fbfa --- /dev/null +++ b/docs/checklists/phase-7.md @@ -0,0 +1,27 @@ +# Phase 7 – checks for Adrian + +## Decided + +**2026-11-02:** every zone guardian gets area attacks, more and harder in harder zones; the Basalt Furnace and the End get bosses; dying or leaving the arena loses the fight; area attacks hit only the player. Then: the Deep Dark gets a legendary, and every guardian should fit its zone. Groudon, Giratina and Meloetta have no model in Cobblemon 1.8.4, so Moltres, Eternatus and Regigigas stand in. Details: `docs/design.md` → "Phase 7". + +## Built so far + +- **Guardians reviewed:** Mudsdale (Highland Meadows), Flygon (Sunscorch), Abomasnow (Frostpeak), Regigigas (Deep Dark), and Moltres for the Basalt Furnace, which had none. Morpeko, Pinsir, Scyther, Tangrowth, Swampert and Kingdra stay. Automated: `bosses_cover_every_guardian` (also checks Cobblemon has a model for each). +- **Boss fights:** a battle with a guardian (or the End boss) becomes a fight with area attacks: red or coloured particles on the ground show where an attack lands, about 1.5–2.5 seconds later it hits whoever stands inside. Circles, lines, cones and rings; harder zones get more of them. Fire, Ice and Poison bosses burn, freeze or poison. Automated: `boss_attack_shapes`, `boss_attacks_land_and_stop_with_the_battle`. +- **Arena:** leaving it (24 blocks; Basalt 32; End 40) or dying ends the battle as lost; the boss heals and stays. Automated: `boss_arena_leaving_loses`. +- **The End:** the Ender Dragon is gone; Eternatus appears on the main island, with the dragon's boss bar; beating it opens the exit portal and gateway, leaves the egg the first time and gives experience. Automated: `end_boss_replaces_the_dragon`. + +## In game (Prism instance) + +On the island save (or a new one), in creative or survival: + +- [ ] Fight the Button Isle guardian (Morpeko, `/cobblesurvival guardians` lists where they stand): red circles appear under you every few seconds; step out before they land. Close the battle screen to move (Cobblemon's own key), then go back to it. +- [ ] Fight a later guardian (e.g. Flygon in Sunscorch or Swampert in the Mire): lines and cones from the boss as well. +- [ ] Walk more than 24 blocks away from a guardian during its fight: the battle ends as lost, and the guardian is at full HP again. +- [ ] Go to the End: no dragon; Eternatus stands south of the portal, the boss bar shows its HP. Beat it: the exit portal opens, the egg appears. +- [ ] Find Regigigas in a Deep Dark cave and Moltres in the Basalt Furnace. + +## Ask Adrian + +- Do the attacks feel dodgeable but dangerous? All numbers are placeholders (`docs/balance.md`). +- Swap Moltres → Groudon, Eternatus → Giratina, Regigigas → Meloetta when Cobblemon adds their models? (one word each in `world/zones.json` / `tools/zone_bosses.py`) diff --git a/docs/design.md b/docs/design.md index f9fd921..2645e10 100644 --- a/docs/design.md +++ b/docs/design.md @@ -265,15 +265,30 @@ Self-tests (`MachineTests`): in and out, wrong Pokémon refused, broken shell an ## Phase 7: boss battles with area attacks (Adrian, 2026-11-02) -Adrian's choices: every zone guardian gets area attacks (more and harder in harder zones); the Basalt Furnace's boss is **Groudon**; **Giratina** replaces the Ender Dragon; dying or leaving the arena loses the fight; area attacks hit only the player (the roadmap's default). +Adrian's choices: every zone guardian gets area attacks (more and harder in harder zones); the Basalt Furnace gets a boss and the End a Pokémon that replaces the Ender Dragon; dying or leaving the arena loses the fight; area attacks hit only the player (the roadmap's default). + +**Guardians reviewed** (Adrian, 2026-11-02: the Deep Dark should have a legendary, and each guardian should fit its zone; Tyranitar in the snow looked off). Adrian's first picks, Groudon (Basalt), Giratina (End) and Meloetta (Deep Dark), have no model in Cobblemon 1.8.4 (only 17 legendaries and 2 mythicals are implemented), so implemented ones stand in until they come; changing one is a word in `world/zones.json`: + +| Zone | Was | Now | Why | +| --- | --- | --- | --- | +| Highland Meadows | Cinderace | **Mudsdale** | a draft horse of highland pastures, not a starter | +| Sunscorch | Delphox | **Flygon** | the desert spirit of sandstorms | +| Frostpeak Range | Tyranitar | **Abomasnow** | lives on snowy mountains; spawns there | +| Deep Dark | Crobat | **Regigigas** (normal size) | a legendary; the ancient colossus sleeping in ruins, like the Ancient Cities | +| Basalt Furnace | none | **Moltres** (Lv 75) | the flame bird of the volcano; stands in for Groudon | +| The End | Ender Dragon | **Eternatus** | the cosmic final boss; stands in for Giratina | + +The others fit and stay: Morpeko (Button Isle), Pinsir (Southwold), Scyther (Westmarch), Tangrowth (Jungle), Swampert (Mire), Kingdra (Deep Sea). **Bosses are data**, `datapacks/core/data/cobblesurvival/bosses/.json`, written by `tools/zone_bosses.py` from a tier table per zone band: which fight it is (a zone's guardian, or the End boss), the arena radius, and the attack patterns. A pattern has a shape (circle, ring, line, cone) and its size, where it's aimed (at the player, or around the boss), a telegraph time, damage, an optional effect (fire: set alight; ice: slowed and freezing) and a trigger (every so many seconds, every so many turns, or once when the boss's HP falls below a share). **Runtime** (companion mod). A battle between players and a boss Pokémon becomes a boss fight. While it lasts, due patterns telegraph with particles on the ground where they'll land, then hit the battling players standing inside; everything stops when the battle ends. The arena is a circle around where the boss stood when the fight began: a player who leaves it, or dies, loses the fight (the battle ends, the boss stays, nothing drops). Cobblemon's own flee check only applies when a battle starts, which is why the arena is enforced here. -**Groudon** becomes the Basalt Furnace's guardian like the others (Lv 75, band top + 5), with the zone's placeholder drops. +**Moltres** becomes the Basalt Furnace's guardian like the others (Lv 75, band top + 5), with placeholder drops (3 Exp. Candy XL, 2 netherite scrap). -**Giratina** replaces the Ender Dragon: the dragon no longer spawns; Giratina (uncatchable, Lv 80) appears on the main End island while a player is there, and beating it does what killing the dragon did (exit portal, gateways, the egg on the first win). +Self-tests (`BossTests`): every guardian has a fight and a Cobblemon model; each attack shape hits a player standing in it and misses one ten blocks away; attacks stop when the battle ends; leaving the arena ends the battle and the boss heals; the dragon can't join the End, Eternatus appears, and beating it opens the exit portal with the egg. + +**Eternatus** replaces the Ender Dragon: the dragon no longer spawns; Eternatus (uncatchable, Lv 80, scaled down to fit) appears on the main End island while a player is there, and beating it does what killing the dragon did (exit portal, gateways, the egg on the first win). ## Open decisions for Adrian (proceeding with the default, flagged) diff --git a/docs/zone-species.md b/docs/zone-species.md index a56c055..fe8086c 100644 --- a/docs/zone-species.md +++ b/docs/zone-species.md @@ -65,12 +65,13 @@ One per zone at its sample point, uncatchable, five levels above the band; it re | Zone | Guardian | Level | Drops | | --- | --- | --- | --- | | Button Isle | Morpeko | 15 | 10× poke_ball, 4× potion | -| Frostpeak Range | Tyranitar | 60 | 3× icy_rock, 2× exp_candy_l | -| Sunscorch | Delphox | 50 | 3× heat_rock, 2× exp_candy_l | +| Frostpeak Range | Abomasnow | 60 | 3× icy_rock, 2× exp_candy_l | +| Sunscorch | Flygon | 50 | 3× heat_rock, 2× exp_candy_l | | Mangrove Mire | Swampert | 43 | 3× icy_rock, 4× super_potion | | Verdant Jungle | Tangrowth | 35 | 1× mire_waders, 4× pecha_berry, 2× exp_candy_m | -| Highland Meadows | Cinderace | 40 | 3× icy_rock, 4× super_potion | +| Highland Meadows | Mudsdale | 40 | 3× icy_rock, 4× super_potion | | Westmarch | Scyther | 30 | 1× mire_waders, 4× pecha_berry, 2× exp_candy_m | | Southwold | Pinsir | 23 | 6× great_ball, 4× exp_candy_s | | Deep Sea | Kingdra | 55 | 1× mystic_water, 3× exp_candy_m | -| Deep Dark | Crobat | 65 | 3× icy_rock, 2× exp_candy_l | +| Deep Dark | Regigigas | 65 | 3× icy_rock, 2× exp_candy_l | +| Basalt Furnace | Moltres | 75 | 3× exp_candy_xl, 2× netherite_scrap | diff --git a/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt b/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt index 850d89d..c372bf9 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt @@ -9,6 +9,8 @@ import dev.cobblesurvival.dev.OreVeins import dev.cobblesurvival.ore.BaseLoot import dev.cobblesurvival.ore.OreNodes import dev.cobblesurvival.permadeath.Permadeath +import dev.cobblesurvival.boss.BossFights +import dev.cobblesurvival.boss.EndBoss import dev.cobblesurvival.machine.MachineShowcase import dev.cobblesurvival.machine.Machines import dev.cobblesurvival.party.ShoulderPassives @@ -74,6 +76,8 @@ class CobbleSurvival { ZoneCondition.register() NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners) Guardians.register() + BossFights.register() + EndBoss.register() Gates.register() ShoulderPassives.register() OreNodes.register() diff --git a/mod/src/main/kotlin/dev/cobblesurvival/boss/BossDefs.kt b/mod/src/main/kotlin/dev/cobblesurvival/boss/BossDefs.kt new file mode 100644 index 0000000..197b5f3 --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/boss/BossDefs.kt @@ -0,0 +1,172 @@ +package dev.cobblesurvival.boss + +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import dev.cobblesurvival.CobbleSurvival +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.packs.resources.ResourceManager +import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener +import net.minecraft.util.profiling.ProfilerFiller +import net.minecraft.world.phys.Vec3 +import net.neoforged.neoforge.common.NeoForge +import net.neoforged.neoforge.event.AddReloadListenerEvent +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.sin + +/** + * Boss fights (roadmap Phase 7), `data/cobblesurvival/bosses/.json` (tools/zone_bosses.py): which fight (a zone's + * guardian, or the End boss), the arena radius and the area attacks. See docs/design.md. + */ +object BossDefs : SimpleJsonResourceReloadListener(Gson(), "bosses") { + enum class Shape { CIRCLE, RING, LINE, CONE } + enum class Aim { PLAYER, BOSS } + enum class Effect { NONE, FIRE, ICE, POISON } + + class Trigger(val everyTicks: Int = 0, val offset: Int = 0, val everyTurns: Int = 0, val belowHealth: Double = 0.0) + + class Pattern( + val shape: Shape, + val aim: Aim, + val radius: Double, val inner: Double, val outer: Double, val length: Double, val width: Double, val angle: Double, + val telegraphTicks: Int, + val damage: Float, + val effect: Effect, + val trigger: Trigger, + ) { + /** Where this pattern lands, aimed from [boss] at [target]. */ + fun area(boss: Vec3, target: Vec3): Area { + val origin = if (aim == Aim.BOSS) boss else target + return when (shape) { + Shape.CIRCLE -> Area.Circle(origin, radius) + Shape.RING -> Area.Ring(origin, inner, outer) + Shape.LINE -> Area.Line(boss, heading(boss, target), length, width / 2) + Shape.CONE -> Area.Cone(boss, heading(boss, target), length, Math.toRadians(angle / 2)) + } + } + + private fun heading(from: Vec3, to: Vec3) = atan2(to.z - from.z, to.x - from.x) + } + + class EndBoss(val species: String, val level: Int, val scale: Float, val respawnHours: Double, val xpFirstWin: Int, val xp: Int) + + class Def(val id: ResourceLocation, val guardian: String?, val end: EndBoss?, val arena: Double, val patterns: List) + + var byId: Map = emptyMap() + private set + /** For the self-tests: a boss with a 24-block arena and no attacks of its own (they cast theirs). */ + val SELFTEST = Def(ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "selftest"), null, null, 24.0, emptyList()) + var byGuardian: Map = emptyMap() + private set + var end: Def? = null + private set + + fun register() { + NeoForge.EVENT_BUS.addListener { it.addListener(this) } + } + + override fun apply(files: Map, resources: ResourceManager, profiler: ProfilerFiller) { + val defs = files.mapNotNull { (id, element) -> + runCatching { parse(id, element.asJsonObject) } + .onFailure { CobbleSurvival.LOGGER.error("Bad boss definition {}: {}", id, it.message) } + .getOrNull() + } + byId = defs.associateBy { it.id } + byGuardian = defs.filter { it.guardian != null }.associateBy { it.guardian!! } + end = defs.firstOrNull { it.end != null } + CobbleSurvival.LOGGER.info("Loaded {} boss fights ({} guardians, end: {})", defs.size, byGuardian.size, end?.end?.species) + } + + private fun parse(id: ResourceLocation, json: JsonObject): Def { + val end = json.getAsJsonObject("end")?.let { + EndBoss(it.get("species").asString, it.get("level").asInt, it.get("scale").asFloat, it.get("respawn_hours").asDouble, + it.get("xp_first_win").asInt, it.get("xp").asInt) + } + val patterns = json.getAsJsonArray("patterns").map { it.asJsonObject }.map { p -> + fun num(key: String) = p.get(key)?.asDouble ?: 0.0 + val t = p.getAsJsonObject("trigger") + Pattern( + Shape.valueOf(p.get("shape").asString.uppercase()), Aim.valueOf(p.get("aim").asString.uppercase()), + num("radius"), num("inner"), num("outer"), num("length"), num("width"), num("angle"), + p.get("telegraph_ticks").asInt, p.get("damage").asFloat, Effect.valueOf(p.get("effect")?.asString?.uppercase() ?: "NONE"), + Trigger(t.get("every_ticks")?.asInt ?: 0, t.get("offset")?.asInt ?: 0, t.get("every_turns")?.asInt ?: 0, t.get("below_health")?.asDouble ?: 0.0), + ) + } + return Def(id, json.get("guardian")?.asString, end, json.get("arena").asDouble, patterns) + } +} + +/** Where an area attack lands, flat on the ground at its origin's height (±[REACH_Y] blocks). */ +sealed class Area(val origin: Vec3) { + abstract fun containsFlat(x: Double, z: Double): Boolean + /** Points along the outline, for the telegraph's particles. */ + abstract fun outline(): List + + fun contains(pos: Vec3) = kotlin.math.abs(pos.y - origin.y) <= REACH_Y && containsFlat(pos.x, pos.z) + + protected fun at(x: Double, z: Double) = Vec3(x, origin.y + 0.1, z) + protected fun circle(r: Double) = (0 until maxOf(12, (r * 2 * Math.PI / STEP).toInt())).map { i -> + val a = i * 2 * Math.PI / maxOf(12, (r * 2 * Math.PI / STEP).toInt()) + at(origin.x + r * cos(a), origin.z + r * sin(a)) + } + + class Circle(origin: Vec3, val radius: Double) : Area(origin) { + override fun containsFlat(x: Double, z: Double) = hypot(x - origin.x, z - origin.z) <= radius + override fun outline() = circle(radius) + circle(radius / 2) + } + + class Ring(origin: Vec3, val inner: Double, val outer: Double) : Area(origin) { + override fun containsFlat(x: Double, z: Double) = hypot(x - origin.x, z - origin.z).let { it in inner..outer } + override fun outline() = circle(inner) + circle(outer) + } + + /** From [origin] along [heading] (radians, x towards z), [length] long, [halfWidth] to each side. */ + class Line(origin: Vec3, val heading: Double, val length: Double, val halfWidth: Double) : Area(origin) { + override fun containsFlat(x: Double, z: Double): Boolean { + val dx = x - origin.x + val dz = z - origin.z + val along = dx * cos(heading) + dz * sin(heading) + val across = -dx * sin(heading) + dz * cos(heading) + return along in 0.0..length && kotlin.math.abs(across) <= halfWidth + } + + override fun outline() = (0..(length / STEP).toInt()).flatMap { i -> + val along = i * STEP + listOf(-halfWidth, halfWidth).map { side -> + at(origin.x + along * cos(heading) - side * sin(heading), origin.z + along * sin(heading) + side * cos(heading)) + } + } + } + + /** From [origin] along [heading], [length] far, [halfAngle] radians to each side. */ + class Cone(origin: Vec3, val heading: Double, val length: Double, val halfAngle: Double) : Area(origin) { + override fun containsFlat(x: Double, z: Double): Boolean { + val distance = hypot(x - origin.x, z - origin.z) + if (distance > length) return false + if (distance < 0.5) return true + var off = atan2(z - origin.z, x - origin.x) - heading + while (off > Math.PI) off -= 2 * Math.PI + while (off < -Math.PI) off += 2 * Math.PI + return kotlin.math.abs(off) <= halfAngle + } + + override fun outline(): List { + val edges = (0..(length / STEP).toInt()).flatMap { i -> + listOf(heading - halfAngle, heading + halfAngle).map { a -> at(origin.x + i * STEP * cos(a), origin.z + i * STEP * sin(a)) } + } + val arcSteps = maxOf(4, (length * 2 * halfAngle / STEP).toInt()) + val arc = (0..arcSteps).map { i -> + val a = heading - halfAngle + 2 * halfAngle * i / arcSteps + at(origin.x + length * cos(a), origin.z + length * sin(a)) + } + return edges + arc + } + } + + companion object { + const val REACH_Y = 3.0 + private const val STEP = 0.6 + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/boss/BossFights.kt b/mod/src/main/kotlin/dev/cobblesurvival/boss/BossFights.kt new file mode 100644 index 0000000..cba2eb7 --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/boss/BossFights.kt @@ -0,0 +1,193 @@ +package dev.cobblesurvival.boss + +import com.cobblemon.mod.common.api.Priority +import com.cobblemon.mod.common.api.battles.model.PokemonBattle +import com.cobblemon.mod.common.api.events.CobblemonEvents +import com.cobblemon.mod.common.api.events.battles.BattleStartedEvent +import com.cobblemon.mod.common.entity.pokemon.PokemonEntity +import dev.cobblesurvival.CobbleSurvival +import dev.cobblesurvival.zone.Guardians +import net.minecraft.ChatFormatting +import net.minecraft.core.particles.DustParticleOptions +import net.minecraft.core.particles.ParticleOptions +import net.minecraft.core.particles.ParticleTypes +import net.minecraft.network.chat.Component +import net.minecraft.server.level.ServerLevel +import net.minecraft.server.level.ServerPlayer +import net.minecraft.sounds.SoundEvents +import net.minecraft.sounds.SoundSource +import net.minecraft.world.effect.MobEffectInstance +import net.minecraft.world.effect.MobEffects +import net.minecraft.world.phys.Vec3 +import net.neoforged.neoforge.common.NeoForge +import net.neoforged.neoforge.event.tick.ServerTickEvent +import org.joml.Vector3f +import kotlin.math.hypot + +/** + * Boss fights (roadmap Phase 7; Adrian, 2026-11-02). A battle between players and a boss Pokémon (a zone's guardian or + * the End boss, [BossDefs]) becomes a fight: its area attacks telegraph on the ground, then hit the battling players + * standing inside. Everything stops when the battle ends. The arena is a circle around where the boss stood when the + * fight began: a player who leaves it, or dies, loses the fight for everyone in it (the battle ends, the boss heals + * and stays, nothing drops). Area attacks hit players only, never Pokémon. + */ +object BossFights { + /** Telegraph particles every so many ticks. */ + private const val SHOW_EVERY = 4 + /** Persistent-data key naming a boss file for any Pokémon (the self-tests use it). */ + const val TAG = "cobblesurvival_boss" + + class Telegraph(val pattern: BossDefs.Pattern, val area: Area, val landsAt: Long) + + class Fight(val battle: PokemonBattle, val def: BossDefs.Def, val boss: PokemonEntity, val center: Vec3, val start: Long) { + var lastTurn = battle.turn + val fired = HashSet() + val telegraphs = ArrayList() + } + + private val fights = ArrayList() + + fun register() { + BossDefs.register() + CobblemonEvents.BATTLE_STARTED_POST.subscribe(Priority.NORMAL) { start(it.battle) } + NeoForge.EVENT_BUS.addListener { fights.toList().forEach(::tick) } + } + + fun fightOf(battle: PokemonBattle): Fight? = fights.firstOrNull { it.battle === battle } + + /** The boss fight a Pokémon would start, if it is a boss. */ + fun defOf(entity: PokemonEntity): BossDefs.Def? { + val data = entity.pokemon.persistentData + val zone = data.getString(Guardians.TAG) + return when { + zone.isNotEmpty() -> BossDefs.byGuardian[zone] + data.contains(EndBoss.TAG) -> BossDefs.end + data.contains(TAG) -> net.minecraft.resources.ResourceLocation.parse(data.getString(TAG)).let { id -> + if (id == BossDefs.SELFTEST.id) BossDefs.SELFTEST else BossDefs.byId[id] + } + else -> null + } + } + + fun start(battle: PokemonBattle): Fight? { + if (battle.players.isEmpty() || fightOf(battle) != null) return null + val boss = battle.actors.flatMap { it.pokemonList }.mapNotNull { it.entity }.firstOrNull { defOf(it) != null } ?: return null + val def = defOf(boss) ?: return null + val level = boss.level() as? ServerLevel ?: return null + val fight = Fight(battle, def, boss, boss.position(), level.gameTime) + fights += fight + battle.players.forEach { + it.sendSystemMessage(Component.translatableWithFallback("message.cobblesurvival.boss.start", + "Boss fight: watch the ground and dodge its attacks. Leaving the arena (%s blocks) loses the fight.", + def.arena.toInt()).withStyle(ChatFormatting.GOLD)) + } + CobbleSurvival.LOGGER.info("Boss fight {} begins against {}", def.id, battle.players.joinToString { it.gameProfile.name }) + return fight + } + + private fun tick(fight: Fight) { + val level = fight.boss.level() as? ServerLevel + if (level == null || fight.battle.ended || !fight.boss.isAlive) { + fights.remove(fight) + return + } + for (player in fight.battle.players) { + when { + player.isDeadOrDying || player.isRemoved -> return lose(fight, player, "fell") + flat(player.position(), fight.center) > fight.def.arena -> return lose(fight, player, "left the arena") + } + } + val now = level.gameTime + val elapsed = now - fight.start + val turnChanged = fight.battle.turn != fight.lastTurn + fight.lastTurn = fight.battle.turn + val health = fight.boss.pokemon.currentHealth.toDouble() / fight.boss.pokemon.maxHealth.coerceAtLeast(1) + fight.def.patterns.forEachIndexed { i, pattern -> + val t = pattern.trigger + val due = when { + t.everyTicks > 0 -> elapsed >= t.offset && (elapsed - t.offset) % t.everyTicks == 0L + t.everyTurns > 0 -> turnChanged && fight.battle.turn % t.everyTurns == 0 + t.belowHealth > 0 -> i !in fight.fired && health < t.belowHealth + else -> false + } + if (due) { + fight.fired += i + cast(fight, pattern, target(fight)) + } + } + fight.telegraphs.removeIf { telegraph -> + if (now >= telegraph.landsAt) { + land(fight, level, telegraph) + true + } else { + if ((telegraph.landsAt - now) % SHOW_EVERY == 0L) show(level, telegraph) + false + } + } + } + + private fun flat(a: Vec3, b: Vec3) = hypot(a.x - b.x, a.z - b.z) + + /** A random battling player's position, else the boss's own. */ + private fun target(fight: Fight): Vec3 = fight.battle.players.filter { it.isAlive }.randomOrNull()?.position() ?: fight.boss.position() + + /** Starts [pattern], aimed at [target]: it telegraphs, then lands. */ + fun cast(fight: Fight, pattern: BossDefs.Pattern, target: Vec3): Telegraph { + val level = fight.boss.level() as ServerLevel + val telegraph = Telegraph(pattern, pattern.area(fight.boss.position(), target), level.gameTime + pattern.telegraphTicks) + fight.telegraphs += telegraph + show(level, telegraph) + return telegraph + } + + private fun color(effect: BossDefs.Effect) = when (effect) { + BossDefs.Effect.FIRE -> Vector3f(1f, 0.45f, 0f) + BossDefs.Effect.ICE -> Vector3f(0.5f, 0.85f, 1f) + BossDefs.Effect.POISON -> Vector3f(0.6f, 0.2f, 0.85f) + BossDefs.Effect.NONE -> Vector3f(1f, 0.15f, 0.1f) + } + + private fun show(level: ServerLevel, telegraph: Telegraph) { + val dust = DustParticleOptions(color(telegraph.pattern.effect), 1.2f) + telegraph.area.outline().forEach { level.sendParticles(dust, it.x, it.y, it.z, 1, 0.0, 0.0, 0.0, 0.0) } + } + + private fun land(fight: Fight, level: ServerLevel, telegraph: Telegraph) { + val pattern = telegraph.pattern + val burst: ParticleOptions = when (pattern.effect) { + BossDefs.Effect.FIRE -> ParticleTypes.FLAME + BossDefs.Effect.ICE -> ParticleTypes.SNOWFLAKE + BossDefs.Effect.POISON -> ParticleTypes.WITCH + BossDefs.Effect.NONE -> ParticleTypes.CRIT + } + telegraph.area.outline().forEach { level.sendParticles(burst, it.x, it.y + 0.2, it.z, 2, 0.1, 0.2, 0.1, 0.02) } + val origin = telegraph.area.origin + level.playSound(null, origin.x, origin.y, origin.z, SoundEvents.GENERIC_EXPLODE.value(), SoundSource.HOSTILE, 0.6f, 1.3f) + for (player in fight.battle.players) { + if (!player.isAlive || !telegraph.area.contains(player.position())) continue + player.hurt(level.damageSources().mobAttack(fight.boss), pattern.damage) + when (pattern.effect) { + BossDefs.Effect.FIRE -> player.igniteForSeconds(4f) + BossDefs.Effect.ICE -> { + player.ticksFrozen = player.ticksRequiredToFreeze + 60 + player.addEffect(MobEffectInstance(MobEffects.MOVEMENT_SLOWDOWN, 60, 1)) + } + BossDefs.Effect.POISON -> player.addEffect(MobEffectInstance(MobEffects.POISON, 100, 0)) + BossDefs.Effect.NONE -> {} + } + } + } + + /** [player] [why] (left the arena, fell): the fight is lost for everyone in it; the boss heals and stays. */ + fun lose(fight: Fight, player: ServerPlayer, why: String) { + fights.remove(fight) + val bossName = fight.boss.pokemon.getDisplayName() + fight.battle.players.forEach { + it.sendSystemMessage(Component.translatableWithFallback("message.cobblesurvival.boss.lost", + "%s %s: the fight against %s is lost.", player.gameProfile.name, why, bossName).withStyle(ChatFormatting.RED)) + } + fight.battle.end() + fight.boss.pokemon.heal() + CobbleSurvival.LOGGER.info("Boss fight {} lost: {} {}", fight.def.id, player.gameProfile.name, why) + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/boss/EndBoss.kt b/mod/src/main/kotlin/dev/cobblesurvival/boss/EndBoss.kt new file mode 100644 index 0000000..3e843b7 --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/boss/EndBoss.kt @@ -0,0 +1,127 @@ +package dev.cobblesurvival.boss + +import com.cobblemon.mod.common.api.Priority +import com.cobblemon.mod.common.api.events.CobblemonEvents +import com.cobblemon.mod.common.api.events.battles.BattleFaintedEvent +import com.cobblemon.mod.common.api.pokemon.PokemonProperties +import com.cobblemon.mod.common.entity.pokemon.PokemonEntity +import dev.cobblesurvival.CobbleSurvival +import dev.cobblesurvival.zone.GuardianState +import net.minecraft.ChatFormatting +import net.minecraft.core.BlockPos +import net.minecraft.network.chat.Component +import net.minecraft.server.level.ServerBossEvent +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.entity.EntityType +import net.minecraft.world.entity.boss.enderdragon.EnderDragon +import net.minecraft.world.level.Level +import net.minecraft.world.level.dimension.end.EndDragonFight +import net.minecraft.world.level.levelgen.Heightmap +import net.neoforged.neoforge.common.NeoForge +import net.neoforged.neoforge.event.tick.ServerTickEvent +import java.util.UUID + +/** + * The End's boss (Adrian, 2026-11-02): a Pokémon replaces the Ender Dragon, which may no longer join a world (it left + * the tag `cobblesurvival:allowed_vanilla_mobs`). Eternatus stands in for Adrian's Giratina until Cobblemon has its + * model ([BossDefs.end]). While a player is near the main island and the boss isn't there (and wasn't beaten within its + * respawn time), it appears; vanilla's dragon boss bar shows its name and HP. Beating it does what killing the dragon + * did: exit portal and gateway, the egg on the first win, and experience for everyone in the battle. + */ +object EndBoss { + const val TAG = "cobblesurvival_end_boss" + /** Its entry in [GuardianState]. */ + private const val STATE = "the_end" + private const val CHECK_TICKS = 40 + private const val NEAR = 160.0 + private const val LEASH = 24.0 + private const val TICKS_PER_HOUR = 72000.0 + /** Where it stands: south of the exit portal, on the island's top. */ + private val SPOT_XZ = BlockPos(0, 0, 24) + + private val dragonUuid by lazy { EndDragonFight::class.java.getDeclaredField("dragonUUID").apply { isAccessible = true } } + private val bossBar by lazy { EndDragonFight::class.java.getDeclaredField("dragonEvent").apply { isAccessible = true } } + + fun register() { + NeoForge.EVENT_BUS.addListener { event -> + if (event.server.tickCount % CHECK_TICKS == 0) event.server.getLevel(Level.END)?.let(::check) + } + CobblemonEvents.BATTLE_FAINTED.subscribe(Priority.NORMAL, ::onFainted) + } + + private fun state(level: ServerLevel) = GuardianState.of(level.server.overworld()).entry(STATE) + + fun current(level: ServerLevel): PokemonEntity? = + state(level).entity?.let { level.getEntity(it) as? PokemonEntity }?.takeIf { it.isAlive } + + private fun check(level: ServerLevel) { + val def = BossDefs.end?.end ?: return + val near = level.players().any { it.horizontalDistanceToSqr0() < NEAR * NEAR } + val boss = current(level) + if (boss != null) { + val spot = spot(level) + if (boss.distanceToSqr(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5) > LEASH * LEASH) boss.teleportTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5) + level.dragonFight?.let { fight -> + (bossBar.get(fight) as ServerBossEvent).apply { + name = boss.pokemon.getDisplayName() + progress = boss.pokemon.currentHealth.toFloat() / boss.pokemon.maxHealth.coerceAtLeast(1) + } + } + return + } + val defeatedAt = state(level).defeatedAt + if (!near || (defeatedAt != null && level.gameTime - defeatedAt < def.respawnHours * TICKS_PER_HOUR)) return + spawn(level) + } + + private fun net.minecraft.world.entity.player.Player.horizontalDistanceToSqr0() = x * x + z * z + + private fun spot(level: ServerLevel): BlockPos = level.getHeightmapPos(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, SPOT_XZ) + + /** Puts the End boss on the main island; null without End boss data. */ + fun spawn(level: ServerLevel): PokemonEntity? { + val def = BossDefs.end?.end ?: return null + val pokemon = PokemonProperties.parse("${def.species} level=${def.level} uncatchable=yes").create() + pokemon.scaleModifier = def.scale + pokemon.persistentData.putBoolean(TAG, true) + val entity = PokemonEntity(level, pokemon) + val spot = spot(level) + entity.moveTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5, 180f, 0f) + entity.setPersistenceRequired() + if (!level.addFreshEntity(entity)) return null + state(level).entity = entity.uuid + CobbleSurvival.LOGGER.info("The End boss {} appeared at {}", def.species, spot.toShortString()) + return entity + } + + private fun onFainted(event: BattleFaintedEvent) { + if (!event.killed.effectedPokemon.persistentData.contains(TAG)) return + val players = event.battle.players + val level = players.firstOrNull()?.server?.getLevel(Level.END) ?: return + defeated(level, players) + } + + /** What killing the dragon did: exit portal, gateway, the egg the first time; experience for [players]. */ + fun defeated(level: ServerLevel, players: List) { + val def = BossDefs.end?.end + state(level).apply { + defeatedAt = level.gameTime + entity = null + } + val fight = level.dragonFight + val firstWin = fight?.hasPreviouslyKilledDragon() == false + if (fight != null) { + val dummy = EnderDragon(EntityType.ENDER_DRAGON, level) + dummy.uuid = UUID.randomUUID() + dragonUuid.set(fight, dummy.uuid) + fight.setDragonKilled(dummy) + } + val xp = if (firstWin) def?.xpFirstWin ?: 0 else def?.xp ?: 0 + players.forEach { + it.giveExperiencePoints(xp) + it.sendSystemMessage(Component.translatableWithFallback("message.cobblesurvival.boss.end_beaten", + "The End's guardian is beaten! The way home is open.").withStyle(ChatFormatting.GOLD)) + } + CobbleSurvival.LOGGER.info("The End boss was beaten by {} (first win: {})", players.joinToString { it.gameProfile.name }, firstWin) + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/selftest/BossTests.kt b/mod/src/main/kotlin/dev/cobblesurvival/selftest/BossTests.kt new file mode 100644 index 0000000..c8743f2 --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/selftest/BossTests.kt @@ -0,0 +1,172 @@ +package dev.cobblesurvival.selftest + +import com.cobblemon.mod.common.api.pokemon.PokemonProperties +import com.cobblemon.mod.common.api.pokemon.PokemonSpecies +import com.cobblemon.mod.common.battles.BattleRegistry +import dev.cobblesurvival.boss.Area +import dev.cobblesurvival.boss.BossDefs +import dev.cobblesurvival.boss.BossFights +import dev.cobblesurvival.boss.EndBoss +import dev.cobblesurvival.zone.Guardians +import net.minecraft.core.BlockPos +import net.minecraft.gametest.framework.GameTestAssertException +import net.minecraft.world.entity.EntityType +import net.minecraft.world.entity.boss.enderdragon.EnderDragon +import net.minecraft.world.level.ChunkPos +import net.minecraft.world.level.Level +import net.minecraft.world.level.block.Blocks +import net.minecraft.world.phys.Vec3 + +/** + * Boss fights (roadmap Phase 7; Adrian, 2026-11-02). Done-when: a test boss telegraphs and lands each pattern type, + * attacks stop on battle end, and leaving the arena forfeits. Also: every guardian has a fight and is a Pokémon Cobblemon + * has a model for, and a Pokémon replaces the Ender Dragon. + */ +object BossTests { + /** The test boss: a 24-block arena, no attacks of its own. */ + private val TEST_BOSS = BossDefs.SELFTEST.id.toString() + + fun all() = listOf( + SelfTestCase("bosses_cover_every_guardian", 20, ::coverage), + SelfTestCase("boss_attack_shapes", 20, ::shapes), + SelfTestCase("boss_attacks_land_and_stop_with_the_battle", 400, ::landAndStop), + SelfTestCase("boss_arena_leaving_loses", 400, ::arena), + SelfTestCase("end_boss_replaces_the_dragon", 200, ::endBoss), + ) + + private fun implemented(species: String) = PokemonSpecies.getByName(species)?.implemented == true + + private fun coverage(s: Scenario) { + val missing = Guardians.definitions.keys - BossDefs.byGuardian.keys + s.check(missing.isEmpty()) { "guardians without a boss fight: $missing" } + val noModel = Guardians.definitions.values.map { PokemonProperties.parse(it.pokemon).species ?: "?" }.filterNot(::implemented) + s.check(noModel.isEmpty()) { "guardians Cobblemon has no model for: $noModel" } + val end = BossDefs.end?.end ?: throw GameTestAssertException("no End boss") + s.check(implemented(end.species)) { "the End boss ${end.species} has no model in Cobblemon" } + s.check(BossDefs.byId.values.all { it.patterns.isNotEmpty() }) { "a boss without attacks" } + s.helper.succeed() + } + + private fun shapes(s: Scenario) { + val o = Vec3(0.0, 64.0, 0.0) + val circle = Area.Circle(o, 3.0) + s.check(circle.contains(Vec3(2.0, 64.0, 0.0)) && !circle.contains(Vec3(4.0, 64.0, 0.0))) { "circle" } + s.check(!circle.contains(Vec3(0.0, 70.0, 0.0))) { "the circle reaches 6 blocks up" } + val ring = Area.Ring(o, 3.0, 8.0) + s.check(ring.contains(Vec3(5.0, 64.0, 0.0)) && !ring.contains(Vec3(1.0, 64.0, 0.0)) && !ring.contains(Vec3(9.0, 64.0, 0.0))) { "ring" } + val line = Area.Line(o, 0.0, 10.0, 1.0) // along +x + s.check(line.contains(Vec3(8.0, 64.0, 0.5)) && !line.contains(Vec3(8.0, 64.0, 2.0)) && !line.contains(Vec3(-2.0, 64.0, 0.0))) { "line" } + val cone = Area.Cone(o, Math.PI / 2, 10.0, Math.toRadians(30.0)) // along +z + s.check(cone.contains(Vec3(2.0, 64.0, 8.0)) && !cone.contains(Vec3(8.0, 64.0, 2.0)) && !cone.contains(Vec3(0.0, 64.0, 11.0))) { "cone" } + s.check(listOf(circle, ring, line, cone).all { it.outline().isNotEmpty() }) { "a shape without telegraph points" } + s.helper.succeed() + } + + private fun pattern(shape: BossDefs.Shape, aim: BossDefs.Aim) = BossDefs.Pattern( + shape, aim, radius = 2.0, inner = 1.0, outer = 4.0, length = 8.0, width = 2.0, angle = 60.0, + telegraphTicks = 10, damage = 4f, effect = BossDefs.Effect.NONE, trigger = BossDefs.Trigger(), + ) + + /** A wild Pokémon that fights as the [TEST_BOSS] boss, and a player with a Pokémon battling it. */ + private fun battle(s: Scenario): Triple BossFights.Fight?> { + val player = s.player() + // New players are protected for their first 3 seconds; the test needn't wait for that. + net.minecraft.server.level.ServerPlayer::class.java.getDeclaredField("spawnInvulnerableTime").apply { isAccessible = true }.setInt(player, 0) + s.give(player, s.pokemon("pikachu", 50)) + val bossPokemon = s.pokemon("morpeko", 15) + bossPokemon.persistentData.putString(BossFights.TAG, TEST_BOSS) + val boss = s.wild(bossPokemon, s.at(7.5, 1.0, 3.5)) + val fight = { BattleRegistry.getBattleByParticipatingPlayer(player)?.let(BossFights::fightOf) } + return Triple(player, boss, fight) + } + + private fun landAndStop(s: Scenario) { + val (player, boss, fight) = battle(s) + var health = 0f + val shapes = BossDefs.Shape.entries.toMutableList() + s.startWildBattle(player, boss) + .thenWaitUntil { s.check(fight() != null) { "the battle with the test boss is no boss fight" } } + .thenExecute { + player.health = player.maxHealth + health = player.health + } + .let { seq -> + // Each shape, aimed so the player stands inside, lands: the player loses health each time. + BossDefs.Shape.entries.fold(seq) { acc, shape -> + acc.thenExecute { + health = player.health + val aim = if (shape == BossDefs.Shape.RING) BossDefs.Aim.BOSS else BossDefs.Aim.PLAYER + val target = if (shape == BossDefs.Shape.RING) player.position() else player.position() + val f = fight()!! + // A ring around the boss: move the player into it first. + if (shape == BossDefs.Shape.RING) player.teleportTo(boss.x + 2.5, boss.y, boss.z) + BossFights.cast(f, pattern(shape, aim), if (shape == BossDefs.Shape.RING) player.position() else target) + }.thenIdle(15).thenExecute { + s.check(player.health < health) { "${shape.name.lowercase()} didn't hit the player standing in it (health $health -> ${player.health})" } + shapes.remove(shape) + } + } + } + .thenExecute { + health = player.health + BossFights.cast(fight()!!, pattern(BossDefs.Shape.CIRCLE, BossDefs.Aim.PLAYER), player.position().add(10.0, 0.0, 0.0)) + } + .thenIdle(15) + .thenExecute { + // Health may only go up (natural regeneration), never down. + s.check(player.health >= health) { "a circle 10 blocks away hit the player" } + BossFights.cast(fight()!!, pattern(BossDefs.Shape.CIRCLE, BossDefs.Aim.PLAYER), player.position()) + BattleRegistry.getBattleByParticipatingPlayer(player)!!.end() + health = player.health + } + .thenIdle(15) + .thenExecute { + s.check(player.health >= health) { "an attack landed after the battle ended" } + s.check(fight() == null) { "the boss fight goes on after the battle ended" } + } + .thenSucceed() + } + + private fun arena(s: Scenario) { + val (player, boss, fight) = battle(s) + s.startWildBattle(player, boss) + .thenWaitUntil { s.check(fight() != null) { "the battle with the test boss is no boss fight" } } + .thenExecute { + boss.pokemon.currentHealth = 1 + val arena = fight()!!.def.arena + player.teleportTo(boss.x + arena + 6, boss.y, boss.z) + } + .thenWaitUntil { + s.check(BattleRegistry.getBattleByParticipatingPlayer(player) == null) { "the battle goes on with the player outside the arena" } + } + .thenExecute { + s.check(boss.isAlive && boss.pokemon.currentHealth == boss.pokemon.maxHealth) { "the boss didn't stay and heal after the lost fight" } + } + .thenSucceed() + } + + private fun endBoss(s: Scenario) { + val end = s.level.server.getLevel(Level.END) ?: throw GameTestAssertException("no End") + val chunks = (-2..2).flatMap { x -> (-2..2).map { z -> ChunkPos(x, z) } } + chunks.forEach { end.setChunkForced(it.x, it.z, true) } + val player = s.player() + s.afterTest { + EndBoss.current(end)?.discard() + chunks.forEach { end.setChunkForced(it.x, it.z, false) } + } + val dragon = EnderDragon(EntityType.ENDER_DRAGON, end) + dragon.moveTo(0.0, 80.0, 0.0) + s.check(!end.addFreshEntity(dragon) || dragon.isRemoved) { "an Ender Dragon could join the End" } + val boss = EndBoss.spawn(end) ?: throw GameTestAssertException("the End boss didn't appear") + s.check(boss.pokemon.species.name.equals(BossDefs.end!!.end!!.species, ignoreCase = true) && boss.pokemon.isUncatchable()) { + "the End boss is a catchable ${boss.pokemon.species.name}" + } + val xp = player.totalExperience + EndBoss.defeated(end, listOf(player)) + s.check(player.totalExperience > xp) { "no experience for beating the End boss" } + val area = BlockPos.betweenClosed(BlockPos(-4, 0, -4), BlockPos(4, 255, 4)) + s.check(area.any { end.getBlockState(it).`is`(Blocks.END_PORTAL) }) { "no exit portal after the End boss was beaten" } + s.check(area.any { end.getBlockState(it).`is`(Blocks.DRAGON_EGG) }) { "no dragon egg after the first win" } + s.helper.succeed() + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/selftest/SelfTest.kt b/mod/src/main/kotlin/dev/cobblesurvival/selftest/SelfTest.kt index 0e38672..843bd56 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/selftest/SelfTest.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/selftest/SelfTest.kt @@ -54,7 +54,7 @@ object SelfTest { private val cases: List get() = PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all() + GuardianTests.all() + VeinTests.all() + OreNodeTests.all() + DropTests.all() + ProgressionTests.all() + - CatchingTests.all() + WorldRulesTests.all() + FieldMoveTests.all() + ShoulderTests.all() + BaseLootTests.all() + BreedingTests.all() + MachineTests.all() + CatchingTests.all() + WorldRulesTests.all() + FieldMoveTests.all() + ShoulderTests.all() + BaseLootTests.all() + BreedingTests.all() + MachineTests.all() + BossTests.all() private var running: List = emptyList() private var forced: List = emptyList() diff --git a/tools/zone_bosses.py b/tools/zone_bosses.py new file mode 100644 index 0000000..612214f --- /dev/null +++ b/tools/zone_bosses.py @@ -0,0 +1,95 @@ +#!/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/.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, "scale": 0.2, "respawn_hours": 5, "xp_first_win": 12000, "xp": 500} +END_ARENA = 40 + +# 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_types(jar): + types = {} + 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 + + +def main(): + jar = next(MODS.glob("Cobblemon-*.jar")) + types = species_types(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 = boss(END["level"], types.get(END["species"], set()), END_ARENA, end=END) + (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") + + +if __name__ == "__main__": + main() diff --git a/tools/zone_spawns.py b/tools/zone_spawns.py index 2f1319c..4a5ef6a 100755 --- a/tools/zone_spawns.py +++ b/tools/zone_spawns.py @@ -30,7 +30,7 @@ datapacks/core/data/cobblesurvival/spawn_pool_world/.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/.json: the - strongest Pokémon spawning in the zone (or "species"), uncatchable, half again as big, five levels + 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. Also writes docs/zone-species.md, the list per zone for review. @@ -339,7 +339,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": GUARDIAN_SCALE, + "scale": config.get("scale", GUARDIAN_SCALE), "drops": [{"item": item, "count": count} for item, count in config["drops"]], "respawn_hours": plan["guardian_respawn_hours"], }, indent=1) + "\n") diff --git a/world/zones.json b/world/zones.json index c1ec033..1a9b862 100644 --- a/world/zones.json +++ b/world/zones.json @@ -37,11 +37,11 @@ "rule": {"within": 300} }, { - "id": "frostpeak", "guardian": {"species": "tyranitar", "drops": [["cobblemon:icy_rock", 3], ["cobblemon:exp_candy_l", 2]]}, "lines": 30, "name": "Frostpeak Range", "levels": [40, 55], "hazard": "cold (Cold Sweat): needs wool insulation", "color": "#f2f7ff", + "id": "frostpeak", "guardian": {"species": "abomasnow", "drops": [["cobblemon:icy_rock", 3], ["cobblemon:exp_candy_l", 2]]}, "lines": 30, "name": "Frostpeak Range", "levels": [40, 55], "hazard": "cold (Cold Sweat): needs wool insulation", "color": "#f2f7ff", "rule": {"biomes": ["minecraft:jagged_peaks"], "absorb": {"minecraft:windswept_hills": 96, "minecraft:cherry_grove": 144}} }, { - "id": "sunscorch", "guardian": {"drops": [["cobblemon:heat_rock", 3], ["cobblemon:exp_candy_l", 2]]}, "lines": 25, "name": "Sunscorch", "levels": [30, 45], "hazard": "heat (Cold Sweat): needs heat insulation", "color": "#f4a443", + "id": "sunscorch", "guardian": {"species": "flygon", "drops": [["cobblemon:heat_rock", 3], ["cobblemon:exp_candy_l", 2]]}, "lines": 25, "name": "Sunscorch", "levels": [30, 45], "hazard": "heat (Cold Sweat): needs heat insulation", "color": "#f4a443", "rule": {"biomes": ["minecraft:desert", "minecraft:badlands"]} }, { @@ -53,7 +53,7 @@ "rule": {"biomes": ["minecraft:jungle"]} }, { - "id": "highlands", "guardian": {"drops": [["cobblemon:icy_rock", 3], ["cobblemon:super_potion", 4]]}, "lines": 35, "name": "Highland Meadows", "levels": [25, 35], "hazard": "none", "color": "#e3b7d6", + "id": "highlands", "guardian": {"species": "mudsdale", "drops": [["cobblemon:icy_rock", 3], ["cobblemon:super_potion", 4]]}, "lines": 35, "name": "Highland Meadows", "levels": [25, 35], "hazard": "none", "color": "#e3b7d6", "rule": {"region": {"z_max": 0, "x_min": -250}} }, { @@ -76,7 +76,7 @@ "caves": [ {"id": "lush_caves", "types": ["grass", "bug", "fairy", "poison", "water"], "lines": 20, "name": "Lush Caves", "biome": "minecraft:lush_caves", "levels": [12, 28], "hazard": "none"}, {"id": "dripstone", "types": ["rock", "ground", "steel", "fairy", "psychic", "dragon"], "lines": 20, "name": "Dripstone Depths", "biome": "minecraft:dripstone_caves", "levels": [22, 38], "hazard": "none"}, - {"id": "deep_dark", "types": ["ghost", "dark", "steel", "poison", "psychic", "rock"], "guardian": {"drops": [["cobblemon:icy_rock", 3], ["cobblemon:exp_candy_l", 2]]}, "lines": 21, "name": "Deep Dark", "biome": "minecraft:deep_dark", "levels": [45, 60], "hazard": "darkness (vanilla sculk; no Warden)"}, - {"id": "basalt", "types": ["fire", "rock", "ground", "steel", "dark"], "lines": 15, "name": "Basalt Furnace", "biome": "minecraft:basalt_deltas", "levels": [55, 70], "hazard": "heat (Cold Sweat)"} + {"id": "deep_dark", "types": ["ghost", "dark", "steel", "poison", "psychic", "rock"], "guardian": {"species": "regigigas", "scale": 1.0, "drops": [["cobblemon:icy_rock", 3], ["cobblemon:exp_candy_l", 2]]}, "lines": 21, "name": "Deep Dark", "biome": "minecraft:deep_dark", "levels": [45, 60], "hazard": "darkness (vanilla sculk; no Warden)"}, + {"id": "basalt", "types": ["fire", "rock", "ground", "steel", "dark"], "guardian": {"species": "moltres", "drops": [["cobblemon:exp_candy_xl", 3], ["minecraft:netherite_scrap", 2]]}, "lines": 15, "name": "Basalt Furnace", "biome": "minecraft:basalt_deltas", "levels": [55, 70], "hazard": "heat (Cold Sweat)"} ] }