Phase 9: safe start island, better quest book, every useful item obtainable
- Button Isle is peaceful: wild Pokémon only fight a player who hit them first (tuning/server.json peaceful_zones); its apricorns and coal make Poké Balls (4 apricorns + coal or charcoal), and the world report checks it has enough. - Quest book: no boilerplate; each zone page says what is particular to it, and every zone that needs gear or a field move to be safe has its own quest. New page on the bigger mods (Cold Sweat, Fight or Flight, Cobblebase, breeding, veins, shared loot, maps and info). - The Mire's poison stops 10 blocks below the surface. - Player battles never cost a Pokémon (CobbleHardcoreMon config, mixins, and the companion mod's own permadeath). - tools/item_audit.py: every item with a use has a source on the island; new sources for the gaps it found (species additions, three recipes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6ace26e012
commit
d29e177f9d
65 files changed
+1920
-364
No files matched your search
@@ -0,0 +1,21 @@
|
||||
package dev.cobblesurvival.mixin;
|
||||
|
||||
import com.cobblemon.mod.common.api.battles.model.PokemonBattle;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Pseudo;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
/**
|
||||
* A battle between players never costs a Pokémon (Adrian, 2026-11-10). The pack's config switches CobbleHardcoreMon's
|
||||
* tracking of player battles off, but a world keeps the server config it was created with: this holds in every world.
|
||||
*/
|
||||
@Pseudo
|
||||
@Mixin(targets = "dev.matthiesen.cobblehardcoremon.common.handlers.PlayerBattles", remap = false)
|
||||
abstract class HardcoreBattlesMixin {
|
||||
@Inject(method = "battleTypeDisabled", at = @At("HEAD"), cancellable = true)
|
||||
private static void cobblesurvival$ignorePlayerBattles(PokemonBattle battle, CallbackInfoReturnable<Boolean> result) {
|
||||
if (battle.isPvP()) result.setReturnValue(true);
|
||||
}
|
||||
}
|
||||
@@ -10,24 +10,25 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
/**
|
||||
* A player's starter only faints, it is never removed (Adrian, 2026-11-09). CobbleHardcoreMon has no exception of its
|
||||
* own: this leaves its Revival Charm alone and skips its removal. Named by string, so the companion mod needs it only at run time.
|
||||
* A player's starter only faints, it is never removed (Adrian, 2026-11-09), and neither is a Pokémon that fainted in a
|
||||
* battle between players (2026-11-10). CobbleHardcoreMon has no such exceptions, and also sweeps parties outside
|
||||
* battles: this leaves their Revival Charm alone and skips the removal. Named by string, so the companion mod needs it only at run time.
|
||||
*/
|
||||
@Pseudo
|
||||
@Mixin(targets = "dev.matthiesen.cobblehardcoremon.common.handlers.PlayerPokeParty", remap = false)
|
||||
abstract class HardcorePartyMixin {
|
||||
@Inject(method = "popPokemonTotem", at = @At("HEAD"), cancellable = true)
|
||||
private void cobblesurvival$keepCharm(Pokemon pokemon, CallbackInfoReturnable<Boolean> result) {
|
||||
if (Permadeath.INSTANCE.isStarter(pokemon)) result.setReturnValue(false);
|
||||
if (Permadeath.INSTANCE.isSpared(pokemon)) result.setReturnValue(false);
|
||||
}
|
||||
|
||||
@Inject(method = "alertPlayerAndRemovedPokemon(Lcom/cobblemon/mod/common/pokemon/Pokemon;)V", at = @At("HEAD"), cancellable = true)
|
||||
private void cobblesurvival$keepStarter(Pokemon pokemon, CallbackInfo info) {
|
||||
if (Permadeath.INSTANCE.isStarter(pokemon)) info.cancel();
|
||||
if (Permadeath.INSTANCE.isSpared(pokemon)) info.cancel();
|
||||
}
|
||||
|
||||
@Inject(method = "alertPlayerAndRemovedPokemon(Lcom/cobblemon/mod/common/pokemon/Pokemon;ZLjava/lang/String;)V", at = @At("HEAD"), cancellable = true)
|
||||
private void cobblesurvival$keepStarter(Pokemon pokemon, boolean soulLink, String partner, CallbackInfo info) {
|
||||
if (Permadeath.INSTANCE.isStarter(pokemon)) info.cancel();
|
||||
if (Permadeath.INSTANCE.isSpared(pokemon)) info.cancel();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.cobblesurvival
|
||||
|
||||
import dev.cobblesurvival.zone.PeacefulZones
|
||||
import dev.cobblesurvival.catching.AttackBattles
|
||||
import dev.cobblesurvival.jei.DropSources
|
||||
import net.neoforged.fml.loading.FMLPaths
|
||||
@@ -111,6 +112,7 @@ class CobbleSurvival {
|
||||
BaseLoot.register()
|
||||
Catching.register()
|
||||
AttackBattles.register()
|
||||
PeacefulZones.register()
|
||||
Wanderers.register()
|
||||
SharedLoot.register()
|
||||
SpecialPokemon.register()
|
||||
|
||||
@@ -24,6 +24,7 @@ object Tuning : SimpleJsonResourceReloadListener(Gson(), "tuning") {
|
||||
val summon_spacing: Double = 96.0,
|
||||
val wanderer_range: Double = 64.0,
|
||||
val attack_battle_grace_ticks: Int = 100,
|
||||
val peaceful_zones: List<String> = listOf("button_isle"),
|
||||
)
|
||||
|
||||
private val FILE = ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "server")
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.cobblemon.mod.common.battles.ErroredBattleStart
|
||||
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
|
||||
import dev.cobblesurvival.Tuning
|
||||
import dev.cobblesurvival.boss.BossToughness
|
||||
import dev.cobblesurvival.zone.PeacefulZones
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import net.neoforged.neoforge.common.NeoForge
|
||||
import net.neoforged.neoforge.event.entity.living.LivingIncomingDamageEvent
|
||||
@@ -16,7 +17,8 @@ import java.util.UUID
|
||||
* A wild Pokémon that attacks a player starts a battle instead of hurting them (Adrian, 2026-11-09). A player with no
|
||||
* Pokémon able to fight still takes the hit, so the wilds stay dangerous to them. After fleeing, a moment's grace
|
||||
* (`tuning/server.json` → `attack_battle_grace_ticks`) to get away, or the same Pokémon would pull them straight back.
|
||||
* A boss in a battle hits through: those are its area attacks.
|
||||
* A boss in a battle hits through: those are its area attacks. On the start island an attack the player didn't provoke
|
||||
* does nothing at all ([PeacefulZones]).
|
||||
*/
|
||||
object AttackBattles {
|
||||
/** Game time until which a hit on this player does nothing: a battle is being set up, or they have just fled. */
|
||||
@@ -33,6 +35,11 @@ object AttackBattles {
|
||||
val player = event.entity as? ServerPlayer ?: return
|
||||
val attacker = event.source.entity as? PokemonEntity ?: return
|
||||
if (attacker.pokemon.isPlayerOwned() || (attacker.isBattling && BossToughness.isBoss(attacker.pokemon))) return
|
||||
if (PeacefulZones.isPeaceful(player) && !PeacefulZones.provoked(attacker, player)) {
|
||||
event.isCanceled = true
|
||||
PeacefulZones.calmDown(attacker)
|
||||
return
|
||||
}
|
||||
if (BattleRegistry.getBattleByParticipatingPlayer(player) != null) {
|
||||
event.isCanceled = true
|
||||
return
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.google.gson.Gson
|
||||
import com.google.gson.JsonElement
|
||||
import dev.cobblesurvival.CobbleSurvival
|
||||
import dev.cobblesurvival.world.SpecialPokemon
|
||||
import dev.cobblesurvival.zone.PeacefulZones
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import net.minecraft.server.packs.resources.ResourceManager
|
||||
@@ -26,6 +27,7 @@ import net.neoforged.neoforge.event.tick.ServerTickEvent
|
||||
* - a Pokémon that breaks out of a ball attacks the thrower instead of fleeing;
|
||||
* - a wild Pokémon above a nearby player's strongest Pokémon is hostile to that player (not guardians and other
|
||||
* special Pokémon, see [SpecialPokemon]).
|
||||
* None of this angers a Pokémon in a peaceful zone (the start island, [PeacefulZones]).
|
||||
*
|
||||
* The attacking is Fight or Flight Reborn's: an angry Pokémon carries the aspect [ENRAGED], which the pack's Fight or
|
||||
* Flight config lists in `always_aggro_aspects` (so it fights rather than flees), and its target is set through Fight
|
||||
@@ -88,13 +90,14 @@ object Catching : SimpleJsonResourceReloadListener(Gson(), "catching") {
|
||||
|
||||
private fun onCaptureCalculated(event: PokeBallCaptureCalculatedEvent) {
|
||||
if (!rules.enrageOnFailedCatch || event.captureResult.isSuccessfulCapture) return
|
||||
enrage(event.pokemonEntity, event.thrower as? ServerPlayer ?: return)
|
||||
val player = event.thrower as? ServerPlayer ?: return
|
||||
if (!PeacefulZones.isPeaceful(player)) enrage(event.pokemonEntity, player)
|
||||
}
|
||||
|
||||
private fun onServerTick(event: ServerTickEvent.Post) {
|
||||
if (!rules.hostileWhenStronger || hostilityPaused || event.server.tickCount % SCAN_TICKS != 0) return
|
||||
for (player in event.server.playerList.players) {
|
||||
if (player.isCreative || player.isSpectator) continue
|
||||
if (player.isCreative || player.isSpectator || PeacefulZones.isPeaceful(player)) continue
|
||||
val strongest = strongest(player)
|
||||
val near = player.boundingBox.inflate(rules.hostileRange)
|
||||
player.serverLevel().getEntitiesOfClass(PokemonEntity::class.java, near) {
|
||||
|
||||
@@ -23,6 +23,11 @@ object FightOrFlight {
|
||||
if (installed) setCapturedBy.invoke(wild, player.id)
|
||||
}
|
||||
|
||||
/** Takes Fight or Flight's mark off again (0 is its "nobody"). */
|
||||
fun clearTarget(wild: PokemonEntity) {
|
||||
if (installed) setCapturedBy.invoke(wild, 0)
|
||||
}
|
||||
|
||||
fun aggression(wild: PokemonEntity): Double = coefficient.invoke(null, wild) as Double
|
||||
|
||||
/** Fight or Flight's `neutral_threshold`: at or above it a provoked Pokémon fights. */
|
||||
|
||||
@@ -36,11 +36,14 @@ import java.util.concurrent.ConcurrentLinkedQueue
|
||||
* Settling: a Pokémon holding an item from `#cobblesurvival:revival_charms` is healed and the charm is used up;
|
||||
* any other is removed from its party or PC. The one exception is the player's starter (Adrian, 2026-11-09): it only
|
||||
* faints, and can't be released or sacrificed either ([isStarter]; CobbleHardcoreMon's side is mixin/HardcorePartyMixin).
|
||||
* A battle between players never costs a Pokémon (Adrian, 2026-11-10): one that faints there is marked [PVP_FAINT] and
|
||||
* stays fainted until it is healed. It loses the mark once it is seen healthy outside a battle, or faints in any other battle.
|
||||
*/
|
||||
object Permadeath {
|
||||
val REVIVAL_CHARMS: TagKey<Item> =
|
||||
TagKey.create(Registries.ITEM, ResourceLocation.fromNamespaceAndPath(CobbleSurvival.MOD_ID, "revival_charms"))
|
||||
private const val SWEEP_TICKS = 20
|
||||
const val PVP_FAINT = "cobblesurvival_pvp_faint"
|
||||
|
||||
private val fainted = ConcurrentLinkedQueue<Pokemon>()
|
||||
private val faintedInBattle = HashMap<UUID, MutableSet<Pokemon>>()
|
||||
@@ -59,6 +62,9 @@ object Permadeath {
|
||||
}
|
||||
}
|
||||
|
||||
/** Never removed for fainting: the starter, and a Pokémon that fainted in a battle between players. */
|
||||
fun isSpared(pokemon: Pokemon): Boolean = isStarter(pokemon) || pokemon.persistentData.getBoolean(PVP_FAINT)
|
||||
|
||||
/** The Pokémon this player chose as a starter, which Cobblemon remembers for each player. */
|
||||
fun isStarter(pokemon: Pokemon): Boolean {
|
||||
val owner = pokemon.getOwnerUUID() ?: return false
|
||||
@@ -69,6 +75,11 @@ object Permadeath {
|
||||
val killed = event.killed
|
||||
// In a battle on copies (exhibition rules) the real Pokémon is not hurt, so it does not count.
|
||||
if (killed.effectedPokemon !== killed.originalPokemon || !killed.originalPokemon.isPlayerOwned()) return
|
||||
if (event.battle.isPvP) {
|
||||
killed.originalPokemon.persistentData.putBoolean(PVP_FAINT, true)
|
||||
return
|
||||
}
|
||||
killed.originalPokemon.persistentData.remove(PVP_FAINT)
|
||||
faintedInBattle.getOrPut(event.battle.battleId) { HashSet() } += killed.originalPokemon
|
||||
}
|
||||
|
||||
@@ -91,16 +102,21 @@ object Permadeath {
|
||||
for (player in event.server.playerList.players) {
|
||||
val party = Cobblemon.storage.getParty(player)
|
||||
val pc = Cobblemon.storage.getPC(player)
|
||||
(party.filter { it.isFainted() } + pc.filter { it.isFainted() }).forEach { settle(player, it) }
|
||||
(party.filter { it.isFainted() || it.persistentData.contains(PVP_FAINT) } +
|
||||
pc.filter { it.isFainted() || it.persistentData.contains(PVP_FAINT) }).forEach { settle(player, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun settle(player: ServerPlayer, pokemon: Pokemon) {
|
||||
val store = pokemon.storeCoordinates.get()?.store
|
||||
if (!pokemon.isFainted() || isStarter(pokemon) || (store !is PlayerPartyStore && store !is PCStore)) return
|
||||
// Faints during a battle are settled when it ends (above, and by CobbleHardcoreMon).
|
||||
if (player.isInBattle()) return
|
||||
if (!pokemon.isFainted()) {
|
||||
pokemon.persistentData.remove(PVP_FAINT)
|
||||
return
|
||||
}
|
||||
if (isStarter(pokemon) || pokemon.persistentData.getBoolean(PVP_FAINT) || (store !is PlayerPartyStore && store !is PCStore)) return
|
||||
try {
|
||||
val name = pokemon.getDisplayName(false)
|
||||
if (pokemon.heldItem().`is`(REVIVAL_CHARMS)) {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
package dev.cobblesurvival.selftest
|
||||
|
||||
import net.minecraft.world.phys.Vec3
|
||||
import net.minecraft.world.level.levelgen.Heightmap
|
||||
import net.minecraft.gametest.framework.GameTestAssertException
|
||||
import net.minecraft.world.item.crafting.RecipeType
|
||||
import net.minecraft.world.item.crafting.CraftingInput
|
||||
import net.minecraft.world.item.ItemStack
|
||||
import net.minecraft.resources.ResourceLocation
|
||||
import net.minecraft.core.registries.BuiltInRegistries
|
||||
import dev.cobblesurvival.zone.Zones
|
||||
import dev.cobblesurvival.zone.PeacefulZones
|
||||
import com.cobblemon.mod.common.battles.BattleRegistry
|
||||
import com.cobblemon.mod.common.api.events.CobblemonEvents
|
||||
import com.cobblemon.mod.common.api.events.pokeball.PokeBallCaptureCalculatedEvent
|
||||
@@ -26,6 +36,8 @@ object CatchingTests {
|
||||
fun all() = listOf(
|
||||
SelfTestCase("catch_rate_falls_with_level_gap", 20, ::catchRate, batch = "catching"),
|
||||
SelfTestCase("failed_catch_enrages", 400, ::failedCatch, batch = "catching"),
|
||||
SelfTestCase("button_isle_pokemon_leave_players_alone", 200, ::peaceful, islandOnly = true, batch = "peaceful"),
|
||||
SelfTestCase("start_isle_materials_make_a_poke_ball", 20, ::startBall),
|
||||
SelfTestCase("wild_attack_starts_a_battle", 300, ::attackBattle, batch = "attack_battle"),
|
||||
SelfTestCase("wild_attack_hurts_without_a_party", 120, ::attackHurts, batch = "attack_battle"),
|
||||
SelfTestCase("strong_wild_pokemon_hunt_weak_trainers", 400, { s -> hostility(s, strongest = 5, hostile = true) },
|
||||
@@ -50,6 +62,57 @@ object CatchingTests {
|
||||
s.helper.succeed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Adrian, 2026-11-10: on Button Isle a wild Pokémon leaves a player alone, even
|
||||
* one with no Pokémon whom it would otherwise hunt, unless the player hits it first.
|
||||
*/
|
||||
private fun peaceful(s: Scenario) {
|
||||
PeacefulZones.paused = false
|
||||
Catching.hostilityPaused = false
|
||||
s.afterTest {
|
||||
PeacefulZones.paused = true
|
||||
Catching.hostilityPaused = true
|
||||
}
|
||||
// On the island's surface: the platform's height counts as a cave biome.
|
||||
val sample = Zones.samples["button_isle"] ?: throw GameTestAssertException("no sample point for Button Isle")
|
||||
s.level.getChunk(sample.x shr 4, sample.z shr 4)
|
||||
val y = s.level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z).toDouble()
|
||||
val player = s.player()
|
||||
s.moveTo(player, sample.x + 0.5, y, sample.z + 0.5)
|
||||
val wild = s.wild(s.pokemon("primeape", 40, "karatechop"), Vec3(sample.x + 3.5, y, sample.z + 0.5))
|
||||
s.check(Zones.at(s.level, player.blockPosition())?.id == "button_isle") { "the player isn't on Button Isle" }
|
||||
val health = player.health
|
||||
s.helper.startSequence()
|
||||
.thenIdle(SPAWN_PROTECTION_TICKS)
|
||||
.thenExecute {
|
||||
s.check(Catching.ENRAGED !in wild.pokemon.forcedAspects) { "a stronger Pokémon was angered on Button Isle" }
|
||||
Catching.enrage(wild, player) // as if something else had set it on the player
|
||||
}
|
||||
.thenIdle(25)
|
||||
.thenExecute {
|
||||
s.check(!PeacefulZones.targets(wild, player)) { "the wild Pokémon still goes for the player" }
|
||||
s.check(!player.hurt(wild.damageSources().mobAttack(wild), 6f)) { "an unprovoked hit went through on Button Isle" }
|
||||
s.check(player.health == health) { "the player lost health" }
|
||||
wild.hurt(player.damageSources().playerAttack(player), 1f)
|
||||
s.check(player.hurt(wild.damageSources().mobAttack(wild), 6f)) { "a Pokémon the player hit couldn't hit back" }
|
||||
}
|
||||
.thenSucceed()
|
||||
}
|
||||
|
||||
/** Adrian, 2026-11-10: the start island can make a Poké Ball: four apricorns of any colour and coal or charcoal. */
|
||||
private fun startBall(s: Scenario) {
|
||||
val level = s.level
|
||||
fun item(id: String) = ItemStack(BuiltInRegistries.ITEM.get(ResourceLocation.parse(id)))
|
||||
for (fuel in listOf("minecraft:coal", "minecraft:charcoal")) {
|
||||
val grid = listOf(ItemStack.EMPTY, item("cobblemon:white_apricorn"), ItemStack.EMPTY, item("cobblemon:red_apricorn"), item(fuel),
|
||||
item("cobblemon:pink_apricorn"), ItemStack.EMPTY, item("cobblemon:green_apricorn"), ItemStack.EMPTY)
|
||||
val input = CraftingInput.of(3, 3, grid)
|
||||
val made = level.recipeManager.getRecipeFor(RecipeType.CRAFTING, input, level).map { it.value.assemble(input, level.registryAccess()) }.orElse(ItemStack.EMPTY)
|
||||
s.check(made.`is`(item("cobblemon:poke_ball").item)) { "four apricorns and $fuel make ${made.hoverName.string}, not a Poké Ball" }
|
||||
}
|
||||
s.helper.succeed()
|
||||
}
|
||||
|
||||
private fun attackBattle(s: Scenario) {
|
||||
val player = s.player()
|
||||
s.give(player, s.pokemon("charizard", 50, "ember"))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package dev.cobblesurvival.selftest
|
||||
|
||||
import net.minecraft.world.level.block.Blocks
|
||||
import net.minecraft.core.BlockPos
|
||||
import dev.cobblesurvival.CobbleSurvival
|
||||
import dev.cobblesurvival.zone.ZoneSample
|
||||
import dev.cobblesurvival.zone.Zones
|
||||
@@ -133,6 +135,7 @@ object HazardTests {
|
||||
val bare = s.player()
|
||||
val wading = s.player().also { it.setItemSlot(EquipmentSlot.FEET, ItemStack(waders)) }
|
||||
val outside = s.player()
|
||||
val below = s.player()
|
||||
fun surface(sample: ZoneSample): Double {
|
||||
level.getChunk(sample.x shr 4, sample.z shr 4)
|
||||
return level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z).toDouble()
|
||||
@@ -141,6 +144,10 @@ object HazardTests {
|
||||
.thenExecute {
|
||||
for (player in listOf(bare, wading)) s.moveTo(player, mire.x + 0.5, surface(mire), mire.z + 0.5)
|
||||
s.moveTo(outside, elsewhere.x + 0.5, surface(elsewhere), elsewhere.z + 0.5)
|
||||
// Deep in the rock under the Mire (a pocket cut for it), where the swamp's poison doesn't reach.
|
||||
val deep = BlockPos(mire.x + 2, surface(mire).toInt() - 30, mire.z)
|
||||
for (dy in 0..1) level.setBlockAndUpdate(deep.above(dy), Blocks.AIR.defaultBlockState())
|
||||
s.moveTo(below, deep.x + 0.5, deep.y.toDouble(), deep.z + 0.5)
|
||||
}
|
||||
.thenIdle(220)
|
||||
.thenExecute {
|
||||
@@ -148,6 +155,7 @@ object HazardTests {
|
||||
s.check(bare.hasEffect(poison)) { "a player without waders in the Mire isn't poisoned" }
|
||||
s.check(!wading.hasEffect(poison)) { "a player in Mire Waders got poisoned" }
|
||||
s.check(!outside.hasEffect(poison)) { "a player on Button Isle got poisoned" }
|
||||
s.check(!below.hasEffect(poison)) { "a player deep under the Mire got poisoned" }
|
||||
}
|
||||
.thenSucceed()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.cobblesurvival.selftest
|
||||
|
||||
import dev.cobblesurvival.permadeath.Permadeath
|
||||
import com.cobblemon.mod.common.Cobblemon
|
||||
import com.cobblemon.mod.common.api.npc.NPCClasses
|
||||
import com.cobblemon.mod.common.api.pokemon.status.Statuses
|
||||
@@ -37,6 +38,7 @@ object PermadeathTests {
|
||||
SelfTestCase("wild_battle_charm", BATTLE_TICKS, { wildBattle(it, charm = true) }),
|
||||
SelfTestCase("revived_during_battle", BATTLE_TICKS, ::revivedDuringBattle),
|
||||
SelfTestCase("trainer_battle", BATTLE_TICKS, ::trainerBattle),
|
||||
SelfTestCase("player_battle_only_faints", BATTLE_TICKS, ::playerBattle),
|
||||
)
|
||||
|
||||
/** Cobblemon heals the party while the player sleeps and wakes fainted Pokémon after a while; both are off. */
|
||||
@@ -160,6 +162,32 @@ object PermadeathTests {
|
||||
.thenSucceed()
|
||||
}
|
||||
|
||||
/** Adrian, 2026-11-10: a Pokémon that faints in a battle between players stays, fainted, until healed. */
|
||||
private fun playerBattle(s: Scenario) {
|
||||
val loser = s.player()
|
||||
val winner = s.player()
|
||||
val pokemon = s.give(loser, s.pokemon("bidoof", 5, "explosion"))
|
||||
s.give(winner, s.pokemon("bidoof", 50, "splash"))
|
||||
s.answerBattles(loser) { _, _ -> MoveActionResponse("explosion") }
|
||||
s.answerBattles(winner) { _, _ -> MoveActionResponse("splash") }
|
||||
var ended = -1L
|
||||
s.helper.startSequence()
|
||||
.thenExecute { s.started(BattleBuilder.pvp1v1(loser, winner)) }
|
||||
.thenWaitUntil {
|
||||
s.check(!loser.isInBattle()) { "the battle is still running: ${s.battleState(loser)}" }
|
||||
if (ended < 0) ended = s.level.gameTime
|
||||
s.check(s.level.gameTime - ended > 60) { "waiting for the permadeath sweeps" }
|
||||
}
|
||||
.thenExecute {
|
||||
s.check(Cobblemon.storage.getParty(loser).any { it.uuid == pokemon.uuid }) { "the Pokémon that fainted in a player battle was removed" }
|
||||
s.check(pokemon.isFainted()) { "it isn't fainted" }
|
||||
pokemon.heal()
|
||||
}
|
||||
.thenIdle(30)
|
||||
.thenExecute { s.check(!pokemon.persistentData.contains(Permadeath.PVP_FAINT)) { "healed, it still carries the player-battle mark" } }
|
||||
.thenSucceed()
|
||||
}
|
||||
|
||||
private fun wildBattle(s: Scenario, charm: Boolean) {
|
||||
val player = s.player()
|
||||
val pokemon = s.give(player, s.pokemon("bidoof", 5, "explosion"))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.cobblesurvival.selftest
|
||||
|
||||
import dev.cobblesurvival.zone.PeacefulZones
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder
|
||||
import dev.cobblesurvival.CobbleSurvival
|
||||
import dev.cobblesurvival.catching.Catching
|
||||
@@ -104,6 +105,7 @@ object SelfTest {
|
||||
running = scenarios
|
||||
Catching.hostilityPaused = true
|
||||
Wanderers.paused = true
|
||||
PeacefulZones.paused = true
|
||||
GameTestTicker.SINGLETON.clear()
|
||||
GameTestRunner.Builder.fromInfo(infos, level)
|
||||
.newStructureSpawner(StructureGridSpawner(origin, TESTS_PER_ROW, false))
|
||||
@@ -135,5 +137,6 @@ object SelfTest {
|
||||
running = emptyList()
|
||||
Catching.hostilityPaused = false
|
||||
Wanderers.paused = false
|
||||
PeacefulZones.paused = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package dev.cobblesurvival.zone
|
||||
|
||||
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
|
||||
import dev.cobblesurvival.Tuning
|
||||
import dev.cobblesurvival.catching.FightOrFlight
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import net.minecraft.world.entity.ai.memory.MemoryModuleType
|
||||
import net.neoforged.neoforge.common.NeoForge
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent
|
||||
|
||||
/**
|
||||
* Wild Pokémon leave players alone on the start island (Adrian, 2026-11-10): a player who has lost everything, with no
|
||||
* tools, armour or Pokémon, can start over there. In a peaceful zone (`tuning/server.json` → `peaceful_zones`) a wild
|
||||
* Pokémon only fights a player who has just hit it. Being stronger than the player's team doesn't anger it there, nor
|
||||
* does a failed catch ([dev.cobblesurvival.catching.Catching]), and any other attack on a player there is called off
|
||||
* and does nothing ([dev.cobblesurvival.catching.AttackBattles]).
|
||||
*/
|
||||
object PeacefulZones {
|
||||
private const val CHECK_TICKS = 10
|
||||
private const val SCAN_RANGE = 32.0
|
||||
/** How long after being hit a Pokémon may hit back. */
|
||||
private const val PROVOKED_TICKS = 200
|
||||
|
||||
/** The self-tests switch this off, except in their own batch: the platform they run on is above Button Isle. */
|
||||
var paused = false
|
||||
|
||||
fun register() {
|
||||
NeoForge.EVENT_BUS.addListener(::onServerTick)
|
||||
}
|
||||
|
||||
fun isPeaceful(player: ServerPlayer): Boolean =
|
||||
!paused && Zones.at(player.serverLevel(), player.blockPosition())?.id in Tuning.values.peaceful_zones
|
||||
|
||||
/** [player] hit [wild] lately, so it may hit back. */
|
||||
fun provoked(wild: PokemonEntity, player: ServerPlayer): Boolean =
|
||||
wild.lastHurtByMob === player && wild.tickCount - wild.lastHurtByMobTimestamp < PROVOKED_TICKS
|
||||
|
||||
fun targets(wild: PokemonEntity, player: ServerPlayer): Boolean =
|
||||
wild.target === player ||
|
||||
(wild.brain.hasMemoryValue(MemoryModuleType.ATTACK_TARGET) && wild.brain.getMemory(MemoryModuleType.ATTACK_TARGET).orElse(null) === player)
|
||||
|
||||
fun calmDown(wild: PokemonEntity) {
|
||||
wild.target = null
|
||||
wild.brain.eraseMemory(MemoryModuleType.ATTACK_TARGET)
|
||||
FightOrFlight.clearTarget(wild)
|
||||
}
|
||||
|
||||
private fun onServerTick(event: ServerTickEvent.Post) {
|
||||
if (paused || event.server.tickCount % CHECK_TICKS != 0) return
|
||||
for (player in event.server.playerList.players) {
|
||||
if (!isPeaceful(player)) continue
|
||||
player.serverLevel().getEntitiesOfClass(PokemonEntity::class.java, player.boundingBox.inflate(SCAN_RANGE)) {
|
||||
it.pokemon.isWild() && !it.isBattling && targets(it, player) && !provoked(it, player)
|
||||
}.forEach(::calmDown)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.cobblesurvival.zone
|
||||
|
||||
import net.minecraft.world.level.levelgen.Heightmap
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonElement
|
||||
import dev.cobblesurvival.CobbleSurvival
|
||||
@@ -39,6 +40,8 @@ object ZoneHazards : SimpleJsonResourceReloadListener(Gson(), "zone_hazards") {
|
||||
val everyTicks: Int,
|
||||
val protection: TagKey<Item>,
|
||||
val warning: String?,
|
||||
/** How far below the surface the hazard still reaches; null: everywhere in the zone. */
|
||||
val maxDepth: Int?,
|
||||
)
|
||||
|
||||
var hazards: List<Hazard> = emptyList()
|
||||
@@ -67,6 +70,7 @@ object ZoneHazards : SimpleJsonResourceReloadListener(Gson(), "zone_hazards") {
|
||||
everyTicks = json.get("every_ticks").asInt,
|
||||
protection = TagKey.create(Registries.ITEM, ResourceLocation.parse(json.get("protection").asString)),
|
||||
warning = json.get("warning")?.asString,
|
||||
maxDepth = json.get("max_depth")?.asInt,
|
||||
)
|
||||
}
|
||||
CobbleSurvival.LOGGER.info("Loaded {} zone hazards", hazards.size)
|
||||
@@ -83,7 +87,7 @@ object ZoneHazards : SimpleJsonResourceReloadListener(Gson(), "zone_hazards") {
|
||||
continue
|
||||
}
|
||||
for (hazard in hazards) {
|
||||
if (hazard.zone != zone || isProtected(player, hazard)) continue
|
||||
if (hazard.zone != zone || isProtected(player, hazard) || tooDeep(player, hazard)) continue
|
||||
// At once on entering the zone (Adrian, 2026-11-09), then renewed every every_ticks.
|
||||
val current = player.getEffect(hazard.effect)
|
||||
if (current != null && current.duration > hazard.durationTicks - hazard.everyTicks) continue
|
||||
@@ -96,6 +100,13 @@ object ZoneHazards : SimpleJsonResourceReloadListener(Gson(), "zone_hazards") {
|
||||
}
|
||||
}
|
||||
|
||||
/** Adrian, 2026-11-10: the Mire's poison is the swamp's, not the rock's under it. Leaves don't count as surface, so the mangrove canopy doesn't shelter. */
|
||||
fun tooDeep(player: ServerPlayer, hazard: Hazard): Boolean {
|
||||
val depth = hazard.maxDepth ?: return false
|
||||
val surface = player.serverLevel().getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, player.blockX, player.blockZ)
|
||||
return player.blockY < surface - depth
|
||||
}
|
||||
|
||||
fun isProtected(player: ServerPlayer, hazard: Hazard) =
|
||||
listOf(EquipmentSlot.HEAD, EquipmentSlot.CHEST, EquipmentSlot.LEGS, EquipmentSlot.FEET)
|
||||
.any { player.getItemBySlot(it).`is`(hazard.protection) }
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"minVersion": "0.8",
|
||||
"package": "dev.cobblesurvival.mixin",
|
||||
"compatibilityLevel": "JAVA_21",
|
||||
"mixins": ["HardcorePartyMixin", "RegeneratorBlockMixin"],
|
||||
"mixins": ["HardcoreBattlesMixin", "HardcorePartyMixin", "RegeneratorBlockMixin"],
|
||||
"injectors": {"defaultRequire": 1}
|
||||
}
|
||||
Reference in new issue
Block a user