Phase 3: Alpha guardians, one per zone, back 5 hours after a defeat
CI / build-and-smoke-test (push) Failing after 7m6s

- 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/<zone>.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 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-29 19:01:21 +01:00
1 parent 398301f0be
commit 73fe71918f
25 files changed
+607 -23

No files matched your search

+1 -1
View File
@@ -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
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
+4 -1
View File
@@ -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` |
+4 -1
View File
@@ -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.
+6
View File
@@ -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/<zone>.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.
+17
View File
@@ -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 |
@@ -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)
}
@@ -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()
}
}
@@ -48,7 +48,7 @@ object SelfTest {
private const val FORCED_CHUNKS = 9
private val cases: List<SelfTestCase> get() =
PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all()
PermadeathTests.all() + SurvivalRulesTests.all() + ZoneTests.all() + HazardTests.all() + GuardianTests.all()
private var running: List<Scenario> = emptyList()
private var forced: List<ChunkPos> = emptyList()
@@ -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/<zone>.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<Pair<ResourceLocation, Int>>,
val respawnTicks: Long,
)
var definitions: Map<String, Definition> = emptyMap()
private set
fun register() {
NeoForge.EVENT_BUS.addListener<AddReloadListenerEvent> { 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<ResourceLocation, JsonElement>, 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<String> {
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<String, Entry>()
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
}
}
}
@@ -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."
}
+1 -1
View File
@@ -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"
+4 -4
View File
@@ -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'
+1 -1
View File
@@ -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"
+39 -1
View File
@@ -21,6 +21,9 @@ datapacks/core/data/cobblesurvival/spawn_pool_world/<zone>.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/<zone>.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():
+12 -11
View File
@@ -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)"}
]
}