diff --git a/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt b/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt index c372bf9..472396d 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/CobbleSurvival.kt @@ -9,6 +9,7 @@ import dev.cobblesurvival.dev.OreVeins import dev.cobblesurvival.ore.BaseLoot import dev.cobblesurvival.ore.OreNodes import dev.cobblesurvival.permadeath.Permadeath +import dev.cobblesurvival.boss.BossCommands import dev.cobblesurvival.boss.BossFights import dev.cobblesurvival.boss.EndBoss import dev.cobblesurvival.machine.MachineShowcase @@ -132,7 +133,8 @@ class CobbleSurvival { val lines = Guardians.status(context.source.server.overworld()) context.source.sendSuccess({ Component.literal(lines.joinToString("\n").ifEmpty { "no guardians loaded" }) }, false) 1 - }) + }.then(BossCommands.guardianReset())) + .then(BossCommands.practice()) if (SelfTest.enabled) root.then(SelfTest.command()) event.dispatcher.register(root) } diff --git a/mod/src/main/kotlin/dev/cobblesurvival/boss/BossCommands.kt b/mod/src/main/kotlin/dev/cobblesurvival/boss/BossCommands.kt new file mode 100644 index 0000000..25258bc --- /dev/null +++ b/mod/src/main/kotlin/dev/cobblesurvival/boss/BossCommands.kt @@ -0,0 +1,78 @@ +package dev.cobblesurvival.boss + +import com.cobblemon.mod.common.api.pokemon.PokemonProperties +import com.cobblemon.mod.common.entity.pokemon.PokemonEntity +import com.mojang.brigadier.arguments.StringArgumentType +import com.mojang.brigadier.builder.LiteralArgumentBuilder +import net.minecraft.commands.CommandSourceStack +import net.minecraft.commands.Commands +import net.minecraft.commands.SharedSuggestionProvider +import net.minecraft.network.chat.Component +import net.minecraft.server.level.ServerLevel +import net.minecraft.world.phys.Vec3 +import dev.cobblesurvival.zone.Guardians + +/** + * Operator commands to try the bosses (Adrian, 2026-11-02: a way to force-spawn them): + * - `/cobblesurvival boss `: a practice copy next to you, anywhere. Same species, level, size and attacks + * as the real boss, uncatchable; beating it gives nothing and doesn't count as the guardian's defeat. + * - `/cobblesurvival guardians reset `: the zone's real guardian forgets its last defeat (the one standing is + * despawned) and appears again when a player comes near its spot (`/cobblesurvival guardians` says where). + */ +object BossCommands { + private const val END = "end" + private const val DISTANCE = 6.0 + + private fun choices() = Guardians.definitions.keys.sorted() + END + + fun practice(): LiteralArgumentBuilder = Commands.literal("boss").then( + Commands.argument("boss", StringArgumentType.word()) + .suggests { _, builder -> SharedSuggestionProvider.suggest(choices(), builder) } + .executes { context -> + val source = context.source + val which = StringArgumentType.getString(context, "boss") + val facing = source.entity?.lookAngle?.multiply(1.0, 0.0, 1.0)?.normalize() ?: Vec3(0.0, 0.0, 1.0) + val entity = spawnPractice(source.level, source.position.add(facing.scale(DISTANCE)), which) + if (entity == null) { + source.sendFailure(Component.literal("No boss \"$which\"; try one of: ${choices().joinToString()}")) + 0 + } else { + source.sendSuccess({ Component.literal("A practice ${entity.pokemon.species.name} (Lv ${entity.pokemon.level}) appeared: no drops, it doesn't count.") }, true) + 1 + } + }, + ) + + /** A practice copy of the boss [which] (a zone or "end") at [pos]; null if there is no such boss. */ + fun spawnPractice(level: ServerLevel, pos: Vec3, which: String): PokemonEntity? { + val (properties, scale, def) = if (which == END) { + val end = BossDefs.end?.end ?: return null + Triple("${end.species} level=${end.level}", end.scale, BossDefs.end ?: return null) + } else { + val guardian = Guardians.definitions[which] ?: return null + Triple(guardian.pokemon, guardian.scale, BossDefs.byGuardian[which] ?: return null) + } + val pokemon = PokemonProperties.parse("$properties uncatchable=yes").create() + pokemon.scaleModifier = scale + pokemon.persistentData.putString(BossFights.TAG, def.id.toString()) + val entity = PokemonEntity(level, pokemon) + entity.moveTo(pos.x, pos.y, pos.z, 0f, 0f) + entity.setPersistenceRequired() + return entity.takeIf { level.addFreshEntity(it) } + } + + fun guardianReset(): LiteralArgumentBuilder = Commands.literal("reset").then( + Commands.argument("zone", StringArgumentType.word()) + .suggests { _, builder -> SharedSuggestionProvider.suggest(Guardians.definitions.keys.sorted(), builder) } + .executes { context -> + val zone = StringArgumentType.getString(context, "zone") + if (zone !in Guardians.definitions) { + context.source.sendFailure(Component.literal("No guardian for \"$zone\"")) + return@executes 0 + } + Guardians.reset(context.source.server.overworld(), zone) + context.source.sendSuccess({ Component.literal("The guardian of $zone appears again when a player comes near its spot.") }, true) + 1 + }, + ) +} diff --git a/mod/src/main/kotlin/dev/cobblesurvival/selftest/BossTests.kt b/mod/src/main/kotlin/dev/cobblesurvival/selftest/BossTests.kt index c8743f2..cebe652 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/selftest/BossTests.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/selftest/BossTests.kt @@ -32,8 +32,21 @@ object BossTests { SelfTestCase("boss_attacks_land_and_stop_with_the_battle", 400, ::landAndStop), SelfTestCase("boss_arena_leaving_loses", 400, ::arena), SelfTestCase("end_boss_replaces_the_dragon", 200, ::endBoss), + SelfTestCase("practice_bosses_spawn", 20, ::practice), ) + /** `/cobblesurvival boss `: a copy that fights like the real boss, for every guardian and the End. */ + private fun practice(s: Scenario) { + for (which in Guardians.definitions.keys + "end") { + val entity = dev.cobblesurvival.boss.BossCommands.spawnPractice(s.level, s.at(7.5, 1.0, 7.5), which) + ?: throw GameTestAssertException("no practice boss for $which") + s.check(BossFights.defOf(entity) != null && entity.pokemon.isUncatchable()) { "the practice $which is no uncatchable boss" } + s.check(entity.pokemon.persistentData.getString(Guardians.TAG).isEmpty()) { "the practice $which counts as the real guardian" } + entity.discard() + } + s.helper.succeed() + } + private fun implemented(species: String) = PokemonSpecies.getByName(species)?.implemented == true private fun coverage(s: Scenario) { diff --git a/mod/src/main/kotlin/dev/cobblesurvival/selftest/GuardianTests.kt b/mod/src/main/kotlin/dev/cobblesurvival/selftest/GuardianTests.kt index 8dc6e61..4f9c18b 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/selftest/GuardianTests.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/selftest/GuardianTests.kt @@ -26,8 +26,8 @@ object GuardianTests { 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) } + Guardians.reset(level, ZONE) + s.afterTest { Guardians.reset(level, ZONE) } val player = s.player() s.give(player, s.pokemon("mewtwo", 100, "earthquake")) // a spread move needs no target diff --git a/mod/src/main/kotlin/dev/cobblesurvival/selftest/ZoneTests.kt b/mod/src/main/kotlin/dev/cobblesurvival/selftest/ZoneTests.kt index aaf0420..122116a 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/selftest/ZoneTests.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/selftest/ZoneTests.kt @@ -108,13 +108,29 @@ object ZoneTests { }.thenSucceed() } + /** + * The ground at [x] [z], below any tree: standing on a mangrove's roots or logs, the survey once found no natural + * ground around it, so nothing could spawn (2026-11-02). + */ + private fun ground(level: net.minecraft.server.level.ServerLevel, x: Int, z: Int): BlockPos { + var y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) + while (y > level.minBuildHeight) { + val below = level.getBlockState(BlockPos(x, y - 1, z)) + val tree = below.`is`(net.minecraft.tags.BlockTags.LOGS) || below.`is`(net.minecraft.tags.BlockTags.LEAVES) || + below.`is`(net.minecraft.world.level.block.Blocks.MANGROVE_ROOTS) + if (below.blocksMotion() && !tree) break + y-- + } + return BlockPos(x, y, z) + } + /** What can spawn around the sample right now, and how many spawnable positions Cobblemon found there. */ private fun survey(s: Scenario, player: ServerPlayer, zoneId: String, sample: ZoneSample): Pair, Int> { val level = s.level // Generate the chunks around the sample, then find where to stand. for (dx in -2..2) for (dz in -2..2) level.getChunk((sample.x shr 4) + dx, (sample.z shr 4) + dz) val stand = if (sample.y == null) { - BlockPos(sample.x, level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z), sample.z) + ground(level, sample.x, sample.z) } else { caveFloor(s, zoneId, sample, sample.y) ?: throw GameTestAssertException("no open $zoneId cave near ${sample.x} ${sample.y} ${sample.z}") diff --git a/mod/src/main/kotlin/dev/cobblesurvival/zone/Guardians.kt b/mod/src/main/kotlin/dev/cobblesurvival/zone/Guardians.kt index 22f80d4..6e7e59b 100644 --- a/mod/src/main/kotlin/dev/cobblesurvival/zone/Guardians.kt +++ b/mod/src/main/kotlin/dev/cobblesurvival/zone/Guardians.kt @@ -203,8 +203,9 @@ object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") { } } - /** 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) { + /** Forget the zone's guardian (despawning it) and its last defeat: it appears again when a player comes near. + * For the self-tests and `/cobblesurvival guardians reset `. */ + fun reset(level: ServerLevel, zone: String) { val entry = GuardianState.of(level).entry(zone) entry.entity?.let { level.getEntity(it) }?.discard() entry.entity = null