Phase 7: boss fights with area attacks, guardians reviewed, End boss
Adrian (2026-11-02): every zone guardian gets area attacks (harder in harder zones); a boss for the Basalt Furnace; a Pokémon replaces the Ender Dragon; dying or leaving the arena loses; 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: Moltres, Eternatus and Regigigas stand in. - Guardians: Mudsdale, Flygon, Abomasnow, Regigigas (normal size), new Moltres for the Basalt Furnace; zone_spawns.py takes a guardian "scale". - tools/zone_bosses.py writes bosses/<zone>.json and end.json from a tier table (circle, line, cone, rings; telegraph, damage, triggers, fire/ice/poison effects, arena). - boss/: BossFights runs patterns during a guardian or End boss battle (particle telegraphs, hits players inside, stops on battle end), enforces the arena and death as a loss (battle ends, boss heals); EndBoss keeps the dragon out (allowlist emptied), spawns Eternatus on the main island with the dragon's boss bar, and on defeat opens the exit portal, gateway and egg via the vanilla dragon fight, plus XP. - Self-tests: coverage (incl. Cobblemon models), shapes, landing and stopping, arena loss, End boss. Docs, balance, Phase 7 checklist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dc4568e023
commit
cd232bdf6a
32 files changed
+1420
-24
No files matched your search
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
{
|
||||
"values": [
|
||||
"minecraft:ender_dragon"
|
||||
]
|
||||
"values": []
|
||||
}
|
||||
+4
-1
@@ -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`) | |
|
||||
|
||||
@@ -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`)
|
||||
+18
-3
@@ -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/<id>.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)
|
||||
|
||||
|
||||
@@ -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 |
|
||||
@@ -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()
|
||||
|
||||
@@ -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/<id>.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<Pattern>)
|
||||
|
||||
var byId: Map<ResourceLocation, Def> = 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<String, Def> = emptyMap()
|
||||
private set
|
||||
var end: Def? = null
|
||||
private set
|
||||
|
||||
fun register() {
|
||||
NeoForge.EVENT_BUS.addListener<AddReloadListenerEvent> { it.addListener(this) }
|
||||
}
|
||||
|
||||
override fun apply(files: Map<ResourceLocation, JsonElement>, 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<Vec3>
|
||||
|
||||
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<Vec3> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<Int>()
|
||||
val telegraphs = ArrayList<Telegraph>()
|
||||
}
|
||||
|
||||
private val fights = ArrayList<Fight>()
|
||||
|
||||
fun register() {
|
||||
BossDefs.register()
|
||||
CobblemonEvents.BATTLE_STARTED_POST.subscribe(Priority.NORMAL) { start(it.battle) }
|
||||
NeoForge.EVENT_BUS.addListener<ServerTickEvent.Post> { 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)
|
||||
}
|
||||
}
|
||||
@@ -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<ServerTickEvent.Post> { 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<net.minecraft.server.level.ServerPlayer>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<net.minecraft.server.level.ServerPlayer, com.cobblemon.mod.common.entity.pokemon.PokemonEntity, () -> 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()
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ object SelfTest {
|
||||
private val cases: List<SelfTestCase> 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<Scenario> = emptyList()
|
||||
private var forced: List<ChunkPos> = emptyList()
|
||||
|
||||
@@ -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/<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, "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()
|
||||
@@ -30,7 +30,7 @@ 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, 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")
|
||||
|
||||
+5
-5
@@ -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)"}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user