From 73fe71918f9fb7e857c8b40da1701de7a4ddc6f7 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 29 Oct 2026 19:01:21 +0100 Subject: [PATCH] Phase 3: Alpha guardians, one per zone, back 5 hours after a defeat - Companion mod (zone/Guardians.kt): each zone's guardian stands at the zone's sample point, appears when a player is within 96 blocks, stays within 24 blocks, is uncatchable and half again as big. Beating it in a battle gives every player in the battle its drops; it returns after 5 hours of game time (Adrian: fixed spots, 5 hours). State lives in the world save; /cobblesurvival guardians lists it. - tools/zone_spawns.py writes data/cobblesurvival/guardians/.json: the strongest walking (or swimming) Pokemon of the zone at band top + 5, with the drops from world/zones.json. Boss drops follow Adrian's rule: Icy Rocks before Sunscorch, Heat Rocks before Frostpeak. - Goat Fur and Hoglin Hide recipes make 8 per rock, so one guardian's 3 rocks pay for one full insulating set. - Self-test guardian_appears_and_returns; hazard tests cleaned up; the zone generator reads Cobblemon's own entries from the field guide's hidden_spawns. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../cobblesurvival/guardians/button_isle.json | 17 ++ .../cobblesurvival/guardians/deep_dark.json | 17 ++ .../cobblesurvival/guardians/deep_sea.json | 17 ++ .../cobblesurvival/guardians/frostpeak.json | 17 ++ .../cobblesurvival/guardians/highlands.json | 17 ++ .../data/cobblesurvival/guardians/jungle.json | 17 ++ .../data/cobblesurvival/guardians/mire.json | 17 ++ .../cobblesurvival/guardians/southwold.json | 17 ++ .../cobblesurvival/guardians/sunscorch.json | 17 ++ .../cobblesurvival/guardians/westmarch.json | 17 ++ docs/balance.md | 5 +- docs/checklists/phase-3.md | 5 +- docs/design.md | 6 + docs/zone-species.md | 17 ++ .../dev/cobblesurvival/CobbleSurvival.kt | 7 + .../cobblesurvival/selftest/GuardianTests.kt | 75 +++++ .../dev/cobblesurvival/selftest/SelfTest.kt | 2 +- .../dev/cobblesurvival/zone/Guardians.kt | 263 ++++++++++++++++++ .../assets/cobblesurvival/lang/en_us.json | 3 +- pack/index.toml | 2 +- pack/kubejs/server_scripts/insulation.js | 8 +- pack/pack.toml | 2 +- tools/zone_spawns.py | 40 ++- world/zones.json | 23 +- 25 files changed, 607 insertions(+), 23 deletions(-) create mode 100644 datapacks/core/data/cobblesurvival/guardians/button_isle.json create mode 100644 datapacks/core/data/cobblesurvival/guardians/deep_dark.json create mode 100644 datapacks/core/data/cobblesurvival/guardians/deep_sea.json create mode 100644 datapacks/core/data/cobblesurvival/guardians/frostpeak.json create mode 100644 datapacks/core/data/cobblesurvival/guardians/highlands.json create mode 100644 datapacks/core/data/cobblesurvival/guardians/jungle.json create mode 100644 datapacks/core/data/cobblesurvival/guardians/mire.json create mode 100644 datapacks/core/data/cobblesurvival/guardians/southwold.json create mode 100644 datapacks/core/data/cobblesurvival/guardians/sunscorch.json create mode 100644 datapacks/core/data/cobblesurvival/guardians/westmarch.json create mode 100644 mod/src/main/kotlin/dev/cobblesurvival/selftest/GuardianTests.kt create mode 100644 mod/src/main/kotlin/dev/cobblesurvival/zone/Guardians.kt diff --git a/README.md b/README.md index 6667bbf..e6bff71 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Lays the zone plan in `world/zones.json` onto the island (from the seed's biome ```bash 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 lists the result 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`. +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/fieldguide/build.py diff --git a/datapacks/core/data/cobblesurvival/guardians/button_isle.json b/datapacks/core/data/cobblesurvival/guardians/button_isle.json new file mode 100644 index 0000000..36ffdf4 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/guardians/button_isle.json @@ -0,0 +1,17 @@ +{ + "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", + "zone": "button_isle", + "pokemon": "pachirisu level=15 uncatchable=yes", + "scale": 1.5, + "drops": [ + { + "item": "cobblemon:poke_ball", + "count": 10 + }, + { + "item": "cobblemon:potion", + "count": 4 + } + ], + "respawn_hours": 5 +} diff --git a/datapacks/core/data/cobblesurvival/guardians/deep_dark.json b/datapacks/core/data/cobblesurvival/guardians/deep_dark.json new file mode 100644 index 0000000..07654cf --- /dev/null +++ b/datapacks/core/data/cobblesurvival/guardians/deep_dark.json @@ -0,0 +1,17 @@ +{ + "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", + "zone": "deep_dark", + "pokemon": "dusknoir level=65 uncatchable=yes", + "scale": 1.5, + "drops": [ + { + "item": "cobblemon:icy_rock", + "count": 3 + }, + { + "item": "cobblemon:exp_candy_l", + "count": 2 + } + ], + "respawn_hours": 5 +} diff --git a/datapacks/core/data/cobblesurvival/guardians/deep_sea.json b/datapacks/core/data/cobblesurvival/guardians/deep_sea.json new file mode 100644 index 0000000..f5a4ae0 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/guardians/deep_sea.json @@ -0,0 +1,17 @@ +{ + "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", + "zone": "deep_sea", + "pokemon": "kingdra level=55 uncatchable=yes", + "scale": 1.5, + "drops": [ + { + "item": "cobblemon:mystic_water", + "count": 1 + }, + { + "item": "cobblemon:exp_candy_m", + "count": 3 + } + ], + "respawn_hours": 5 +} diff --git a/datapacks/core/data/cobblesurvival/guardians/frostpeak.json b/datapacks/core/data/cobblesurvival/guardians/frostpeak.json new file mode 100644 index 0000000..8628a18 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/guardians/frostpeak.json @@ -0,0 +1,17 @@ +{ + "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", + "zone": "frostpeak", + "pokemon": "tyranitar level=60 uncatchable=yes", + "scale": 1.5, + "drops": [ + { + "item": "cobblemon:icy_rock", + "count": 3 + }, + { + "item": "cobblemon:exp_candy_l", + "count": 2 + } + ], + "respawn_hours": 5 +} diff --git a/datapacks/core/data/cobblesurvival/guardians/highlands.json b/datapacks/core/data/cobblesurvival/guardians/highlands.json new file mode 100644 index 0000000..e59c98f --- /dev/null +++ b/datapacks/core/data/cobblesurvival/guardians/highlands.json @@ -0,0 +1,17 @@ +{ + "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", + "zone": "highlands", + "pokemon": "cinderace level=40 uncatchable=yes", + "scale": 1.5, + "drops": [ + { + "item": "cobblemon:icy_rock", + "count": 3 + }, + { + "item": "cobblemon:super_potion", + "count": 4 + } + ], + "respawn_hours": 5 +} diff --git a/datapacks/core/data/cobblesurvival/guardians/jungle.json b/datapacks/core/data/cobblesurvival/guardians/jungle.json new file mode 100644 index 0000000..ae1baf9 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/guardians/jungle.json @@ -0,0 +1,17 @@ +{ + "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", + "zone": "jungle", + "pokemon": "tangrowth level=35 uncatchable=yes", + "scale": 1.5, + "drops": [ + { + "item": "cobblemon:pecha_berry", + "count": 8 + }, + { + "item": "cobblemon:exp_candy_m", + "count": 2 + } + ], + "respawn_hours": 5 +} diff --git a/datapacks/core/data/cobblesurvival/guardians/mire.json b/datapacks/core/data/cobblesurvival/guardians/mire.json new file mode 100644 index 0000000..a80cf87 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/guardians/mire.json @@ -0,0 +1,17 @@ +{ + "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", + "zone": "mire", + "pokemon": "arcanine level=43 uncatchable=yes", + "scale": 1.5, + "drops": [ + { + "item": "cobblemon:icy_rock", + "count": 3 + }, + { + "item": "cobblemon:super_potion", + "count": 4 + } + ], + "respawn_hours": 5 +} diff --git a/datapacks/core/data/cobblesurvival/guardians/southwold.json b/datapacks/core/data/cobblesurvival/guardians/southwold.json new file mode 100644 index 0000000..880ca6b --- /dev/null +++ b/datapacks/core/data/cobblesurvival/guardians/southwold.json @@ -0,0 +1,17 @@ +{ + "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", + "zone": "southwold", + "pokemon": "sigilyph level=23 uncatchable=yes", + "scale": 1.5, + "drops": [ + { + "item": "cobblemon:great_ball", + "count": 6 + }, + { + "item": "cobblemon:exp_candy_s", + "count": 4 + } + ], + "respawn_hours": 5 +} diff --git a/datapacks/core/data/cobblesurvival/guardians/sunscorch.json b/datapacks/core/data/cobblesurvival/guardians/sunscorch.json new file mode 100644 index 0000000..20a9877 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/guardians/sunscorch.json @@ -0,0 +1,17 @@ +{ + "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", + "zone": "sunscorch", + "pokemon": "delphox level=50 uncatchable=yes", + "scale": 1.5, + "drops": [ + { + "item": "cobblemon:heat_rock", + "count": 3 + }, + { + "item": "cobblemon:exp_candy_l", + "count": 2 + } + ], + "respawn_hours": 5 +} diff --git a/datapacks/core/data/cobblesurvival/guardians/westmarch.json b/datapacks/core/data/cobblesurvival/guardians/westmarch.json new file mode 100644 index 0000000..51759b0 --- /dev/null +++ b/datapacks/core/data/cobblesurvival/guardians/westmarch.json @@ -0,0 +1,17 @@ +{ + "_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.", + "zone": "westmarch", + "pokemon": "carbink level=30 uncatchable=yes", + "scale": 1.5, + "drops": [ + { + "item": "cobblemon:pecha_berry", + "count": 8 + }, + { + "item": "cobblemon:exp_candy_m", + "count": 2 + } + ], + "respawn_hours": 5 +} diff --git a/docs/balance.md b/docs/balance.md index 7ffe3d2..256a078 100644 --- a/docs/balance.md +++ b/docs/balance.md @@ -22,7 +22,10 @@ Every tunable lives in a config file or in this table. **Placeholder** means gue | Windswept hills temperature | 45–72 °F (Cold Sweat: 24–58 °F) | Placeholder | `config/coldsweat/world.toml` | They lie in the starter lowlands too | | Insulating armour | fur set (cold 10/14/12/10), hide set (heat 10/14/12/10), as Cold Sweat; leather armour none (Cold Sweat: 5/7/6/5 both) | Placeholder | `config/coldsweat/item.toml` | Frostpeak at noon: about 1 min bare, 14 min in fur; Sunscorch at noon: about 2 min bare, 25 min in hide | | 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) | Placeholder | `config/coldsweat/item.toml` | | -| Goat Fur / Hoglin Hide recipes | 8 wool + 1 Heat Rock → 4 Goat Fur; 8 leather + 1 Icy Rock → 4 Hoglin Hide | Placeholder | `kubejs/server_scripts/insulation.js` | Heat and Icy Rocks come from the guardians before Frostpeak and Sunscorch (archaeology finds too) | +| 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) | Placeholder | `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 | Placeholder | `tools/zone_spawns.py` (`GUARDIAN_*`), `world/zones.json` → `guardian.species` | | +| Guardian drops | Button Isle 10 Poké Balls + 4 Potions; Southwold 6 Great Balls + 4 Exp. Candy S; Westmarch and Jungle 8 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 | Placeholder | `world/zones.json` → `guardian.drops` | 3 rocks make one full fur or hide set | | Healing while sleeping | off (`healPercent` 0) | Decided (roadmap) | `config/cobblemon/main.json` | | | Loot container refresh | 1 hour (72,000 ticks of game time) after first opened, all containers | Decided (Adrian, 2026-10-29) | `config/lootr-common.toml` | Tiering by structure/biome later | | Gamerules in the template | `doMobSpawning false` | Decided (Adrian, 2026-10-28; may be reverted later) | `world/world.json` → `gamerules` | Pokémon spawning is Cobblemon's own `doPokemonSpawning` | diff --git a/docs/checklists/phase-3.md b/docs/checklists/phase-3.md index 810f4f5..8be01f5 100644 --- a/docs/checklists/phase-3.md +++ b/docs/checklists/phase-3.md @@ -17,7 +17,9 @@ Open `build/zone-map.png` (the island coloured by zone) and read the draft in `d - **Heat and cold** (step 5, Cold Sweat). Automated: `cold_needs_fur`, `heat_needs_hide` and `lowlands_are_mild` on the island in `tools/quick_check.py`. -Still to come: Alpha guardians with the boss drops that help the next zone (including the Heat and Icy Rocks), the Mire's poison, soft level pressure, flyers. +- **Alpha guardians** (step 4). One per zone at a fixed spot, back 5 hours after being beaten; drops help with the next zone (`docs/zone-species.md` lists them). Automated: `guardian_appears_and_returns`. + +Still to come: the Mire's poison (and the wading gear the Westmarch and Jungle guardians should drop for it), soft level pressure, flyers. ## In-game @@ -27,3 +29,4 @@ Still to come: Alpha guardians with the boss drops that help the next zone (incl - [ ] **Frostpeak cold:** walk onto the jagged peaks without gear: Cold Sweat's thermometer drops and after a minute or so you take damage (not during the first 5 minutes of a new world: grace period). In the fur set (Goat Fur from 8 wool + 1 Heat Rock) you last many minutes. - [ ] **Sunscorch heat:** the desert at noon overheats you in a couple of minutes; at night it's fine. The hide set (Hoglin Hide from 8 leather + 1 Icy Rock) or a Fire Resistance potion keeps you going. - [ ] **Lowlands:** a night in Southwold or on Button Isle without gear never gets dangerous. +- [ ] **Guardians:** `/cobblesurvival guardians` (operator) lists each zone's guardian and spot. Walk there: a big Pokémon appears, can't be caught, and beating it puts its drops in your inventory with a gold message. It's gone for 5 hours of play. diff --git a/docs/design.md b/docs/design.md index 3a4b2c6..73de5a1 100644 --- a/docs/design.md +++ b/docs/design.md @@ -109,6 +109,12 @@ Order of play the bands suggest: Button Isle → Southwold → Westmarch or Jung - Basalt Furnace got Durant, Torkoal and Heatmor pinned to it: its own lines were all big (Charizard, Turtonator, Centiskorch, …) and Cobblemon only spawns a Pokémon where its body fits, so cramped basalt caves stayed empty. - Tested: `spawn_pools_are_zoned` (no Cobblemon default spawn is left; every entry has a zone and spawns within its band) and `spawn_survey` (a player at a point deep inside each zone is offered only that zone's spawns, and at least one), both in `tools/quick_check.py`. +**Alpha guardians** (Phase 3, step 4; Adrian, 2026-10-29: fixed spots, back 5 hours after being beaten): +- One per zone except the Shallows, Lush Caves and Dripstone; the Basalt Furnace gets its boss in Phase 7. Each stands at its zone's sample point (deep inside the zone; a cave floor for the Deep Dark) and only appears while a player is within 96 blocks; it wanders no further than 24 blocks. +- The guardian is the strongest Pokémon that spawns walking (or, at sea, swimming) in its zone, five levels above the band, half again as big, and uncatchable: Pachirisu (Button Isle), Sigilyph (Southwold), Carbink (Westmarch), Tangrowth (Jungle), Arcanine (Mire), Cinderace (Highlands), Delphox (Sunscorch), Tyranitar (Frostpeak), Kingdra (Deep Sea), Dusknoir (Deep Dark). `world/zones.json` → `guardian.species` overrides the pick. +- Beating it in a battle gives every player in that battle its drops (the chain above: Icy Rocks before Sunscorch, Heat Rocks before Frostpeak, ...). It returns after 5 hours of game time, which only passes while the server runs. Cobblemon's Habitat Blocks were the roadmap's idea, but they can't wait a set time after a defeat or carry custom drops, so the companion mod runs guardians (`zone/Guardians.kt`; state in the world save; `/cobblesurvival guardians` for operators). Definitions: `datapacks/core/data/cobblesurvival/guardians/.json`, written by `tools/zone_spawns.py`. +- Tested: `guardian_appears_and_returns` (appears big and uncatchable at its level, beaten in a real battle, drops arrive, stays away, returns after its time). + **Hazards** (Phase 3, step 5), with Cold Sweat 2.4.4: - Cold and heat follow the biome: Frostpeak's jagged peaks freeze an unprotected player in about a minute even at noon, Sunscorch's desert and badlands overheat one in about two minutes at noon; deserts cool off at night. Cold Sweat's insulation doesn't move the comfortable range, it slows how fast the body drifts out of it. - Only Cold Sweat's fur set (cold) and hide set (heat) really insulate: about 14 minutes in Frostpeak at noon and 25 in Sunscorch, longer with extra fur or hide sewn in. Their materials need boss drops (Adrian's rule): Goat Fur from wool and a Heat Rock (Sunscorch's guardian), Hoglin Hide from leather and an Icy Rock (the Mire's and the Highlands' guardians), `kubejs/server_scripts/insulation.js`. Leather armour no longer insulates by itself; sewn-in leather and wool only a little. Fire Resistance and Cold Sweat's own potions and warm/cold food still work. diff --git a/docs/zone-species.md b/docs/zone-species.md index 1c984fb..0a8be2f 100644 --- a/docs/zone-species.md +++ b/docs/zone-species.md @@ -57,3 +57,20 @@ Duskull (Dusclops, Dusknoir), Feebas (Milotic), Greavard (Houndstone), Mimikyu, ## Basalt Furnace (Lv 55–70, 9 lines) Charmander (Charizard), Durant, Gligar (Gliscor), Heatmor, Houndour (Houndoom), Magby (Magmortar), Sizzlipede (Centiskorch), Torkoal, Turtonator + +## Alpha guardians + +One per zone at its sample point, uncatchable, five levels above the band; it returns 5 hours after it is beaten. Its drops help with the next zone. + +| Zone | Guardian | Level | Drops | +| --- | --- | --- | --- | +| Button Isle | Pachirisu | 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 | +| Mangrove Mire | Arcanine | 43 | 3× icy_rock, 4× super_potion | +| Verdant Jungle | Tangrowth | 35 | 8× pecha_berry, 2× exp_candy_m | +| Highland Meadows | Cinderace | 40 | 3× icy_rock, 4× super_potion | +| Westmarch | Carbink | 30 | 8× pecha_berry, 2× exp_candy_m | +| Southwold | Sigilyph | 23 | 6× great_ball, 4× exp_candy_s | +| Deep Sea | Kingdra | 55 | 1× mystic_water, 3× exp_candy_m | +| Deep Dark | Dusknoir | 65 | 3× icy_rock, 2× exp_candy_l | diff --git a/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt b/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt index 1517cf7..d977c20 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt @@ -7,6 +7,7 @@ import dev.cobblesurvival.dev.BiomePreview import dev.cobblesurvival.permadeath.Permadeath import dev.cobblesurvival.selftest.SelfTest import dev.cobblesurvival.world.OnlyPokemon +import dev.cobblesurvival.zone.Guardians import dev.cobblesurvival.zone.ZoneCondition import dev.cobblesurvival.zone.Zones import net.minecraft.commands.Commands @@ -55,6 +56,7 @@ class CobbleSurvival { Permadeath.register() ZoneCondition.register() NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners) + Guardians.register() SelfTest.register() } @@ -93,6 +95,11 @@ class CobbleSurvival { source.sendSuccess({ Component.literal(text) }, false) 1 }) + .then(Commands.literal("guardians").executes { context -> + val lines = Guardians.status(context.source.server.overworld()) + context.source.sendSuccess({ Component.literal(lines.joinToString("\n").ifEmpty { "no guardians loaded" }) }, false) + 1 + }) if (SelfTest.enabled) root.then(SelfTest.command()) event.dispatcher.register(root) } diff --git a/mod/src/main/kotlin/dev/cobblesurvival/selftest/GuardianTests.kt b/mod/src/main/kotlin/dev/cobblesurvival/selftest/GuardianTests.kt new file mode 100644 index 0000000..80d5e17 --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/selftest/GuardianTests.kt @@ -0,0 +1,75 @@ +package dev.cobblesurvival.selftest + +import com.cobblemon.mod.common.battles.BattleBuilder +import com.cobblemon.mod.common.battles.MoveActionResponse +import com.cobblemon.mod.common.entity.pokemon.PokemonEntity +import com.cobblemon.mod.common.util.isInBattle +import dev.cobblesurvival.zone.Guardians +import dev.cobblesurvival.zone.Zones +import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.gametest.framework.GameTestAssertException +import net.minecraft.world.level.levelgen.Heightmap +import kotlin.math.abs + +/** + * Roadmap Phase 3, Alpha guardians (Adrian: fixed spots, back 5 hours after being beaten). On Button Isle's spot: + * a player arriving makes the guardian appear, big and uncatchable at its level; beating it in a battle gives the + * player its drops; it stays away, and returns once its time is up. Runs in a batch of its own, since the guardian + * state is shared. + */ +object GuardianTests { + private const val ZONE = "button_isle" + + fun all() = listOf(SelfTestCase("guardian_appears_and_returns", 2400, ::guardian, batch = "guardian")) + + private fun guardian(s: Scenario) { + val definition = Guardians.definitions[ZONE] ?: throw GameTestAssertException("no guardian defined for $ZONE") + val sample = Zones.samples[ZONE] ?: throw GameTestAssertException("no sample point for $ZONE") + val level = s.level + Guardians.resetForTest(level, ZONE) + s.afterTest { Guardians.resetForTest(level, ZONE) } + + val player = s.player() + s.give(player, s.pokemon("mewtwo", 100, "earthquake")) // a spread move needs no target + s.answerBattles(player) { _, _ -> MoveActionResponse("earthquake") } + lateinit var guardian: PokemonEntity + val expectedLevel = Regex("level=(\\d+)").find(definition.pokemon)?.groupValues?.get(1)?.toInt() + + s.helper.startSequence() + .thenExecute { + level.getChunk(sample.x shr 4, sample.z shr 4) + val y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z) + s.moveTo(player, sample.x + 6.5, y.toDouble(), sample.z + 0.5) + } + .thenWaitUntil { + guardian = Guardians.guardianOf(level, ZONE) ?: throw GameTestAssertException("no guardian appeared near the player") + } + .thenExecute { + val pokemon = guardian.pokemon + s.check(pokemon.isUncatchable()) { "the guardian can be caught" } + s.check(abs(pokemon.scaleModifier - definition.scale) < 0.01f) { "the guardian is size ${pokemon.scaleModifier}, not ${definition.scale}" } + s.check(expectedLevel == null || pokemon.level == expectedLevel) { "the guardian is level ${pokemon.level}, not $expectedLevel" } + s.moveTo(player, guardian.x + 3, guardian.y, guardian.z) + } + .thenWaitUntil { s.check(guardian.pokemon.entity === guardian) { "the guardian has not ticked yet" } } + .thenExecute { s.started(BattleBuilder.pve(player, guardian)) } + .thenWaitUntil { + s.check(!player.isInBattle() && Guardians.defeatedAt(level, ZONE) != null) { "the guardian isn't beaten yet: ${s.battleState(player)}" } + } + .thenExecute { + for ((item, count) in definition.drops) { + val have = player.inventory.countItem(BuiltInRegistries.ITEM.get(item)) + s.check(have >= count) { "the player got $have of $item from the guardian, not $count" } + } + } + .thenIdle(100) + .thenExecute { + s.check(Guardians.guardianOf(level, ZONE) == null) { "the guardian came back right away" } + Guardians.expireDefeatForTest(level, ZONE) + } + .thenWaitUntil { + s.check(Guardians.guardianOf(level, ZONE) != null) { "the guardian didn't come back after its time" } + } + .thenSucceed() + } +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/selftest/SelfTest.kt b/mod/src/main/kotlin/dev/cobblesurvival/selftest/SelfTest.kt index ccd5c01..4dcb995 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/selftest/SelfTest.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/selftest/SelfTest.kt @@ -48,7 +48,7 @@ object SelfTest { private const val FORCED_CHUNKS = 9 private val cases: List get() = - PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all() + PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all() + GuardianTests.all() private var running: List = emptyList() private var forced: List = emptyList() diff --git a/mod/src/main/kotlin/dev/cobblesurvival/zone/Guardians.kt b/mod/src/main/kotlin/dev/cobblesurvival/zone/Guardians.kt new file mode 100644 index 0000000..8427113 --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/zone/Guardians.kt @@ -0,0 +1,263 @@ +package dev.cobblesurvival.zone + +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 com.google.gson.Gson +import com.google.gson.JsonElement +import dev.cobblesurvival.CobbleSurvival +import net.minecraft.ChatFormatting +import net.minecraft.core.BlockPos +import net.minecraft.core.HolderLookup +import net.minecraft.core.SectionPos +import net.minecraft.core.registries.BuiltInRegistries +import net.minecraft.nbt.CompoundTag +import net.minecraft.nbt.NbtUtils +import net.minecraft.network.chat.Component +import net.minecraft.resources.ResourceLocation +import net.minecraft.server.level.ServerLevel +import net.minecraft.server.packs.resources.ResourceManager +import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener +import net.minecraft.util.profiling.ProfilerFiller +import net.minecraft.world.item.ItemStack +import net.minecraft.world.level.ChunkPos +import net.minecraft.world.level.levelgen.Heightmap +import net.minecraft.world.level.saveddata.SavedData +import net.neoforged.neoforge.common.NeoForge +import net.neoforged.neoforge.event.AddReloadListenerEvent +import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent +import net.neoforged.neoforge.event.tick.ServerTickEvent +import java.util.UUID +import kotlin.math.abs + +/** + * One Alpha guardian per zone (roadmap Phase 3): a big, uncatchable Pokémon standing at the zone's sample point. + * Beating it in a battle gives every player in that battle its drops, which help with the next zone; it returns + * `respawn_hours` of game time later (Adrian: fixed spots, 5 hours). It only appears while a player is near, and + * wanders no further than [LEASH] blocks from its spot. Definitions: `data/cobblesurvival/guardians/.json`, + * written by tools/zone_spawns.py; state (which entity, when beaten, where it stands) lives in the world save. + */ +object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") { + /** Key in the guardian Pokémon's persistent data; its value is the zone id. */ + const val TAG = "cobblesurvival_guardian" + private const val CHECK_TICKS = 40 + private const val NEAR = 96.0 + private const val LEASH = 24.0 + private const val TICKS_PER_HOUR = 72_000L + + class Definition( + val zone: String, + val pokemon: String, + val scale: Float, + val drops: List>, + val respawnTicks: Long, + ) + + var definitions: Map = emptyMap() + private set + + fun register() { + NeoForge.EVENT_BUS.addListener { it.addListener(this) } + NeoForge.EVENT_BUS.addListener(::onServerTick) + NeoForge.EVENT_BUS.addListener(::onEntityJoin) + CobblemonEvents.BATTLE_FAINTED.subscribe(Priority.NORMAL, ::onFainted) + } + + override fun apply(files: Map, resources: ResourceManager, profiler: ProfilerFiller) { + definitions = files.values.map { it.asJsonObject }.associate { json -> + val zone = json.get("zone").asString + zone to Definition( + zone = zone, + pokemon = json.get("pokemon").asString, + scale = json.get("scale")?.asFloat ?: 1f, + drops = json.getAsJsonArray("drops").map { drop -> + val d = drop.asJsonObject + ResourceLocation.parse(d.get("item").asString) to d.get("count").asInt + }, + respawnTicks = (json.get("respawn_hours").asDouble * TICKS_PER_HOUR).toLong(), + ) + } + CobbleSurvival.LOGGER.info("Loaded {} zone guardians", definitions.size) + } + + private fun onServerTick(event: ServerTickEvent.Post) { + val server = event.server + if (server.tickCount % CHECK_TICKS != 0 || definitions.isEmpty()) return + val level = server.overworld() + val state = GuardianState.of(level) + for ((zone, definition) in definitions) { + val sample = Zones.samples[zone] ?: continue + val entry = state.entry(zone) + val guardian = entry.entity?.let { level.getEntity(it) as? PokemonEntity }?.takeIf { it.isAlive } + if (guardian != null) { + entry.spot?.let { leash(guardian, it) } + continue + } + val beaten = entry.defeatedAt + if (beaten != null && level.gameTime - beaten < definition.respawnTicks) continue + if (level.players().none { abs(it.x - sample.x) < NEAR && abs(it.z - sample.z) < NEAR }) continue + // The old guardian may still be loading from disk with its chunk; wait for that. + val chunk = ChunkPos(SectionPos.blockToSectionCoord(sample.x), SectionPos.blockToSectionCoord(sample.z)) + if (!level.areEntitiesLoaded(chunk.toLong())) continue + val spot = entry.spot ?: spotFor(level, zone, sample)?.also { entry.spot = it } ?: continue + val spawned = spawn(level, definition, spot) ?: continue + entry.entity = spawned.uuid + } + } + + private fun spawn(level: ServerLevel, definition: Definition, spot: BlockPos): PokemonEntity? { + val pokemon = PokemonProperties.parse(definition.pokemon).create() + pokemon.scaleModifier = definition.scale + pokemon.persistentData.putString(TAG, definition.zone) + val entity = PokemonEntity(level, pokemon) + entity.moveTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5, level.random.nextFloat() * 360f, 0f) + entity.setPersistenceRequired() + if (!level.addFreshEntity(entity)) return null + CobbleSurvival.LOGGER.info("Guardian of {} appeared at {}", definition.zone, spot.toShortString()) + return entity + } + + private fun leash(guardian: PokemonEntity, spot: BlockPos) { + if (guardian.isBattling) return + if (guardian.distanceToSqr(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5) > LEASH * LEASH) { + guardian.teleportTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5) + } + } + + /** Where the guardian stands: the surface at the sample point, or for a cave zone open floor near it. */ + private fun spotFor(level: ServerLevel, zone: String, sample: ZoneSample): BlockPos? { + val caveY = sample.y ?: return BlockPos(sample.x, level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z), sample.z) + for (radius in 0..16 step 2) for (dx in -radius..radius) for (dz in -radius..radius) { + if (maxOf(abs(dx), abs(dz)) != radius) continue + for (dy in 0..24) for (sign in intArrayOf(1, -1)) { + val pos = BlockPos(sample.x + dx, caveY + sign * dy, sample.z + dz) + val open = (0 until 3).all { level.getBlockState(pos.above(it)).isAir } + if (open && level.getBlockState(pos.below()).isSolid && Zones.at(level, pos)?.id == zone) return pos + } + } + return null + } + + /** A second copy of a zone's guardian (e.g. one that wandered into a chunk loading late) never joins. */ + private fun onEntityJoin(event: EntityJoinLevelEvent) { + val entity = event.entity as? PokemonEntity ?: return + val level = event.level as? ServerLevel ?: return + val zone = entity.pokemon.persistentData.getString(TAG).takeIf { it.isNotEmpty() } ?: return + val entry = GuardianState.of(level.server.overworld()).entry(zone) + val current = entry.entity + if (current == null || current == entity.uuid) { + entry.entity = entity.uuid + return + } + if ((level.getEntity(current) as? PokemonEntity)?.isAlive == true) event.isCanceled = true else entry.entity = entity.uuid + } + + private fun onFainted(event: BattleFaintedEvent) { + val pokemon = event.killed.effectedPokemon + val zone = pokemon.persistentData.getString(TAG).takeIf { it.isNotEmpty() } ?: return + val definition = definitions[zone] ?: return + val players = event.battle.players + val level = players.firstOrNull()?.server?.overworld() ?: return + val state = GuardianState.of(level) + state.entry(zone).apply { + defeatedAt = level.gameTime + entity = null + } + val zoneName = Zones.byId[zone]?.name ?: zone + val hours = definition.respawnTicks / TICKS_PER_HOUR + for (player in players) { + for ((item, count) in definition.drops) { + val stack = ItemStack(BuiltInRegistries.ITEM.get(item), count) + if (!player.inventory.add(stack)) player.drop(stack, false) + } + player.sendSystemMessage( + Component.translatableWithFallback( + "message.cobblesurvival.guardian.beaten", + "The guardian of %s is beaten! It returns in %s hours.", zoneName, hours, + ).withStyle(ChatFormatting.GOLD) + ) + } + CobbleSurvival.LOGGER.info("Guardian of {} beaten by {}", zone, players.joinToString { it.gameProfile.name }) + } + + /** One line per zone for `/cobblesurvival guardians`. */ + fun status(level: ServerLevel): List { + val state = GuardianState.of(level) + return definitions.keys.sorted().map { zone -> + val entry = state.entry(zone) + val alive = entry.entity?.let { level.getEntity(it) }?.isAlive == true + val beaten = entry.defeatedAt + val text = when { + alive -> "here" + beaten != null && level.gameTime - beaten < definitions.getValue(zone).respawnTicks -> { + val left = definitions.getValue(zone).respawnTicks - (level.gameTime - beaten) + "beaten, back in %dh %02dm".format(left / TICKS_PER_HOUR, left % TICKS_PER_HOUR / 1200) + } + else -> "waiting for a player to come near" + } + "$zone: $text" + (entry.spot?.let { " at ${it.toShortString()}" } ?: "") + } + } + + /** For the self-tests: forget the zone's guardian (despawning it) or pretend it was beaten long enough ago. */ + fun resetForTest(level: ServerLevel, zone: String) { + val entry = GuardianState.of(level).entry(zone) + entry.entity?.let { level.getEntity(it) }?.discard() + entry.entity = null + entry.defeatedAt = null + } + + fun expireDefeatForTest(level: ServerLevel, zone: String) { + val entry = GuardianState.of(level).entry(zone) + entry.defeatedAt = entry.defeatedAt?.minus(definitions.getValue(zone).respawnTicks) + } + + fun guardianOf(level: ServerLevel, zone: String): PokemonEntity? = + GuardianState.of(level).entry(zone).entity?.let { level.getEntity(it) as? PokemonEntity }?.takeIf { it.isAlive } + + fun defeatedAt(level: ServerLevel, zone: String): Long? = GuardianState.of(level).entry(zone).defeatedAt +} + +/** Per zone: the guardian entity, when it was last beaten (game time), and its spot. Saved with the overworld. */ +class GuardianState : SavedData() { + class Entry(var entity: UUID? = null, var defeatedAt: Long? = null, var spot: BlockPos? = null) + + private val entries = HashMap() + + fun entry(zone: String): Entry = entries.getOrPut(zone) { Entry() } + + override fun save(tag: CompoundTag, registries: HolderLookup.Provider): CompoundTag { + for ((zone, entry) in entries) { + val e = CompoundTag() + entry.entity?.let { e.putUUID("entity", it) } + entry.defeatedAt?.let { e.putLong("defeated_at", it) } + entry.spot?.let { e.put("spot", NbtUtils.writeBlockPos(it)) } + tag.put(zone, e) + } + return tag + } + + // Changes to entries happen in place; mark dirty generously so they get saved. + override fun isDirty() = true + + companion object { + private val FACTORY = Factory(::GuardianState, ::load) + + fun of(level: ServerLevel): GuardianState = level.dataStorage.computeIfAbsent(FACTORY, "cobblesurvival_guardians") + + private fun load(tag: CompoundTag, registries: HolderLookup.Provider): GuardianState { + val state = GuardianState() + for (zone in tag.allKeys) { + val e = tag.getCompound(zone) + state.entries[zone] = Entry( + entity = if (e.hasUUID("entity")) e.getUUID("entity") else null, + defeatedAt = if (e.contains("defeated_at")) e.getLong("defeated_at") else null, + spot = NbtUtils.readBlockPos(e, "spot").orElse(null), + ) + } + return state + } + } +} diff --git a/mod/src/main/resources/assets/cobblesurvival/lang/en_us.json b/mod/src/main/resources/assets/cobblesurvival/lang/en_us.json index 7c04226..053d43c 100644 --- a/mod/src/main/resources/assets/cobblesurvival/lang/en_us.json +++ b/mod/src/main/resources/assets/cobblesurvival/lang/en_us.json @@ -1,5 +1,6 @@ { "generator.cobblesurvival.island": "Cobblemon Survival Island", "message.cobblesurvival.permadeath.lost": "%s fainted and is gone for good.", - "message.cobblesurvival.permadeath.saved": "%s's Revival Charm saved it and crumbled to dust." + "message.cobblesurvival.permadeath.saved": "%s's Revival Charm saved it and crumbled to dust.", + "message.cobblesurvival.guardian.beaten": "The guardian of %s is beaten! It returns in %s hours." } diff --git a/pack/index.toml b/pack/index.toml index 71f5c7b..7910732 100644 --- a/pack/index.toml +++ b/pack/index.toml @@ -50,7 +50,7 @@ hash = "6b1b127b16a87b846a5abdbdb442edb8cbd9386562081309a3808eed1325ed1e" [[files]] file = "kubejs/server_scripts/insulation.js" -hash = "731756b6277ebc41cd258c8f7d1c519593c43914e8b4426ce230d77d5b6d9fe7" +hash = "026dc5ab98cb13c906d5b6974ea7624014e804c58141f01ec57e077e1e8bb339" [[files]] file = "kubejs/server_scripts/revival_charm.js" diff --git a/pack/kubejs/server_scripts/insulation.js b/pack/kubejs/server_scripts/insulation.js index 647f29c..7c04baa 100644 --- a/pack/kubejs/server_scripts/insulation.js +++ b/pack/kubejs/server_scripts/insulation.js @@ -4,10 +4,10 @@ // - Goat Fur (cold insulation, Frostpeak): wool and a Heat Rock, which Sunscorch's guardian drops; // - Hoglin Hide (heat insulation, Sunscorch): leather and an Icy Rock, which the guardians of the // Mangrove Mire and Highland Meadows drop. -// Cold Sweat's own armour recipes then turn these into the fur and hide sets. Placeholder amounts -// (docs/balance.md). +// Cold Sweat's own armour recipes then turn these into the fur and hide sets: a set takes 24, so three +// rocks, which is one guardian's drop. Placeholder amounts (docs/balance.md). ServerEvents.recipes(event => { - event.shaped('4x cold_sweat:goat_fur', [ + event.shaped('8x cold_sweat:goat_fur', [ 'WWW', 'WHW', 'WWW' @@ -16,7 +16,7 @@ ServerEvents.recipes(event => { H: 'cobblemon:heat_rock' }).id('cobblesurvival:goat_fur') - event.shaped('4x cold_sweat:hoglin_hide', [ + event.shaped('8x cold_sweat:hoglin_hide', [ 'LLL', 'LIL', 'LLL' diff --git a/pack/pack.toml b/pack/pack.toml index a65945d..0352427 100644 --- a/pack/pack.toml +++ b/pack/pack.toml @@ -6,7 +6,7 @@ pack-format = "packwiz:1.1.0" [index] file = "index.toml" hash-format = "sha256" -hash = "a866bfd2522a67b61718bc72fb9e9ce49dadbdb0daf01b7c1421385ced0f1deb" +hash = "090cc213fdd22bcaf74234d279c438a3aff08b9eb87b169802f66ae33b9b672f" [versions] minecraft = "1.21.1" diff --git a/tools/zone_spawns.py b/tools/zone_spawns.py index dfef003..5629595 100755 --- a/tools/zone_spawns.py +++ b/tools/zone_spawns.py @@ -21,6 +21,9 @@ datapacks/core/data/cobblesurvival/spawn_pool_world/.json: doesn't spawn there. If no stage fits, the closest one spawns across the whole band. 3. world/zones.json → "species" pins a line to a zone by hand: {"pikachu": "southwold"}. 4. In cave zones, sky-light minimums, "can see sky" and the "natural" ground preset are dropped. + 5. 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 + 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. @@ -41,6 +44,9 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent OUT = ROOT / "datapacks/core/data/cobblesurvival/spawn_pool_world" +GUARDIANS = ROOT / "datapacks/core/data/cobblesurvival/guardians" +GUARDIAN_SCALE = 1.5 # "Alpha": half again as big as a normal one +GUARDIAN_LEVELS_ABOVE_BAND = 5 REPORT = ROOT / "docs/zone-species.md" EXCLUDED_LABELS = {"legendary", "mythical", "ultra_beast", "paradox", "restricted"} MIN_CELLS = 5 # a zone "has" a biome when at least this many grid cells of it lie in the zone @@ -242,6 +248,29 @@ def main(): "spawns": sorted(spawns, key=lambda s: s["id"]), }, indent=1) + "\n") + GUARDIANS.mkdir(parents=True, exist_ok=True) + for old in GUARDIANS.glob("*.json"): + old.unlink() + guardians = {} + for zid, zone in zones.items(): + config = zone.get("guardian") + if not config: + continue + # A guardian stands its ground: on land a walking spawn, at sea a swimming one, never a fishing catch. + kinds = {"submerged", "surface"} if "water" in zone.get("rule", {}) else {"grounded"} + spawning = {s["pokemon"].split()[0].lower() for s in pools.get(zid, []) if s.get("spawnablePositionType") in kinds} + species_name = config.get("species") or max(spawning, key=lambda n: bst.get(f"cobblemon:{n}", 0)) + level = zone["levels"][1] + GUARDIAN_LEVELS_ABOVE_BAND + guardians[zid] = species_name + (GUARDIANS / f"{zid}.json").write_text(json.dumps({ + "_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, + "drops": [{"item": item, "count": count} for item, count in config["drops"]], + "respawn_hours": plan["guardian_respawn_hours"], + }, indent=1) + "\n") + lines_out = ["# Pokémon per zone", "", "Generated by `tools/zone_spawns.py` from Cobblemon's spawn data and `world/zones.json`; " "do not edit by hand. Each evolution line spawns in one zone; the stages listed are the ones " @@ -251,7 +280,16 @@ def main(): rows = sorted(report.get(zid, [])) lines_out += [f"## {zone['name']} (Lv {zone['levels'][0]}–{zone['levels'][1]}, {len(rows)} lines)", ""] lines_out += [", ".join(f"{root}" + (f" ({', '.join(n)})" if n != [root] else "") for root, n in rows) or "none", ""] - REPORT.write_text("\n".join(lines_out)) + lines_out += ["## Alpha guardians", "", + f"One per zone at its sample point, uncatchable, five levels above the band; it returns " + f"{plan['guardian_respawn_hours']} hours after it is beaten. Its drops help with the next zone.", "", + "| Zone | Guardian | Level | Drops |", "| --- | --- | --- | --- |"] + for zid, species_name in guardians.items(): + zone = zones[zid] + drop_text = ", ".join(f"{count}× {item.split(':')[1]}" for item, count in zone["guardian"]["drops"]) + lines_out.append(f"| {zone['name']} | {species[f'cobblemon:{species_name}']['name'] if f'cobblemon:{species_name}' in species else species_name} " + f"| {zone['levels'][1] + GUARDIAN_LEVELS_ABOVE_BAND} | {drop_text} |") + REPORT.write_text("\n".join(lines_out) + "\n") unplaced = [r for r in lines if r not in home] for zid, zone in zones.items(): diff --git a/world/zones.json b/world/zones.json index 38ed162..25e8303 100644 --- a/world/zones.json +++ b/world/zones.json @@ -1,39 +1,40 @@ { - "_comment": "Zone plan for Phase 3 (model approved by Adrian 2026-10-29; levels are placeholders, see docs/balance.md). tools/zone_layout.py assigns each surface cell of the island to the first zone whose rule matches, in this order, and renders build/zone-map.png. Rules: 'within' = distance from 0,0 in blocks; 'biomes' = cells of these biomes, clusters smaller than min_cells dropped, plus 'absorb' = cells of other biomes within that many blocks of the cluster; 'region' = what is left, by half-plane (x/z bounds in blocks); 'water' = sea cells of these biomes. Biomes in 'inherit' (rivers) take the zone of the land around them. Cave zones follow the cave biome everywhere under the island and are not drawn. 'lines' = how many evolution lines tools/zone_spawns.py aims to give the zone (scaled to the lines available); 'species' pins a line to a zone by hand.", + "_comment": "Zone plan for Phase 3 (model approved by Adrian 2026-10-29; levels are placeholders, see docs/balance.md). tools/zone_layout.py assigns each surface cell of the island to the first zone whose rule matches, in this order, and renders build/zone-map.png. Rules: 'within' = distance from 0,0 in blocks; 'biomes' = cells of these biomes, clusters smaller than min_cells dropped, plus 'absorb' = cells of other biomes within that many blocks of the cluster; 'region' = what is left, by half-plane (x/z bounds in blocks); 'water' = sea cells of these biomes. Biomes in 'inherit' (rivers) take the zone of the land around them. Cave zones follow the cave biome everywhere under the island and are not drawn. 'lines' = how many evolution lines tools/zone_spawns.py aims to give the zone (scaled to the lines available); 'species' pins a line to a zone by hand. 'guardian': the zone's Alpha guardian (tools/zone_spawns.py picks the strongest Pokémon spawning in the zone unless 'species' is given, at the band's top level + 5) and its drops, which help with the next zone; it stands at the zone's sample point and returns guardian_respawn_hours after it is beaten (Adrian, 2026-10-29: fixed spots, 5 hours).", "min_cells": 6, "inherit": ["minecraft:river"], + "guardian_respawn_hours": 5, "species": {"durant": "basalt", "torkoal": "basalt", "heatmor": "basalt"}, "zones": [ { - "id": "button_isle", "lines": 20, "name": "Button Isle", "levels": [2, 10], "hazard": "none", "color": "#a6d96a", + "id": "button_isle", "guardian": {"drops": [["cobblemon:poke_ball", 10], ["cobblemon:potion", 4]]}, "lines": 20, "name": "Button Isle", "levels": [2, 10], "hazard": "none", "color": "#a6d96a", "rule": {"within": 300} }, { - "id": "frostpeak", "lines": 30, "name": "Frostpeak Range", "levels": [40, 55], "hazard": "cold (Cold Sweat): needs wool insulation", "color": "#f2f7ff", + "id": "frostpeak", "guardian": {"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", "lines": 25, "name": "Sunscorch", "levels": [30, 45], "hazard": "heat (Cold Sweat): needs heat insulation", "color": "#f4a443", + "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", "rule": {"biomes": ["minecraft:desert", "minecraft:badlands"]} }, { - "id": "mire", "lines": 25, "name": "Mangrove Mire", "levels": [25, 38], "hazard": "poison area effect (companion mod)", "color": "#3cb5a0", + "id": "mire", "guardian": {"drops": [["cobblemon:icy_rock", 3], ["cobblemon:super_potion", 4]]}, "lines": 25, "name": "Mangrove Mire", "levels": [25, 38], "hazard": "poison area effect (companion mod)", "color": "#3cb5a0", "rule": {"biomes": ["minecraft:mangrove_swamp"]} }, { - "id": "jungle", "lines": 30, "name": "Verdant Jungle", "levels": [18, 30], "hazard": "none (warm and humid, below Cold Sweat's damage)", "color": "#5a8f1e", + "id": "jungle", "guardian": {"drops": [["cobblemon:pecha_berry", 8], ["cobblemon:exp_candy_m", 2]]}, "lines": 30, "name": "Verdant Jungle", "levels": [18, 30], "hazard": "none (warm and humid, below Cold Sweat's damage)", "color": "#5a8f1e", "rule": {"biomes": ["minecraft:jungle"]} }, { - "id": "highlands", "lines": 35, "name": "Highland Meadows", "levels": [25, 35], "hazard": "none", "color": "#e3b7d6", + "id": "highlands", "guardian": {"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}} }, { - "id": "westmarch", "lines": 35, "name": "Westmarch", "levels": [15, 25], "hazard": "none", "color": "#2f8f4e", + "id": "westmarch", "guardian": {"drops": [["cobblemon:pecha_berry", 8], ["cobblemon:exp_candy_m", 2]]}, "lines": 35, "name": "Westmarch", "levels": [15, 25], "hazard": "none", "color": "#2f8f4e", "rule": {"region": {"z_max": 0, "x_max": -250}} }, { - "id": "southwold", "lines": 35, "name": "Southwold", "levels": [8, 18], "hazard": "none", "color": "#7cc576", + "id": "southwold", "guardian": {"drops": [["cobblemon:great_ball", 6], ["cobblemon:exp_candy_s", 4]]}, "lines": 35, "name": "Southwold", "levels": [8, 18], "hazard": "none", "color": "#7cc576", "rule": {"region": {"z_min": 0}} }, { @@ -41,14 +42,14 @@ "rule": {"water": ["minecraft:river", "minecraft:beach", "minecraft:ocean", "minecraft:lukewarm_ocean", "minecraft:warm_ocean"]} }, { - "id": "deep_sea", "lines": 20, "name": "Deep Sea", "levels": [30, 50], "hazard": "none", "color": "#1f4e8c", + "id": "deep_sea", "guardian": {"drops": [["cobblemon:mystic_water", 1], ["cobblemon:exp_candy_m", 3]]}, "lines": 20, "name": "Deep Sea", "levels": [30, 50], "hazard": "none", "color": "#1f4e8c", "rule": {"water": ["minecraft:deep_ocean", "minecraft:deep_lukewarm_ocean"]} } ], "caves": [ {"id": "lush_caves", "lines": 20, "name": "Lush Caves", "biome": "minecraft:lush_caves", "levels": [12, 28], "hazard": "none"}, {"id": "dripstone", "lines": 20, "name": "Dripstone Depths", "biome": "minecraft:dripstone_caves", "levels": [22, 38], "hazard": "none"}, - {"id": "deep_dark", "lines": 20, "name": "Deep Dark", "biome": "minecraft:deep_dark", "levels": [45, 60], "hazard": "darkness (vanilla sculk; no Warden)"}, + {"id": "deep_dark", "guardian": {"drops": [["cobblemon:icy_rock", 3], ["cobblemon:exp_candy_l", 2]]}, "lines": 20, "name": "Deep Dark", "biome": "minecraft:deep_dark", "levels": [45, 60], "hazard": "darkness (vanilla sculk; no Warden)"}, {"id": "basalt", "lines": 15, "name": "Basalt Furnace", "biome": "minecraft:basalt_deltas", "levels": [55, 70], "hazard": "heat (Cold Sweat)"} ] }