From 9299fbe7c2355676f1f8691eec6ed068f6e48d23 Mon Sep 17 00:00:00 2001 From: bigtorka Date: Tue, 8 Sep 2026 19:01:55 -0400 Subject: [PATCH] feat: add Vorkath encounter --- .../combat/commons/DragonfireProtection.kt | 59 +- .../api/death/plugin/DeathDropHooksModule.kt | 2 + .../kotlin/org/rsmod/api/death/PlayerDeath.kt | 26 +- .../org/rsmod/api/death/PlayerDeathHook.kt | 13 + .../rsmod/api/instances/InstanceManager.kt | 9 +- .../rsmod/api/repo/world/WorldRepository.kt | 12 +- content/bosses/vorkath/build.gradle.kts | 37 + .../bosses/vorkath/VorkathAccessPolicy.kt | 14 + .../bosses/vorkath/VorkathAcidLayout.kt | 72 ++ .../content/bosses/vorkath/VorkathAcidPool.kt | 5 + .../content/bosses/vorkath/VorkathCombat.kt | 66 + .../content/bosses/vorkath/VorkathConfig.kt | 146 +++ .../bosses/vorkath/VorkathDeathStorage.kt | 100 ++ .../bosses/vorkath/VorkathDragonfire.kt | 76 ++ .../bosses/vorkath/VorkathEncounterManager.kt | 1099 +++++++++++++++++ .../bosses/vorkath/VorkathLifecycle.kt | 94 ++ .../content/bosses/vorkath/VorkathModule.kt | 18 + .../bosses/vorkath/VorkathProjectiles.kt | 110 ++ .../content/bosses/vorkath/VorkathRules.kt | 84 ++ .../content/bosses/vorkath/VorkathTimeline.kt | 93 ++ .../content/bosses/vorkath/VorkathWorld.kt | 147 +++ .../bosses/vorkath/VorkathAcidLayoutTest.kt | 87 ++ .../bosses/vorkath/VorkathDeathStorageTest.kt | 83 ++ .../bosses/vorkath/VorkathDragonfireTest.kt | 94 ++ .../vorkath/VorkathEncounterManagerTest.kt | 644 ++++++++++ .../bosses/vorkath/VorkathProjectilesTest.kt | 117 ++ .../bosses/vorkath/VorkathRulesTest.kt | 181 +++ .../bosses/vorkath/VorkathSafeLaneTest.kt | 37 + .../bosses/vorkath/VorkathStorageCodecTest.kt | 48 + .../bosses/vorkath/VorkathTimelineTest.kt | 152 +++ .../magic/spell-attacks/build.gradle.kts | 1 + .../magic/spell/attacks/SpellAttacksModule.kt | 2 + .../attacks/standard/CrumbleUndeadSpells.kt | 108 ++ .../standard/CrumbleUndeadRulesTest.kt | 31 + 34 files changed, 3856 insertions(+), 11 deletions(-) create mode 100644 content/bosses/vorkath/build.gradle.kts create mode 100644 content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathAccessPolicy.kt create mode 100644 content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathAcidLayout.kt create mode 100644 content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathAcidPool.kt create mode 100644 content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathCombat.kt create mode 100644 content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathConfig.kt create mode 100644 content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathDeathStorage.kt create mode 100644 content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathDragonfire.kt create mode 100644 content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathEncounterManager.kt create mode 100644 content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathLifecycle.kt create mode 100644 content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathModule.kt create mode 100644 content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathProjectiles.kt create mode 100644 content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathRules.kt create mode 100644 content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathTimeline.kt create mode 100644 content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathWorld.kt create mode 100644 content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathAcidLayoutTest.kt create mode 100644 content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathDeathStorageTest.kt create mode 100644 content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathDragonfireTest.kt create mode 100644 content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathEncounterManagerTest.kt create mode 100644 content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathProjectilesTest.kt create mode 100644 content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathRulesTest.kt create mode 100644 content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathSafeLaneTest.kt create mode 100644 content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathStorageCodecTest.kt create mode 100644 content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathTimelineTest.kt create mode 100644 content/skills/magic/spell-attacks/src/main/kotlin/org/rsmod/content/skills/magic/spell/attacks/standard/CrumbleUndeadSpells.kt create mode 100644 content/skills/magic/spell-attacks/src/test/kotlin/org/rsmod/content/skills/magic/spell/attacks/standard/CrumbleUndeadRulesTest.kt diff --git a/api/combat/combat-commons/src/main/kotlin/org/rsmod/api/combat/commons/DragonfireProtection.kt b/api/combat/combat-commons/src/main/kotlin/org/rsmod/api/combat/commons/DragonfireProtection.kt index f11191e19..bef4c5b42 100644 --- a/api/combat/combat-commons/src/main/kotlin/org/rsmod/api/combat/commons/DragonfireProtection.kt +++ b/api/combat/combat-commons/src/main/kotlin/org/rsmod/api/combat/commons/DragonfireProtection.kt @@ -4,7 +4,7 @@ import org.rsmod.game.entity.Player public object DragonfireProtection { - public enum class DragonfireType { Chromatic, Metal, WyvernIce } + public enum class DragonfireType { Chromatic, Metal, WyvernIce, Vorkath } private val antifireShields = setOf( "obj.antidragonbreathshield", @@ -28,6 +28,9 @@ public object DragonfireProtection { ) public fun resolveMaxHit(player: Player, type: DragonfireType, baseMax: Int): Int { + if (type == DragonfireType.Vorkath) { + return resolveVorkathMaxHit(player) + } if (type == DragonfireType.WyvernIce) { return when { wyvernIceShields.any { it in player.worn } -> 10 @@ -56,6 +59,60 @@ public object DragonfireProtection { private fun hasAntifireShield(player: Player): Boolean = antifireShields.any { it in player.worn } + private fun resolveVorkathMaxHit(player: Player): Int = + resolveVorkathMaxHit( + shield = hasAntifireShield(player), + protect = isProtectingFromMagic(player), + antifire = hasAntifire(player), + superAntifire = hasSuperAntifire(player), + ) + + public fun resolveVorkathResistedMaxHit(player: Player): Int = + resolveVorkathResistedMaxHit( + shield = hasAntifireShield(player), + protect = isProtectingFromMagic(player), + antifire = hasAntifire(player), + superAntifire = hasSuperAntifire(player), + ) + + /** Pure post-quest Vorkath dragonfire protection table. */ + public fun resolveVorkathMaxHit( + shield: Boolean, + protect: Boolean, + antifire: Boolean, + superAntifire: Boolean, + ): Int = + when { + superAntifire && shield -> 0 + superAntifire && protect -> 10 + superAntifire -> 60 + antifire && shield -> 10 + antifire && protect -> 20 + antifire -> 70 + shield -> 20 + protect -> 30 + else -> 80 + } + + /** Vorkath still deals reduced dragonfire damage when its accuracy roll fails. */ + public fun resolveVorkathResistedMaxHit( + shield: Boolean, + protect: Boolean, + antifire: Boolean, + superAntifire: Boolean, + ): Int = + when { + superAntifire && shield -> 0 + superAntifire && protect -> 10 + superAntifire -> 30 + antifire && shield -> 10 + antifire && protect -> 20 + antifire -> 40 + shield -> 20 + protect -> 30 + else -> 50 + } + private fun hasSuperAntifire(player: Player): Boolean = player.vars["varbit.super_antifire_potion"] > 0 diff --git a/api/death-plugin/src/main/kotlin/org/rsmod/api/death/plugin/DeathDropHooksModule.kt b/api/death-plugin/src/main/kotlin/org/rsmod/api/death/plugin/DeathDropHooksModule.kt index 027227394..79b967946 100644 --- a/api/death-plugin/src/main/kotlin/org/rsmod/api/death/plugin/DeathDropHooksModule.kt +++ b/api/death-plugin/src/main/kotlin/org/rsmod/api/death/plugin/DeathDropHooksModule.kt @@ -4,6 +4,7 @@ import org.rsmod.api.death.NpcDeathDropHook import org.rsmod.api.death.NpcDeathKillHook import org.rsmod.api.death.PlayerDeathCleanupHook import org.rsmod.api.death.PlayerDeathHook +import org.rsmod.api.death.PlayerDeathStorageHook import org.rsmod.api.death.PvPAttackValidateHook import org.rsmod.api.death.PvPPlayerHitHook import org.rsmod.api.death.PvPSkullHook @@ -16,6 +17,7 @@ public class DeathDropHooksModule : PluginModule() { newSetBinding() newSetBinding() newSetBinding() + newSetBinding() newSetBinding() newSetBinding() newSetBinding() diff --git a/api/death/src/main/kotlin/org/rsmod/api/death/PlayerDeath.kt b/api/death/src/main/kotlin/org/rsmod/api/death/PlayerDeath.kt index b63826f5c..cb1de34c6 100644 --- a/api/death/src/main/kotlin/org/rsmod/api/death/PlayerDeath.kt +++ b/api/death/src/main/kotlin/org/rsmod/api/death/PlayerDeath.kt @@ -1,20 +1,20 @@ package org.rsmod.api.death -import dev.or2.central.account.Rights import dev.openrune.ServerCacheManager import dev.openrune.rscm.RSCM import dev.openrune.rscm.RSCMType +import dev.or2.central.account.Rights import jakarta.inject.Inject import jakarta.inject.Singleton import org.rsmod.api.area.checker.AreaChecker import org.rsmod.api.area.checker.isInWildernessBasic +import org.rsmod.api.mechanics.toxins.Toxin.cureAllToxins import org.rsmod.api.player.death.DEATH_CAUSE_ATTR import org.rsmod.api.player.death.DeathCause -import org.rsmod.api.player.hasProtectItemPrayer -import org.rsmod.api.player.hook.TeleportType -import org.rsmod.api.mechanics.toxins.Toxin.cureAllToxins import org.rsmod.api.player.deathResetTimers import org.rsmod.api.player.disablePrayers +import org.rsmod.api.player.hasProtectItemPrayer +import org.rsmod.api.player.hook.TeleportType import org.rsmod.api.player.protect.ProtectedAccess import org.rsmod.api.player.vars.boolVarBit import org.rsmod.api.player.vars.intVarp @@ -29,6 +29,7 @@ constructor( private val mapClock: MapClock, private val drops: PlayerDeathDrops, private val handlingResolver: PlayerDeathHandlingResolver, + private val storageHooks: Set, private val cleanupHooks: Set, private val areaChecker: AreaChecker, ) { @@ -92,7 +93,22 @@ constructor( val handling = handlingResolver.resolve(context) val result = drops.selectDrops(player, context, handling) - drops.applyDrops(player, result, handling, deathCoords) + var stored = false + for (hook in storageHooks) { + if (hook.store(context, result)) stored = true + } + val appliedResult = + if (stored) { + result.copy( + supplyPile = emptyList(), + lostTradeable = emptyList(), + lostUntradeable = emptyList(), + coinsForKiller = 0L, + ) + } else { + result + } + drops.applyDrops(player, appliedResult, handling, deathCoords) drops.spawnRemains(deathCoords, handling) player.attr.remove(DEATH_KILLER_ATTR) diff --git a/api/death/src/main/kotlin/org/rsmod/api/death/PlayerDeathHook.kt b/api/death/src/main/kotlin/org/rsmod/api/death/PlayerDeathHook.kt index c4fef9e93..6914840cd 100644 --- a/api/death/src/main/kotlin/org/rsmod/api/death/PlayerDeathHook.kt +++ b/api/death/src/main/kotlin/org/rsmod/api/death/PlayerDeathHook.kt @@ -51,3 +51,16 @@ public const val RECENT_PVP_HIT_TICKS: Int = 600 public interface PlayerDeathHook { public fun handleDeath(context: PlayerDeathContext): PlayerDeathHandling? } + +/** + * Stores the items a player would otherwise lose on death. + * + * Returning true claims the lost portion of [result], preventing it from being placed on the + * ground. Kept items are still returned to the player's inventory by the standard death flow. + */ +public interface PlayerDeathStorageHook { + public fun store( + context: PlayerDeathContext, + result: PlayerDeathDrops.DeathDropResult, + ): Boolean +} diff --git a/api/instances/src/main/kotlin/org/rsmod/api/instances/InstanceManager.kt b/api/instances/src/main/kotlin/org/rsmod/api/instances/InstanceManager.kt index 2a812019f..7c138a9a1 100644 --- a/api/instances/src/main/kotlin/org/rsmod/api/instances/InstanceManager.kt +++ b/api/instances/src/main/kotlin/org/rsmod/api/instances/InstanceManager.kt @@ -15,7 +15,6 @@ import org.rsmod.api.instances.events.InstancePlayerLeaveUnboundEvent import org.rsmod.api.instances.events.InstanceStartedEvent import org.rsmod.api.instances.events.InstanceTimeTickEvent import org.rsmod.api.instances.region.InstanceAreaResolver -import org.rsmod.api.instances.region.InstancePlacement import org.rsmod.api.instances.region.OsrsInstancing import org.rsmod.api.instances.region.enterCoord import org.rsmod.api.instances.region.localCoord @@ -27,17 +26,14 @@ import org.rsmod.api.player.output.ChatType import org.rsmod.api.player.output.mes import org.rsmod.api.repo.npc.NpcRepository import org.rsmod.api.repo.region.RegionRepository -import org.rsmod.api.table.InstanceSettingsRow import org.rsmod.events.EventBus import org.rsmod.events.KeyedEvent import org.rsmod.game.MapClock import org.rsmod.game.damage.DamageContributions import org.rsmod.game.entity.Npc -import org.rsmod.game.entity.PathingEntity import org.rsmod.game.entity.Player import org.rsmod.game.entity.PlayerList import org.rsmod.game.entity.npc.NpcUid -import org.rsmod.game.entity.util.PathingEntityCommon import org.rsmod.game.region.Region import org.rsmod.map.CoordGrid import org.rsmod.routefinder.collision.CollisionFlagMap @@ -218,6 +214,11 @@ constructor( public fun sessionForRegion(regionId: Int): InstanceSession? = regionToInstance[regionId]?.let(sessions::get) + public fun localCoord(session: InstanceSession, local: RegionLocal): CoordGrid? { + val region = regions[session.id] ?: return null + return session.localCoord(region, local) + } + public fun contributionsFor(id: InstanceId): DamageContributions? = sessionForId(id)?.damageContributions diff --git a/api/repo/src/main/kotlin/org/rsmod/api/repo/world/WorldRepository.kt b/api/repo/src/main/kotlin/org/rsmod/api/repo/world/WorldRepository.kt index 28f7b9220..d700fcf11 100644 --- a/api/repo/src/main/kotlin/org/rsmod/api/repo/world/WorldRepository.kt +++ b/api/repo/src/main/kotlin/org/rsmod/api/repo/world/WorldRepository.kt @@ -3,7 +3,6 @@ package org.rsmod.api.repo.world import dev.openrune.rscm.RSCM.asRSCM import dev.openrune.rscm.RSCMType import dev.openrune.types.ProjAnimType -import dev.openrune.types.SequenceServerType import dev.openrune.types.aconverted.SpotanimType import dev.openrune.types.aconverted.SynthType import jakarta.inject.Inject @@ -36,6 +35,17 @@ public class WorldRepository @Inject constructor(private val zoneUpdates: ZoneUp zoneUpdates.soundArea(source, synth.asRSCM(RSCMType.SYNTH), delay, loops, radius, size) } + public fun soundArea( + source: CoordGrid, + synth: SynthType, + delay: Int = 0, + loops: Int = 1, + radius: Int = 5, + size: Int = 0, + ) { + zoneUpdates.soundArea(source, synth.id, delay, loops, radius, size) + } + public fun soundArea( source: PathingEntity, synth: String, diff --git a/content/bosses/vorkath/build.gradle.kts b/content/bosses/vorkath/build.gradle.kts new file mode 100644 index 000000000..129ec5887 --- /dev/null +++ b/content/bosses/vorkath/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { id("base-conventions") } + +dependencies { + implementation(libs.guice) + implementation(libs.fastutil) + implementation(libs.rsprot.api) + implementation(projects.api.attr) + implementation(projects.api.bossHpBarPlugin) + implementation(projects.api.registry) + implementation(projects.engine.events) + implementation(projects.api.combat.combatCommons) + implementation(projects.api.combat.combatScripts) + implementation(projects.api.combat.combatFormulas) + implementation(projects.api.death) + implementation(projects.api.instances) + implementation(projects.api.mechanics.toxins) + implementation(projects.api.npc) + implementation(projects.api.player) + implementation(projects.api.playerOutput) + implementation(projects.api.pluginCommons) + implementation(projects.api.random) + implementation(projects.api.repo) + implementation(projects.api.route) + implementation(projects.api.script) + implementation(projects.api.spells) + implementation(projects.api.utils.utilsLogging) + implementation(projects.api.utils.utilsVars) + implementation(projects.engine.game) + implementation(projects.engine.map) + implementation(projects.engine.plugin) + implementation(projects.engine.routefinder) + testImplementation(kotlin("test")) + testImplementation("org.mockito:mockito-core:5.18.0") +} + + +tasks.test { workingDir = rootProject.projectDir } diff --git a/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathAccessPolicy.kt b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathAccessPolicy.kt new file mode 100644 index 000000000..891ca8475 --- /dev/null +++ b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathAccessPolicy.kt @@ -0,0 +1,14 @@ +package org.rsmod.content.bosses.vorkath + +import jakarta.inject.Inject +import jakarta.inject.Singleton +import org.rsmod.game.entity.Player + +/** + * Single replacement point for a future Dragon Slayer II quest check. OpenRune does not currently + * expose a complete Dragon Slayer II quest state, so the production default remains accessible. + */ +@Singleton +internal class VorkathAccessPolicy @Inject constructor() { + fun canAccess(@Suppress("UNUSED_PARAMETER") player: Player): Boolean = true +} diff --git a/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathAcidLayout.kt b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathAcidLayout.kt new file mode 100644 index 000000000..d6a268a78 --- /dev/null +++ b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathAcidLayout.kt @@ -0,0 +1,72 @@ +package org.rsmod.content.bosses.vorkath + +import org.rsmod.map.CoordGrid + +/** + * Capture-derived layout: all 200 casts use 48 three-by-three cells, eight edge segments, then + * the player tile if it was not already selected (56 or 57 distinct pools). + * + * Grid/ranges/order and boss exclusion are observed. Uniform choice among each cell's walkable + * candidates is inferred; decoded packets cannot establish the server's random-number routine. + */ +internal object VorkathAcidLayout { + private const val BOSS_SIZE = 7 + + fun select( + boss: CoordGrid, + player: CoordGrid, + isWalkable: (CoordGrid) -> Boolean, + choose: (List) -> CoordGrid, + ): Set { + val selected = linkedSetOf() + fun available(tile: CoordGrid): Boolean = + !occupiesBoss(boss, tile) && isWalkable(tile) + + fun sample(x: IntRange, z: IntRange) { + val candidates = buildList { + for (offsetZ in z) { + for (offsetX in x) { + val tile = boss.translate(offsetX, offsetZ) + if (available(tile)) add(tile) + } + } + } + // A changed collision map must not create unreachable pools or an endless reroll. + if (candidates.isEmpty()) return + val tile = choose(candidates) + require(tile in candidates) { "Acid selection returned a tile outside its capture cell" } + selected += tile + } + + // Canonical anchor (2269,4062): grid origin (2262,4055), outer bounds (2282,4075). + for (row in 0..6) { + for (column in 0..6) { + if (row == 3 && column == 3) continue + val x = -7 + column * 3 + val z = -7 + row * 3 + sample(x..x + 2, z..z + 2) + } + } + + // South, north, west, east; exactly this packet order in every supplied cast. + sample(-3..-1, -8..-8) + sample(7..9, -8..-8) + sample(-3..-1, 14..14) + sample(7..9, 14..14) + sample(-8..-8, -3..-1) + sample(-8..-8, 7..9) + sample(14..14, -3..-1) + sample(14..14, 7..9) + + if (available(player)) selected += player + return selected + } + + /** Seven centre tiles of the southern edge are untouched except for the forced player pool. */ + fun exitLane(boss: CoordGrid): Set = + (0 until BOSS_SIZE).mapTo(linkedSetOf()) { boss.translate(it, -8) } + + private fun occupiesBoss(boss: CoordGrid, tile: CoordGrid): Boolean = + tile.x in boss.x until boss.x + BOSS_SIZE && + tile.z in boss.z until boss.z + BOSS_SIZE +} diff --git a/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathAcidPool.kt b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathAcidPool.kt new file mode 100644 index 000000000..f98ec9a3d --- /dev/null +++ b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathAcidPool.kt @@ -0,0 +1,5 @@ +package org.rsmod.content.bosses.vorkath + +import org.rsmod.map.CoordGrid + +internal data class PendingAcidPool(val impactCycle: Int, val tile: CoordGrid) diff --git a/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathCombat.kt b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathCombat.kt new file mode 100644 index 000000000..2cb0c65ca --- /dev/null +++ b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathCombat.kt @@ -0,0 +1,66 @@ +package org.rsmod.content.bosses.vorkath + +import dev.openrune.ServerCacheManager +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import jakarta.inject.Inject +import org.rsmod.api.death.NpcDeath +import org.rsmod.api.script.onAiApPlayer2 +import org.rsmod.api.script.onModifyNpcHit +import org.rsmod.api.script.onNpcHit +import org.rsmod.api.script.onNpcQueue +import org.rsmod.game.entity.PlayerList +import org.rsmod.game.entity.player.PlayerUid +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +internal class VorkathCombat +@Inject +constructor( + private val encounters: VorkathEncounterManager, + private val death: NpcDeath, + private val players: PlayerList, +) : PluginScript() { + override fun ScriptContext.startup() { + val active = requireNpc(VorkathAssets.ACTIVE) + val sleeping = requireNpc(VorkathAssets.SLEEPING) + val spawn = requireNpc(VorkathAssets.SPAWN) + + onAiApPlayer2(active) { encounters.attack(this, it.target) } + onModifyNpcHit(active) { + val player = + if (hit.isFromPlayer) hit.sourceUid?.let(::PlayerUid)?.resolve(players) else null + if (player == null) hit.damage = 0 else encounters.modifyNpcHit(player, npc, hit) + } + onModifyNpcHit(sleeping) { hit.damage = 0 } + onModifyNpcHit(spawn) { + val player = + if (hit.isFromPlayer) hit.sourceUid?.let(::PlayerUid)?.resolve(players) else null + if (player == null) hit.damage = 0 else encounters.modifyNpcHit(player, npc, hit) + } + onNpcHit(spawn) { + if (npc.hitpoints == 0) encounters.beginSpawnDeath(npc) + } + onNpcQueue(active, "queue.death") { + val run = encounters.beginBossDeath(npc) ?: return@onNpcQueue + noneMode() + hideAllOps() + npc.anim(VorkathAssets.DEATH_ANIM) + // Captures remove the corpse and produce loot at animation +6. The native + // sequence's rounded seven-tick duration is not the encounter's death deadline. + delay(VORKATH_DEATH_TICKS) + if (encounters.canFinishDeath(run, npc)) { + if (run.rewardEligible) death.spawnDrops(this, npc.coords) + encounters.finishBossDeath(npc) + } + } + onNpcQueue(spawn, "queue.death") { + encounters.beginSpawnDeath(npc) + } + } + + private fun requireNpc(internal: String) = + requireNotNull(ServerCacheManager.getNpc(internal.asRSCM(RSCMType.NPC))) { + "Missing Vorkath npc definition: $internal" + } +} diff --git a/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathConfig.kt b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathConfig.kt new file mode 100644 index 000000000..4ad9d112e --- /dev/null +++ b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathConfig.kt @@ -0,0 +1,146 @@ +package org.rsmod.content.bosses.vorkath + +import org.rsmod.api.attr.AttributeKey +import org.rsmod.api.instances.InstanceArea +import org.rsmod.api.instances.InstanceSettings +import org.rsmod.api.instances.RegionLocal +import org.rsmod.map.CoordGrid + +internal const val VORKATH_INSTANCE_KEY = "vorkath" +internal const val VORKATH_MAX_HITPOINTS = 750 +internal const val VORKATH_NPC_LIFETIME = Int.MAX_VALUE +internal const val VORKATH_ATTACK_RATE = 5 +internal const val VORKATH_STANDARD_ATTACKS = 6 +internal const val VORKATH_ACID_SHOTS = 25 +internal const val VORKATH_SPAWN_DISTANCE = 8 +internal const val VORKATH_DEATH_TICKS = 6 +internal const val VORKATH_FREEZE_TICKS = 100 +internal const val VORKATH_ACID_POOL_LOC_ID = 32000 +internal const val VORKATH_DROP_DURATION = 18_000 +internal const val VORKATH_DEATH_FEE = 100_000 +internal const val VORKATH_CRATER_ENTRANCE_LOC_ID = 31990 +internal val VORKATH_CRATER_ENTRANCE_LOC_IDS = intArrayOf(31990, 31992, 31994) +internal const val VORKATH_WALL_MIN_LOCAL_X = 25 +internal const val VORKATH_WALL_MAX_LOCAL_X = 38 +internal const val VORKATH_WALL_LOCAL_Z = 20 +internal const val VORKATH_WALL_APPROACH_MIN_LOCAL_Z = 19 +internal const val VORKATH_WALL_APPROACH_MAX_LOCAL_Z = 22 +internal const val VORKATH_SOURCE_REGION_X = 35 +internal const val VORKATH_SOURCE_REGION_Z = 63 +internal const val VORKATH_WALL_OUTSIDE_LOCAL_Z = 20 +internal const val VORKATH_WALL_INSIDE_LOCAL_Z = 23 +internal const val VORKATH_WALL_CROSS_CLIENT_CYCLES = 38 + +internal val VORKATH_OUTSIDE = CoordGrid(2272, 4044, 0) +internal val VORKATH_RELLEKKA = CoordGrid(2642, 3697, 0) + +internal val VORKATH_DEFAULT_ENTRY = RegionLocal(0, 35, 63, 32, VORKATH_WALL_OUTSIDE_LOCAL_Z) +internal val VORKATH_AREA = + InstanceArea.copyRegions( + regionIds = listOf(9023), + enterCoord = VORKATH_DEFAULT_ENTRY, + exitCoord = VORKATH_OUTSIDE, + ) + +internal fun vorkathSpec(activeType: dev.openrune.types.NpcServerType) = + InstanceSettings( + maxPlayers = 1, + destroyWhenEmpty = true, + bossNpc = listOf(activeType), + bossName = "Vorkath", + recommendedCombat = 100..126, + teamSize = 1, + description = "Instanced Vorkath encounter", + ) + .withArea(VORKATH_AREA, settingsRowId = 0) + +internal enum class VorkathState { + ENTERING, + SLEEPING, + AWAKENING, + ACTIVE, + ZOMBIFIED_SPAWN_SPECIAL, + ACID_SPECIAL, + DYING, + LOOTABLE, + RESETTING, + ENDED, +} + +internal enum class VorkathSpecial { + ACID, + ZOMBIFIED_SPAWN, +} + +internal enum class VorkathStandardAttack { + MELEE, + RANGED, + MAGIC, + DRAGONFIRE, + VENOM_DRAGONFIRE, + PRAYER_DRAGONFIRE, + FIREBALL, +} + +internal object VorkathAssets { + const val SLEEPING = "npc.vorkath_sleeping" + const val SLEEPING_NOOP = "npc.vorkath_sleeping_noop" + const val ACTIVE = "npc.vorkath" + const val SPAWN = "npc.vorkath_spawn" + const val TORFINN = "npc.torfinn_ungael" + const val TORFINN_COLLECT = "npc.torfinn_collect_ungael" + const val TORFINN_RELLEKKA = "npc.torfinn_rellekka" + const val TORFINN_COLLECT_RELLEKKA = "npc.torfinn_collect_rellekka" + + const val WAKE_ANIM = "seq.ds2_vorkath_spawn" + const val DEATH_ANIM = "seq.ds2_vorkath_death" + const val ICE_WALL_JUMP_ANIM = "seq.human_spot_jump" + const val MELEE_ANIM = "seq.ds2_vorkath_attack_melee" + const val RANGED_ANIM = "seq.ds2_vorkath_ranged" + const val RANGED_UP_ANIM = "seq.ds2_vorkath_ranged_up" + const val ACID_ANIM = "seq.ds2_vorkath_acid" + const val SPAWN_DEATH_ANIM = "seq.ds2_spawn_death" + const val SPAWN_ATTACK_ANIM = "seq.ds2_spawn_attack" + const val FIREBALL_IMPACT = 1466 + const val RAPID_FIRE_IMPACT = 131 + const val SPAWN_EXPLOSION = 1460 + const val FIREBALL_IMPACT_SOUND = 163 + const val RAPID_FIRE_IMPACT_SOUND = 158 + + const val RANGED_TRAVEL = "spotanim.vorkath_ranged_travel" + const val MAGIC_TRAVEL = "spotanim.vorkath_magic_travel" + const val DRAGONFIRE_TRAVEL = "spotanim.dragon_ranged_fire_attack" + const val VENOM_DRAGONFIRE_TRAVEL = "spotanim.dragon_ranged_venom_attack" + const val PRAYER_DRAGONFIRE_TRAVEL = "spotanim.dragon_ranged_corrupting_attack" + const val RANGED_IMPACT = "spotanim.vorkath_ranged_impact" + const val MAGIC_IMPACT = "spotanim.vorkath_magic_impact" + const val FIREBALL_TRAVEL = "spotanim.vorkath_area_travel" + const val RAPID_FIRE_TRAVEL = "spotanim.vorkath_area_small_travel" + const val ACID_TRAVEL = "spotanim.vorkath_acid_travel" + const val SPAWN_TRAVEL = "spotanim.vorkath_spawn_travel" +} + +internal data class PendingFireball(val launchCycle: Int) + +internal data class PendingStandardEffect( + val impactCycle: Int, + val attack: VorkathStandardAttack, +) + +internal data class PendingTileHit( + val impactCycle: Int, + val tile: CoordGrid, + val minimum: Int, + val maximum: Int, + val kind: VorkathTileAttack, +) + +internal enum class VorkathTileAttack { + FIREBALL, + RAPID_FIRE, +} + +internal val VORKATH_PERSONAL_BEST_TICKS: AttributeKey = + AttributeKey(persistenceKey = "vorkath_personal_best_ticks") +internal val VORKATH_PREVIOUS_DROP_DURATION: AttributeKey = AttributeKey() +internal val VORKATH_STORAGE_REMINDER_SHOWN: AttributeKey = AttributeKey() diff --git a/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathDeathStorage.kt b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathDeathStorage.kt new file mode 100644 index 000000000..c8e4de98e --- /dev/null +++ b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathDeathStorage.kt @@ -0,0 +1,100 @@ +@file:OptIn(dev.openrune.types.util.UncheckedType::class) + +package org.rsmod.content.bosses.vorkath + +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import jakarta.inject.Inject +import jakarta.inject.Singleton +import org.rsmod.api.attr.AttributeKey +import org.rsmod.api.death.PlayerDeathContext +import org.rsmod.api.death.PlayerDeathDrops +import org.rsmod.api.death.PlayerDeathStorageHook +import org.rsmod.api.invtx.add +import org.rsmod.api.invtx.delete +import org.rsmod.api.invtx.invTransaction +import org.rsmod.api.invtx.select +import org.rsmod.api.player.output.mes +import org.rsmod.game.entity.Player +import org.rsmod.game.inv.InvObj + +internal val VORKATH_DEATH_STORAGE: AttributeKey> = + AttributeKey(persistenceKey = "vorkath_death_storage_v1") + +@Singleton +internal class VorkathDeathStorage +@Inject +constructor(private val encounters: VorkathEncounterManager) : PlayerDeathStorageHook { + override fun store( + context: PlayerDeathContext, + result: PlayerDeathDrops.DeathDropResult, + ): Boolean { + val player = context.player + val hadStoredItems = player.attr.has(VORKATH_DEATH_STORAGE) + if (!encounters.isActive(player)) { + if (hadStoredItems) { + player.attr.remove(VORKATH_DEATH_STORAGE) + player.attr.remove(VORKATH_STORAGE_REMINDER_SHOWN) + player.mes("Your items held by Torfinn were lost because you died an unsafe death.") + } + return false + } + val lost = result.supplyPile + result.lostTradeable + result.lostUntradeable + if (lost.isEmpty()) { + if (hadStoredItems) { + player.attr.remove(VORKATH_DEATH_STORAGE) + player.attr.remove(VORKATH_STORAGE_REMINDER_SHOWN) + player.mes( + "Your items held by Torfinn were lost because you died again before reclaiming them." + ) + } + return false + } + if (hadStoredItems) { + player.mes("Your previously stored items were lost.") + } + player.attr[VORKATH_DEATH_STORAGE] = VorkathStorageCodec.encode(lost) + player.attr.remove(VORKATH_STORAGE_REMINDER_SHOWN) + player.mes("Torfinn has collected your items. He will return them for 100,000 coins.") + return true + } + + fun hasItems(player: Player): Boolean = + VorkathStorageCodec.decode(player.attr[VORKATH_DEATH_STORAGE]).isNotEmpty() + + fun count(player: Player): Int = + VorkathStorageCodec.decode(player.attr[VORKATH_DEATH_STORAGE]).size + + fun reclaim(player: Player): Boolean { + val items = VorkathStorageCodec.decode(player.attr[VORKATH_DEATH_STORAGE]) + if (items.isEmpty()) { + player.mes("Torfinn is not holding any items for you.") + return false + } + val query = + player.invTransaction(player.inv) { + val inv = select(player.inv) + delete(inv, "obj.coins".asRSCM(RSCMType.OBJ), VORKATH_DEATH_FEE) + for (item in items) add(inv, item.id, item.count, item.vars) + } + if (!query.success) { + player.mes("You need 100,000 coins and enough inventory space for every stored item.") + return false + } + player.attr.remove(VORKATH_DEATH_STORAGE) + player.attr.remove(VORKATH_STORAGE_REMINDER_SHOWN) + player.mes("Torfinn returns all of your stored items.") + return true + } +} + +internal object VorkathStorageCodec { + internal fun encode(items: List): MutableList = + items.flatMapTo(mutableListOf()) { listOf(it.id, it.count, it.vars) } + + internal fun decode(values: List?): List = + values.orEmpty().chunked(3).mapNotNull { triple -> + if (triple.size != 3 || triple[1] <= 0) null + else InvObj(triple[0], triple[1], triple[2]) + } +} diff --git a/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathDragonfire.kt b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathDragonfire.kt new file mode 100644 index 000000000..5c9d7dd39 --- /dev/null +++ b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathDragonfire.kt @@ -0,0 +1,76 @@ +package org.rsmod.content.bosses.vorkath + +import org.rsmod.api.combat.commons.DragonfireProtection +import org.rsmod.game.entity.Player + +/** + * Wiki-derived Vorkath distribution: roll first, subtract potion protection, then variant + * reduction. The shared table remains authoritative for shield recognition and protection + * combinations. + */ +internal object VorkathDragonfire { + const val ANTIFIRE_REDUCTION = 10 + const val SUPER_ANTIFIRE_REDUCTION = 20 + + fun snapshot(player: Player): ProtectionSnapshot { + val reduction = + potionReduction( + antifire = player.vars["varbit.antifire_potion"] > 0, + superAntifire = player.vars["varbit.super_antifire_potion"] > 0, + ) + return fromReducedCaps( + DragonfireProtection.resolveMaxHit( + player, + DragonfireProtection.DragonfireType.Vorkath, + 80, + ), + DragonfireProtection.resolveVorkathResistedMaxHit(player), + reduction, + ) + } + + /** + * Pure equivalent of the player snapshot for exhaustive protection/distribution verification. + */ + fun snapshot( + shield: Boolean, + protect: Boolean, + antifire: Boolean, + superAntifire: Boolean, + ): ProtectionSnapshot = + fromReducedCaps( + DragonfireProtection.resolveVorkathMaxHit(shield, protect, antifire, superAntifire), + DragonfireProtection.resolveVorkathResistedMaxHit( + shield, + protect, + antifire, + superAntifire, + ), + potionReduction(antifire, superAntifire), + ) + + private fun fromReducedCaps( + maximum: Int, + resistedMaximum: Int, + reduction: Int, + ): ProtectionSnapshot = + // Vorkath's lowest base cap is 20, equal to the strongest potion subtraction. Restoring + // the subtraction therefore reconstructs the exact base even when the reduced cap is zero. + ProtectionSnapshot(maximum + reduction, resistedMaximum + reduction, reduction) + + private fun potionReduction(antifire: Boolean, superAntifire: Boolean): Int = + when { + superAntifire -> SUPER_ANTIFIRE_REDUCTION + antifire -> ANTIFIRE_REDUCTION + else -> 0 + } + + data class ProtectionSnapshot( + val maximum: Int, + val resistedMaximum: Int, + val potionReduction: Int, + ) { + fun damage(rawDamage: Int, attack: VorkathStandardAttack): Int = + VorkathRules.dragonfireMaximum((rawDamage - potionReduction).coerceAtLeast(0), attack) + } +} diff --git a/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathEncounterManager.kt b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathEncounterManager.kt new file mode 100644 index 000000000..7c876e1cc --- /dev/null +++ b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathEncounterManager.kt @@ -0,0 +1,1099 @@ +package org.rsmod.content.bosses.vorkath + +import com.github.michaelbull.logging.InlineLogger +import dev.openrune.ServerCacheManager +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import dev.openrune.types.MoveRestrict +import dev.openrune.types.ObjectServerType +import dev.openrune.types.aconverted.SpotanimType +import dev.openrune.types.aconverted.SynthType +import jakarta.inject.Inject +import jakarta.inject.Singleton +import org.rsmod.api.bossbar.plugin.BossHpBarScript +import org.rsmod.api.combat.commons.CombatEffects +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.combat.formulas.AccuracyFormulae +import org.rsmod.api.instances.InstanceAccess +import org.rsmod.api.instances.InstanceManager +import org.rsmod.api.instances.InstanceSession +import org.rsmod.api.instances.RegionLocal +import org.rsmod.api.mechanics.toxins.impl.PlayerVenom +import org.rsmod.api.npc.access.StandardNpcAccess +import org.rsmod.api.npc.apPlayer2 +import org.rsmod.api.npc.heal +import org.rsmod.api.npc.interact.AiPlayerInteractions +import org.rsmod.api.npc.owner.assignSpawnOwner +import org.rsmod.api.npc.owner.isSpawnOwnedBy +import org.rsmod.api.player.combatClearQueue +import org.rsmod.api.player.disablePrayers +import org.rsmod.api.player.hit.modifier.StandardPlayerHitModifier +import org.rsmod.api.player.hit.queueHit +import org.rsmod.api.player.output.mes +import org.rsmod.api.player.stat.hitpoints +import org.rsmod.api.player.vars.VarPlayerIntMapSetter +import org.rsmod.api.random.GameRandom +import org.rsmod.api.repo.loc.LocRepository +import org.rsmod.api.repo.npc.NpcRepository +import org.rsmod.api.repo.world.WorldRepository +import org.rsmod.api.route.StepFactory +import org.rsmod.game.MapClock +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.Player +import org.rsmod.game.entity.npc.NpcUid +import org.rsmod.game.entity.player.PlayerUid +import org.rsmod.game.entity.util.PathingEntityCommon +import org.rsmod.game.hit.HitBuilder +import org.rsmod.game.hit.HitType +import org.rsmod.game.loc.LocAngle +import org.rsmod.game.loc.LocInfo +import org.rsmod.game.loc.LocShape +import org.rsmod.game.map.collision.get +import org.rsmod.map.CoordGrid +import org.rsmod.routefinder.collision.CollisionFlagMap +import org.rsmod.routefinder.flag.CollisionFlag + +@Singleton +internal class VorkathEncounterManager +@Inject +constructor( + private val clock: MapClock, + private val instances: InstanceManager, + private val npcs: NpcRepository, + private val locs: LocRepository, + private val interactions: AiPlayerInteractions, + private val collision: CollisionFlagMap, + private val random: GameRandom, + private val accuracy: AccuracyFormulae, + private val world: WorldRepository, + private val playerHitModifier: StandardPlayerHitModifier, + private val bossBars: BossHpBarScript, +) { + private val log = InlineLogger() + private val spawnSteps = StepFactory(collision) + private val COMBAT_STATES = setOf( + VorkathState.ACTIVE, VorkathState.ACID_SPECIAL, VorkathState.ZOMBIFIED_SPAWN_SPECIAL + ) + private val runs = mutableMapOf() + private var generation = 1L + + fun validateAssets() { + listOf( + VorkathAssets.SLEEPING, + VorkathAssets.SLEEPING_NOOP, + VorkathAssets.ACTIVE, + VorkathAssets.SPAWN, + VorkathAssets.TORFINN, + VorkathAssets.TORFINN_COLLECT, + VorkathAssets.TORFINN_RELLEKKA, + VorkathAssets.TORFINN_COLLECT_RELLEKKA, + ) + .forEach(::requireNpc) + listOf( + VorkathAssets.WAKE_ANIM, + VorkathAssets.ICE_WALL_JUMP_ANIM, + VorkathAssets.MELEE_ANIM, + VorkathAssets.RANGED_ANIM, + VorkathAssets.RANGED_UP_ANIM, + VorkathAssets.ACID_ANIM, + VorkathAssets.SPAWN_DEATH_ANIM, + VorkathAssets.SPAWN_ATTACK_ANIM, + ) + .forEach { internal -> + val id = internal.asRSCM(RSCMType.SEQ) + requireNotNull(ServerCacheManager.getAnim(id)) { + "Missing Vorkath sequence: $internal" + } + } + listOf( + VorkathAssets.RANGED_TRAVEL, + VorkathAssets.MAGIC_TRAVEL, + VorkathAssets.DRAGONFIRE_TRAVEL, + VorkathAssets.VENOM_DRAGONFIRE_TRAVEL, + VorkathAssets.PRAYER_DRAGONFIRE_TRAVEL, + VorkathAssets.RANGED_IMPACT, + VorkathAssets.MAGIC_IMPACT, + VorkathAssets.FIREBALL_TRAVEL, + VorkathAssets.RAPID_FIRE_TRAVEL, + VorkathAssets.ACID_TRAVEL, + VorkathAssets.SPAWN_TRAVEL, + ) + .forEach { it.asRSCM(RSCMType.SPOTANIM) } + VORKATH_CRATER_ENTRANCE_LOC_IDS.forEach { + requireNotNull(ServerCacheManager.getObject(it)) { "Missing Vorkath entrance loc: $it" } + } + acidPoolType() + } + + fun teleportOutside(player: Player): Boolean { + val current = instances.sessionForPlayer(player) + if (current != null && current.key != VORKATH_INSTANCE_KEY) { + player.mes("Leave your current instance before travelling to Ungael.") + return false + } + runs[player.uid]?.let { abort(player, "staff travel command", teleport = false) } + if (current != null) instances.leave(player, current, clock.cycle) + prepare(player) + PathingEntityCommon.telejump(player, collision, VORKATH_OUTSIDE) + player.mes("You arrive outside Vorkath's arena. Climb over the ice chunks to enter.") + return true + } + + fun teleportRellekka(player: Player): Boolean { + runs[player.uid]?.let { abort(player, "Torfinn travel", teleport = false) } + val current = instances.sessionForPlayer(player) + if (current != null && current.key == VORKATH_INSTANCE_KEY) { + instances.leave(player, current, clock.cycle) + } + prepare(player) + PathingEntityCommon.telejump(player, collision, VORKATH_RELLEKKA) + player.mes("Torfinn sails you back to Rellekka.") + return true + } + + fun enter(player: Player, wallLocalX: Int): Boolean { + if (player.hitpoints <= 0 || player.isAccessProtected) { + player.mes("Finish what you are doing before entering Vorkath's arena.") + return false + } + if (runs.containsKey(player.uid) || instances.sessionForPlayer(player) != null) { + player.mes("You already have an active instance.") + return false + } + val activeType = requireNpc(VorkathAssets.ACTIVE) + requireNpc(VorkathAssets.SLEEPING) + requireNpc(VorkathAssets.SPAWN) + val result = + instances.create( + owner = player, + key = VORKATH_INSTANCE_KEY, + spec = vorkathSpec(activeType), + access = InstanceAccess.Private, + currentTick = clock.cycle, + ) + if (result !is InstanceManager.Result.Created) { + player.mes( + (result as? InstanceManager.Result.Failed)?.reason ?: "The arena is unavailable." + ) + return false + } + prepare(player) + val entry = wallTile(result.session, wallLocalX, inside = false) ?: result.enter + PathingEntityCommon.telejump(player, collision, entry) + instances.finalizeEntry(player, result.session, clock.cycle) + player.lootDropDuration?.let { player.attr[VORKATH_PREVIOUS_DROP_DURATION] = it } + player.lootDropDuration = VORKATH_DROP_DURATION + val sleeping = + spawnNpc(player, result.session, VorkathAssets.SLEEPING, bossCoord(result.session)) + sleeping.movementLocked = true + val run = + Run( + player = player, + session = result.session, + generation = generation++, + firstSpecial = VorkathRules.firstSpecial(random.of(2)), + boss = sleeping, + initialState = VorkathState.ENTERING, + ) + runs[player.uid] = run + run.state = VorkathState.SLEEPING + player.mes("Vorkath is sleeping. Poke him when you are ready.") + log.info { + "[Vorkath] player=${player.displayName} generation=${run.generation} instance=${result.session.id}" + } + return true + } + + fun poke(player: Player, npc: Npc): Boolean { + val run = runs[player.uid] ?: return false + if (!inArena(run) || !player.isSlotAssigned || player.hitpoints <= 0) return false + if (run.state != VorkathState.SLEEPING || run.boss !== npc || !npc.isSpawnOwnedBy(player)) + return false + npc.mode = null + npc.movementLocked = true + run.timeline.wake(clock.cycle) + npc.transmog(requireNpc(VorkathAssets.SLEEPING_NOOP), Int.MAX_VALUE) + npc.anim(VorkathAssets.WAKE_ANIM) + player.mes("Vorkath awakens.") + return true + } + + fun tickAll() { + runs.values.toList().forEach { tick(it.player) } + } + + internal fun auditRun(player: Player): Run? = runs[player.uid] + + fun tick(player: Player) { + val run = runs[player.uid] ?: return + if ( + !player.isSlotAssigned || + player.pendingLogout || + player.loggingOut + ) { + abort(player, "player unavailable", teleport = false, logout = player.loggingOut || player.pendingLogout) + return + } + if (player.hitpoints <= 0) { + // Keep membership until the death hook chooses the existing Vorkath reclaim behavior. + if (run.state != VorkathState.ENDED) { + run.state = VorkathState.ENDED + clearMechanics(run) + bossBars.onClose(player, run.boss, instant = true) + } + return + } + if (!inArena(run)) { + abort(player, "left arena", teleport = false) + return + } + if (run.state in COMBAT_STATES && !run.boss.isSlotAssigned) { + abort(player, "boss removed", teleport = false) + return + } + if (run.state in COMBAT_STATES && run.boss.hitpoints <= 0) { + // The death queue runs later in the tick. Do not launch pending effects before it. + clearMechanics(run) + return + } + if (run.state in COMBAT_STATES) { + bossBars.onUpdate(player, run.boss) + val heals = run.pendingHeals.filter { it.first <= clock.cycle } + run.pendingHeals.removeAll(heals.toSet()) + heals.forEach { run.boss.heal(it.second, showHitsplat = true) } + processFireballLaunches(run) + processPendingStandardEffects(run) + processPending(run) + } + if (clock.cycle >= run.spawnRetireCycle) { + run.retiringSpawns.forEach { if (it.isSlotAssigned) npcs.del(it, Int.MAX_VALUE) } + run.retiringSpawns.clear() + run.spawnRetireCycle = Int.MAX_VALUE + } + when (run.state) { + VorkathState.ENTERING, + VorkathState.SLEEPING, + VorkathState.RESETTING, + VorkathState.DYING, + VorkathState.ENDED -> Unit + VorkathState.AWAKENING -> if (clock.cycle >= run.wakeCycle) activate(run) + VorkathState.ACTIVE -> { + if (run.timeline.attackDue(clock.cycle) && + run.standardAttacks == VORKATH_STANDARD_ATTACKS) beginSpecial(run) + else keepAggressive(run) + } + VorkathState.ACID_SPECIAL -> tickAcid(run) + VorkathState.ZOMBIFIED_SPAWN_SPECIAL -> tickSpawn(run) + VorkathState.LOOTABLE -> + if (clock.cycle >= run.respawnCycle) { + run.state = VorkathState.RESETTING + respawn(run) + } + } + } + + fun attack(access: StandardNpcAccess, target: Player) { + val run = runs[target.uid] ?: return + if (run.boss !== access.npc || !validTarget(run) || !run.timeline.attackDue(clock.cycle)) return + if (run.standardAttacks == VORKATH_STANDARD_ATTACKS) { + beginSpecial(run) + return + } + val attack = chooseStandard(run, target) + if (attack == VorkathStandardAttack.FIREBALL) { + launchFireball(run) + return + } + launchStandard(run, target, attack) + } + + fun isEncounterNpc(npc: Npc): Boolean = runs.values.any { it.owns(npc) } + + fun isOwnedBy(player: Player, npc: Npc): Boolean = + runs[player.uid]?.let { it.owns(npc) && npc.isSpawnOwnedBy(player) } == true + + fun attackDenial(player: Player, npc: Npc): String? { + val run = runs[player.uid] ?: return "You do not have an active Vorkath encounter." + if (!run.owns(npc)) return "That creature belongs to another encounter." + if ( + npc === run.boss && + run.state != VorkathState.ACTIVE && + run.state != VorkathState.ACID_SPECIAL + ) { + return if (run.state == VorkathState.SLEEPING || run.state == VorkathState.AWAKENING) { + "Poke Vorkath to wake him first." + } else { + "Vorkath is immune during this phase." + } + } + return null + } + + fun modifyNpcHit(player: Player, npc: Npc, hit: HitBuilder) { + val run = runs[player.uid] + if (run == null || !run.owns(npc) || !npc.isSpawnOwnedBy(player)) { + hit.damage = 0 + return + } + if (npc === run.boss) { + hit.damage = + when (run.state) { + VorkathState.ACTIVE -> hit.damage + VorkathState.ACID_SPECIAL -> VorkathRules.acidDamage(hit.damage) + else -> 0 + } + } + } + + fun beginBossDeath(npc: Npc): Run? { + val run = runs.values.firstOrNull { it.boss === npc } ?: return null + if ( + run.state == VorkathState.DYING || + run.state == VorkathState.LOOTABLE || + run.state == VorkathState.RESETTING || + run.state == VorkathState.ENDED + ) + return null + run.state = VorkathState.DYING + run.player.combatClearQueue() + clearMechanics(run) + if (run.rewardEligible) { + val newKillcount = run.player.vars["varp.kc_vorkath"] + 1 + VarPlayerIntMapSetter.set(run.player, "varp.kc_vorkath", newKillcount) + run.killcount = newKillcount + run.killTicks = (clock.cycle - run.startCycle).coerceAtLeast(0) + } + return run + } + + fun finishBossDeath(npc: Npc) { + val run = runs.values.firstOrNull { it.boss === npc } ?: return + if (runs[run.player.uid] !== run) return + if (run.rewardEligible) { + val oldPb = run.player.attr[VORKATH_PERSONAL_BEST_TICKS] + val personalBest = oldPb == null || run.killTicks < oldPb + if (personalBest) run.player.attr[VORKATH_PERSONAL_BEST_TICKS] = run.killTicks + val suffix = if (personalBest) " New personal best!" else "" + run.player.mes( + "Vorkath kill ${run.killcount}: ${VorkathRules.formatTicks(run.killTicks)}.$suffix" + ) + } else { + run.player.mes( + "Developer Vorkath kill complete; no loot, killcount, or record awarded." + ) + } + run.state = VorkathState.LOOTABLE + run.respawnCycle = clock.cycle + run.boss = npc + respawn(run) + } + + fun beginSpawnDeath(npc: Npc) { + val run = runs.values.firstOrNull { it.spawn === npc } ?: return + if (run.state != VorkathState.ZOMBIFIED_SPAWN_SPECIAL || + run.spawnDeathCycle != Int.MAX_VALUE) return + npc.movementLocked = true + npc.mode = null + npc.hideAllOps() + // Anchor to the lethal hit, independently of death-queue processing order. + run.spawnDeathCycle = clock.cycle + 3 + } + + fun canFinishDeath(run: Run, npc: Npc): Boolean = + runs[run.player.uid] === run && run.boss === npc && run.state == VorkathState.DYING && + inArena(run) && run.player.hitpoints > 0 + + fun isActive(player: Player): Boolean = runs[player.uid]?.state != null + + fun escape(player: Player, localX: Int): Boolean { + if (!runs.containsKey(player.uid)) return false + val publicWallTile = + CoordGrid( + x = (VORKATH_SOURCE_REGION_X * 64) + localX, + z = (VORKATH_SOURCE_REGION_Z * 64) + VORKATH_WALL_OUTSIDE_LOCAL_Z, + level = 0, + ) + abort(player, "left through the ice chunks", teleport = false) + if (player.isSlotAssigned) { + PathingEntityCommon.telejump(player, collision, publicWallTile) + } + player.mes("You leave Vorkath's arena.") + return true + } + + fun wallTile(player: Player, localX: Int, inside: Boolean): CoordGrid? { + val session = instances.sessionForPlayer(player) ?: return null + if (session.key != VORKATH_INSTANCE_KEY) return null + return wallTile(session, localX, inside) + } + + private fun wallTile(session: InstanceSession, localX: Int, inside: Boolean): CoordGrid? { + if (localX !in VORKATH_WALL_MIN_LOCAL_X..VORKATH_WALL_MAX_LOCAL_X) return null + val localZ = if (inside) VORKATH_WALL_INSIDE_LOCAL_Z else VORKATH_WALL_OUTSIDE_LOCAL_Z + return instances.localCoord(session, RegionLocal(0, 35, 63, localX, localZ)) + } + + fun abort(player: Player, reason: String, teleport: Boolean, logout: Boolean = false) { + val run = runs.remove(player.uid) ?: return + run.state = VorkathState.ENDED + clearMechanics(run) + bossBars.onClose(player, run.boss, instant = true) + if (run.boss.isSlotAssigned) npcs.del(run.boss, Int.MAX_VALUE) + player.lootDropDuration = player.attr[VORKATH_PREVIOUS_DROP_DURATION] + player.attr.remove(VORKATH_PREVIOUS_DROP_DURATION) + if (logout) { + instances.handleLogout(player, clock.cycle) + } else { + val exit = instances.leave(player, run.session, clock.cycle) + if (teleport && player.isSlotAssigned) + PathingEntityCommon.telejump(player, collision, exit) + } + prepare(player) + log.info { + "[Vorkath] player=${player.displayName} generation=${run.generation} abort=$reason" + } + } + + internal fun forceWake(player: Player): Boolean { + val run = runs[player.uid] ?: return false + if (run.state != VorkathState.SLEEPING) return false + run.rewardEligible = false + return poke(player, run.boss) + } + + internal fun forceSpecial(player: Player, special: VorkathSpecial): Boolean { + val run = runs[player.uid] ?: return false + if (run.state != VorkathState.ACTIVE) return false + run.rewardEligible = false + clearMechanics(run) + run.state = VorkathState.ACTIVE + run.timeline.forceSpecial(clock.cycle, special) + run.boss.actionDelay = clock.cycle + beginSpecial(run) + return true + } + + internal fun forceAttack(player: Player, attack: VorkathStandardAttack): Boolean { + val run = runs[player.uid] ?: return false + if (run.state != VorkathState.ACTIVE) return false + run.rewardEligible = false + run.timeline.forceAttack(clock.cycle) + run.boss.actionDelay = clock.cycle + if (attack == VorkathStandardAttack.FIREBALL) { + launchFireball(run) + } else { + launchStandard(run, player, attack) + } + return true + } + + internal fun forceReset(player: Player): Boolean { + val run = runs[player.uid] ?: return false + run.rewardEligible = false + run.state = VorkathState.RESETTING + clearMechanics(run) + if (run.boss.isSlotAssigned) npcs.del(run.boss, Int.MAX_VALUE) + respawn(run) + return true + } + + private fun activate(run: Run) { + val sleeping = run.boss + val active = spawnNpc(run.player, run.session, VorkathAssets.ACTIVE, bossCoord(run.session)) + active.baseHitpointsLvl = VORKATH_MAX_HITPOINTS + active.hitpoints = VORKATH_MAX_HITPOINTS + active.movementLocked = true + active.apRangeOverride = 32 + active.apRequiresLineOfSight = false + run.boss = active + run.timeline.activate(clock.cycle) + active.actionDelay = run.timeline.nextAttackCycle + run.startCycle = clock.cycle + if (sleeping.isSlotAssigned) npcs.del(sleeping, Int.MAX_VALUE) + active.apPlayer2(run.player, interactions) + bossBars.onOpen(run.player, active) + } + + private fun respawn(run: Run) { + clearMechanics(run) + bossBars.onClose(run.player, run.boss, instant = true) + if (run.boss.isSlotAssigned) npcs.del(run.boss, Int.MAX_VALUE) + val sleeping = + spawnNpc(run.player, run.session, VorkathAssets.SLEEPING, bossCoord(run.session)) + sleeping.movementLocked = true + run.boss = sleeping + run.timeline.reset(VorkathRules.firstSpecial(random.of(2))) + run.player.mes("Vorkath settles back into a deep sleep.") + } + + private fun keepAggressive(run: Run) { + if (run.boss.isSlotAssigned && run.boss.mode == null) { + run.boss.apPlayer2(run.player, interactions) + } + } + + private fun launchStandard( + run: Run, + target: Player, + attack: VorkathStandardAttack, + ) { + if (!validTarget(run) || !run.timeline.attackDue(clock.cycle)) return + val spec = VorkathProjectiles.standard(attack) + val projectile = spec?.build(run.boss, target.coords, target) + val dragonfire = + attack == VorkathStandardAttack.DRAGONFIRE || + attack == VorkathStandardAttack.VENOM_DRAGONFIRE || + attack == VorkathStandardAttack.PRAYER_DRAGONFIRE + val accurate = + when (attack) { + VorkathStandardAttack.MELEE -> + accuracy.rollMeleeAccuracy(run.boss, target, MeleeAttackType.Crush, random) + VorkathStandardAttack.RANGED -> + accuracy.rollRangedAccuracy(run.boss, target, random) + else -> accuracy.rollMagicAccuracy(run.boss, target, random) + } + val protection = if (dragonfire) VorkathDragonfire.snapshot(target) else null + val maximum = + when (attack) { + VorkathStandardAttack.MELEE, VorkathStandardAttack.RANGED -> 32 + VorkathStandardAttack.MAGIC -> 30 + VorkathStandardAttack.FIREBALL -> 0 + else -> + if (accurate) { + requireNotNull(protection).maximum + } else { + requireNotNull(protection).resistedMaximum + } + } + val rolled = if (accurate || dragonfire) random.of(0..maximum) else 0 + val damage = if (dragonfire) requireNotNull(protection).damage(rolled, attack) else rolled + val hitType = + when (attack) { + VorkathStandardAttack.MELEE -> HitType.Melee + VorkathStandardAttack.RANGED -> HitType.Ranged + VorkathStandardAttack.MAGIC -> HitType.Magic + else -> HitType.Typeless + } + + run.boss.facePlayer(target) + run.boss.anim( + if (attack == VorkathStandardAttack.MELEE) { + VorkathAssets.MELEE_ANIM + } else { + VorkathAssets.RANGED_ANIM + } + ) + projectile?.let(world::projAnim) + run.timeline.standardLaunched(clock.cycle) + run.boss.actionDelay = run.timeline.nextAttackCycle + + // Player hit queues count their first processing tick, including this NPC launch tick. + val impactDelay = (projectile?.endTime?.div(30) ?: 0) + 1 + VorkathProjectiles.impact(attack)?.let { + PathingEntityCommon.spotanim( + target, + it, + delay = projectile?.endTime ?: 0, + height = 124, + slot = 0, + ) + } + target.queueHit( + source = run.boss, + delay = impactDelay, + type = hitType, + damage = damage, + modifier = playerHitModifier, + ) + if ( + attack == VorkathStandardAttack.VENOM_DRAGONFIRE || + attack == VorkathStandardAttack.PRAYER_DRAGONFIRE + ) { + run.pendingStandardEffects += PendingStandardEffect(clock.cycle + impactDelay, attack) + } + } + + private fun launchFireball(run: Run) { + if (!validTarget(run) || !run.timeline.attackDue(clock.cycle)) return + run.timeline.standardLaunched(clock.cycle) + run.boss.actionDelay = run.timeline.nextAttackCycle + run.boss.anim(VorkathAssets.RANGED_UP_ANIM, delay = 2) + run.pendingFireballs += PendingFireball(clock.cycle + 1) + } + + private fun processFireballLaunches(run: Run) { + val due = run.pendingFireballs.filter { it.launchCycle <= clock.cycle } + run.pendingFireballs.removeAll(due.toSet()) + for (launch in due) { + val tile = run.player.coords + launchTileProjectile(run, tile, VorkathProjectiles.FIREBALL) + run.pendingHits += PendingTileHit( + clock.cycle + VorkathProjectiles.FIREBALL.impactTicks, tile, 0, 121, + VorkathTileAttack.FIREBALL + ) + } + } + + private fun processPendingStandardEffects(run: Run) { + val due = run.pendingStandardEffects.filter { it.impactCycle <= clock.cycle } + run.pendingStandardEffects.removeAll(due.toSet()) + if (!validTarget(run)) return + for (effect in due) { + when (effect.attack) { + VorkathStandardAttack.VENOM_DRAGONFIRE -> PlayerVenom.tryVenom(run.player) + VorkathStandardAttack.PRAYER_DRAGONFIRE -> run.player.disablePrayers() + else -> Unit + } + } + } + + private fun beginSpecial(run: Run) { + if (!validTarget(run)) return + when (run.timeline.beginSpecial(clock.cycle)) { + VorkathSpecial.ACID -> beginAcid(run) + VorkathSpecial.ZOMBIFIED_SPAWN -> beginSpawn(run) + } + run.boss.mode = null + } + + private fun beginAcid(run: Run) { + run.boss.anim(VorkathAssets.ACID_ANIM) + clearAcid(run) + val selected = VorkathAcidLayout.select( + boss = run.boss.coords, + player = run.player.coords, + isWalkable = { collision[it] and CollisionFlag.BLOCK_WALK == 0 }, + choose = { it[random.of(it.size)] }, + ) + run.acidSafeLane += VorkathAcidLayout.exitLane(run.boss.coords) + .filter { it !in selected && collision[it] and CollisionFlag.BLOCK_WALK == 0 } + selected.forEach { tile -> + launchTileProjectile(run, tile, VorkathProjectiles.ACID) + run.pendingAcidPools += PendingAcidPool(clock.cycle + 3, tile) + } + } + + private fun tickAcid(run: Run) { + processPendingAcid(run) + if (run.player.coords in run.acidTiles) { + val damage = random.of(1..10) + val hit = + run.player.queueHit( + source = run.boss, + delay = 1, + type = HitType.Typeless, + damage = damage, + modifier = playerHitModifier, + ) + run.pendingHeals += (clock.cycle + 1) to hit.damage + } + if (run.timeline.rapidShotDue(clock.cycle)) { + val tile = run.player.coords + run.boss.facePlayer(run.player) + val delay = launchTileProjectile(run, tile, VorkathProjectiles.RAPID_FIRE) + run.pendingHits += + PendingTileHit( + impactCycle = clock.cycle + delay, + tile = tile, + minimum = 25, + maximum = 41, + kind = VorkathTileAttack.RAPID_FIRE, + ) + run.timeline.rapidLaunched(clock.cycle) + if (run.shotsFired == VORKATH_ACID_SHOTS) clearAcid(run) + } + if ( + clock.cycle >= run.timeline.specialStartCycle + 33 + ) + finishSpecial(run, recoveryTicks = 0) + } + + private fun processPending(run: Run) { + val due = run.pendingHits.filter { it.impactCycle <= clock.cycle } + run.pendingHits.removeAll(due.toSet()) + for (hit in due) { + val graphic = if (hit.kind == VorkathTileAttack.FIREBALL) + VorkathAssets.FIREBALL_IMPACT else VorkathAssets.RAPID_FIRE_IMPACT + val height = if (hit.kind == VorkathTileAttack.FIREBALL) 38 else 30 + world.spotanimMap(SpotanimType(graphic), hit.tile, height) + if (hit.kind == VorkathTileAttack.FIREBALL) + world.soundArea( + hit.tile, + SynthType(VorkathAssets.FIREBALL_IMPACT_SOUND), + radius = 12, + ) + else + world.soundArea( + hit.tile, + SynthType(VorkathAssets.RAPID_FIRE_IMPACT_SOUND), + radius = 12, + ) + val distance = run.player.coords.chebyshevDistance(hit.tile) + val maximum = + when (hit.kind) { + VorkathTileAttack.FIREBALL -> VorkathRules.fireballMaximum(distance) + VorkathTileAttack.RAPID_FIRE -> if (distance == 0) hit.maximum else 0 + } + if (maximum <= 0) continue + run.player.queueHit( + source = run.boss, + delay = 1, + type = HitType.Typeless, + damage = + if (hit.kind == VorkathTileAttack.FIREBALL) { + VorkathRules.fireballDamage(random.of(0..hit.maximum), distance) + } else { + random.of(hit.minimum..maximum) + }, + modifier = playerHitModifier, + ) + } + } + + private fun processPendingAcid(run: Run) { + val due = run.pendingAcidPools.filter { it.impactCycle <= clock.cycle } + run.pendingAcidPools.removeAll(due.toSet()) + for (pool in due) { + if (pool.tile in run.acidTiles) continue + val visual = + locs.add( + pool.tile, + acidPoolType(), + Int.MAX_VALUE, + LocAngle[random.of(4)], + LocShape.CentrepieceStraight, + ) + run.acidTiles += pool.tile + run.acidVisuals += visual + } + } + + private fun beginSpawn(run: Run) { + run.boss.anim(VorkathAssets.RANGED_ANIM) + val ice = VorkathProjectiles.ICE.build(run.boss, run.player.coords, run.player) + world.projAnim(ice) + PathingEntityCommon.spotanim( + run.player, + 369, + delay = ice.endTime, + height = 0, + slot = 0, + ) + run.freezeCycle = clock.cycle + ice.endTime / 30 + run.spawnLaunchCycle = run.freezeCycle + } + + private fun tickSpawn(run: Run) { + if (clock.cycle >= run.freezeCycle) { + run.freezeCycle = Int.MAX_VALUE + // This freeze belongs to the special; existing PvP immunity must not prevent it. + run.player.clearQueue("queue.com_retaliate_npc") + run.player.clearInteraction() + CombatEffects.clearFreezeImmunity(run.player) + CombatEffects.freeze(run.player, VORKATH_FREEZE_TICKS) + run.ownsFreeze = run.player.frozen + } + if (clock.cycle >= run.spawnLaunchCycle) { + run.spawnLaunchCycle = Int.MAX_VALUE + val candidates = arenaTiles(run.session).filter { + it.chebyshevDistance(run.player.coords) == VORKATH_SPAWN_DISTANCE && + !bossOccupies(run, it) && collision[it] and CollisionFlag.BLOCK_WALK == 0 && + spawnCanReach(run, it) + } + if (candidates.isEmpty()) { + log.error { "[Vorkath] no valid spawn location at distance $VORKATH_SPAWN_DISTANCE for ${run.session.id}" } + finishSpecial(run) + return + } + val tile = candidates[random.of(candidates.size)] + run.boss.anim(VorkathAssets.RANGED_UP_ANIM) + launchTileProjectile(run, tile, VorkathProjectiles.SPAWN) + run.pendingSpawnTile = tile + run.spawnArrivalCycle = clock.cycle + 4 + } + val pendingTile = run.pendingSpawnTile + if (pendingTile != null && clock.cycle >= run.spawnArrivalCycle) { + val spawn = spawnNpc(run.player, run.session, VorkathAssets.SPAWN, pendingTile) + spawn.baseHitpointsLvl = 38 + spawn.hitpoints = 38 + spawn.mode = null + run.spawn = spawn + run.pendingSpawnTile = null + return // First walk is the tick after arrival. + } + val spawn = run.spawn ?: return + if (!spawn.isSlotAssigned) { + finishSpecial(run) + return + } + if (spawn.hitpoints <= 0) { + if (clock.cycle >= run.spawnDeathCycle) { + spawn.anim(VorkathAssets.SPAWN_DEATH_ANIM) + run.retiringSpawns += spawn + run.spawn = null + run.spawnRetireCycle = clock.cycle + 2 + finishSpecial(run) + } + return + } + if (clock.cycle >= run.spawnExplosionCycle) { + run.spawnExplosionCycle = Int.MAX_VALUE + val damage = VorkathRules.zombifiedSpawnDamage(spawn.hitpoints) + run.player.queueHit( + source = spawn, + delay = 1, + type = HitType.Typeless, + damage = damage, + modifier = playerHitModifier, + ) + // Captures remove the NPC on the explosion's damage tick (contact +1). + if (spawn.isSlotAssigned) npcs.del(spawn, Int.MAX_VALUE) + run.spawn = null + finishSpecial(run, recoveryTicks = 0) + } else if (run.spawnExplosionCycle != Int.MAX_VALUE) { + return + } else if (spawn.coords.chebyshevDistance(run.player.coords) == 0) { + beginSpawnExplosion(run, spawn, contactCycle = clock.cycle) + } else { + spawn.facePlayer(run.player) + // Arrival is processed after movement and player hits, before NPC/world updates. + // The map clock has advanced by then, so retain this movement tick's cycle. + val movementCycle = clock.cycle + val next = nextSpawnStep(run, spawn.coords) + if (next == CoordGrid.NULL) return + // Only the final step may overlap the target. Captures retain boss-body clipping. + spawn.moveRestrict = + if (next == run.player.coords) MoveRestrict.PassThru else MoveRestrict.Normal + spawn.walk(next) { + beginSpawnExplosion(run, spawn, contactCycle = movementCycle) + } + } + } + + internal fun nextSpawnStep(run: Run, source: CoordGrid): CoordGrid { + val target = run.player.coords + if (source == target) return target + if (source.chebyshevDistance(target) == 1 && !bossOccupies(run, target)) { + val diagonal = source.x != target.x && source.z != target.z + val crossesBossCorner = diagonal && + (bossOccupies(run, CoordGrid(source.x, target.z, source.level)) || + bossOccupies(run, CoordGrid(target.x, source.z, source.level))) + if (!crossesBossCorner && spawnSteps.validated(source, target) == target) return target + } + return spawnSteps.validated(source, target, extraFlag = CollisionFlag.BLOCK_NPCS) + } + + internal fun spawnCanReach(run: Run, source: CoordGrid): Boolean { + // The ring and edge-clipping are captured; rejecting stuck direct routes is inferred. + var tile = source + repeat(VORKATH_SPAWN_DISTANCE * 2) { + if (tile == run.player.coords) return true + tile = nextSpawnStep(run, tile) + if (tile == CoordGrid.NULL) return false + } + return tile == run.player.coords + } + + private fun beginSpawnExplosion(run: Run, spawn: Npc, contactCycle: Int) { + if ( + !validTarget(run) || + run.state != VorkathState.ZOMBIFIED_SPAWN_SPECIAL || + run.spawn !== spawn || + !spawn.isSlotAssigned || + spawn.hitpoints <= 0 || + run.spawnExplosionCycle != Int.MAX_VALUE || + spawn.coords != run.player.coords + ) return + spawn.anim(VorkathAssets.SPAWN_ATTACK_ANIM) + world.spotanimMap( + SpotanimType(VorkathAssets.SPAWN_EXPLOSION), spawn.coords, height = 30, delay = 22 + ) + releaseFreeze(run) + spawn.movementLocked = true + spawn.abortRoute() + run.spawnExplosionCycle = contactCycle + 1 + } + + private fun finishSpecial(run: Run, recoveryTicks: Int = 1) { + clearAcid(run) + clearSpawn(run) + releaseFreeze(run) + run.timeline.finishSpecial(clock.cycle, recoveryTicks) + run.boss.actionDelay = run.timeline.nextAttackCycle + run.boss.apPlayer2(run.player, interactions) + } + + private fun clearMechanics(run: Run) { + clearAcid(run) + run.pendingHits.clear() + run.pendingFireballs.clear() + run.pendingStandardEffects.clear() + run.pendingHeals.clear() + clearSpawn(run) + run.retiringSpawns.forEach { if (it.isSlotAssigned) npcs.del(it, Int.MAX_VALUE) } + run.retiringSpawns.clear() + run.spawnRetireCycle = Int.MAX_VALUE + releaseFreeze(run) + run.boss.clearInteraction() + run.boss.clearFacingLock() + run.boss.mode = null + run.boss.vars["varn.attacking_player"] = PlayerUid.NULL.packed + run.boss.vars["varn.aggressive_player"] = PlayerUid.NULL.packed + if (run.player.vars["varp.aggressive_npc"] == run.boss.uid.packed) + VarPlayerIntMapSetter.set(run.player, "varp.aggressive_npc", NpcUid.NULL.packed) + run.player.combatClearQueue() + run.player.clearInteraction() + run.player.resetSpotanim() + } + + private fun releaseFreeze(run: Run) { + run.freezeCycle = Int.MAX_VALUE + if (!run.ownsFreeze) return + CombatEffects.unfreeze(run.player) + run.player.resetSpotanim() + run.ownsFreeze = false + } + + private fun clearAcid(run: Run) { + run.acidVisuals.forEach { locs.del(it, Int.MAX_VALUE) } + run.acidVisuals.clear() + run.acidTiles.clear() + run.acidSafeLane.clear() + run.pendingAcidPools.clear() + } + + private fun clearSpawn(run: Run) { + run.pendingSpawnTile = null + run.spawnLaunchCycle = Int.MAX_VALUE + run.spawnArrivalCycle = Int.MAX_VALUE + run.spawnExplosionCycle = Int.MAX_VALUE + run.spawnDeathCycle = Int.MAX_VALUE + val spawn = run.spawn ?: return + run.spawn = null + if (spawn.isSlotAssigned) npcs.del(spawn, Int.MAX_VALUE) + } + + private fun inArena(run: Run): Boolean { + if (instances.sessionForPlayer(run.player) !== run.session) return false + // Session regionIds are allocation IDs, not packed OSRS map-square coordinates. + val origin = + instances.localCoord(run.session, RegionLocal(0, 35, 63, 0, 0)) ?: return false + val tile = run.player.coords + return tile.level == origin.level && + tile.x in origin.x until (origin.x + 64) && + tile.z in origin.z until (origin.z + 64) + } + + private fun validTarget(run: Run): Boolean = + runs[run.player.uid] === run && inArena(run) && + run.player.isSlotAssigned && !run.player.pendingLogout && !run.player.loggingOut && + run.player.hitpoints > 0 && run.boss.isSlotAssigned && run.boss.hitpoints > 0 && + run.state in COMBAT_STATES + + private fun chooseStandard(run: Run, target: Player): VorkathStandardAttack { + val adjacent = run.boss.isWithinDistance(target, 1) && !bossOccupies(run, target.coords) + val weights = VorkathRules.standardWeights(adjacent) + var roll = random.of(weights.values.sum()) + for ((attack, weight) in weights) { + if (roll < weight) return attack + roll -= weight + } + return VorkathStandardAttack.MAGIC + } + + private fun arenaTiles(session: InstanceSession): List { + val tiles = ArrayList(550) + // All four bounds are witnessed by the supplied spawn launches. + for (x in 21..43) { + for (z in 22..44) { + instances.localCoord(session, RegionLocal(0, 35, 63, x, z))?.let(tiles::add) + } + } + return tiles + } + + private fun bossOccupies(run: Run, tile: CoordGrid): Boolean = + tile.x in run.boss.coords.x until (run.boss.coords.x + run.boss.size) && + tile.z in run.boss.coords.z until (run.boss.coords.z + run.boss.size) + + private fun bossCoord(session: InstanceSession): CoordGrid = + requireNotNull(instances.localCoord(session, RegionLocal(0, 35, 63, 29, 30))) + + private fun spawnNpc( + player: Player, + session: InstanceSession, + internal: String, + coords: CoordGrid, + ): Npc { + val type = requireNpc(internal) + val npc = Npc(type, coords) + npc.mode = null + npc.assignSpawnOwner(player, clock.cycle) + npcs.add(npc, VORKATH_NPC_LIFETIME) + instances.attachNpc(session.id, npc) + return npc + } + + private fun launchTileProjectile(run: Run, tile: CoordGrid, spec: VorkathProjectile): Int { + world.projAnim(spec.build(run.boss, tile, facingTile = run.player.coords)) + return spec.impactTicks + } + + private fun acidPoolType(): ObjectServerType = + requireNotNull(ServerCacheManager.getObject(VORKATH_ACID_POOL_LOC_ID)) { + "Missing Vorkath acid-pool loc: $VORKATH_ACID_POOL_LOC_ID" + } + + private fun requireNpc(internal: String) = + requireNotNull(ServerCacheManager.getNpc(internal.asRSCM(RSCMType.NPC))) { + "Missing Vorkath npc definition: $internal" + } + + private fun prepare(player: Player) { + player.combatClearQueue() + player.clearInteraction() + player.resetAnim() + } + + internal data class Run( + val player: Player, + val session: InstanceSession, + val generation: Long, + var boss: Npc, + val initialState: VorkathState = VorkathState.SLEEPING, + val firstSpecial: VorkathSpecial = VorkathSpecial.ACID, + var startCycle: Int = 0, + var respawnCycle: Int = Int.MAX_VALUE, + var spawnArrivalCycle: Int = Int.MAX_VALUE, + var freezeCycle: Int = Int.MAX_VALUE, + var spawnLaunchCycle: Int = Int.MAX_VALUE, + var spawnExplosionCycle: Int = Int.MAX_VALUE, + var spawnDeathCycle: Int = Int.MAX_VALUE, + var spawnRetireCycle: Int = Int.MAX_VALUE, + var ownsFreeze: Boolean = false, + val retiringSpawns: MutableList = mutableListOf(), + val pendingFireballs: MutableList = mutableListOf(), + val pendingStandardEffects: MutableList = mutableListOf(), + val pendingHeals: MutableList> = mutableListOf(), + var spawn: Npc? = null, + var pendingSpawnTile: CoordGrid? = null, + var killcount: Int = 0, + var killTicks: Int = 0, + var rewardEligible: Boolean = true, + val acidTiles: MutableSet = linkedSetOf(), + val acidSafeLane: MutableSet = linkedSetOf(), + val acidVisuals: MutableList = mutableListOf(), + val pendingAcidPools: MutableList = mutableListOf(), + val pendingHits: MutableList = mutableListOf(), + ) { + val timeline = VorkathTimeline(firstSpecial).also { it.state = initialState } + var state: VorkathState + get() = timeline.state + set(value) { timeline.state = value } + val standardAttacks: Int get() = timeline.standardAttacks + val nextSpecial: VorkathSpecial get() = timeline.nextSpecial + val wakeCycle: Int get() = timeline.wakeCycle + val shotsFired: Int get() = timeline.shotsFired + fun owns(npc: Npc): Boolean = npc === boss || npc === spawn || npc in retiringSpawns + } +} diff --git a/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathLifecycle.kt b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathLifecycle.kt new file mode 100644 index 000000000..30eed09fe --- /dev/null +++ b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathLifecycle.kt @@ -0,0 +1,94 @@ +package org.rsmod.content.bosses.vorkath + +import jakarta.inject.Inject +import jakarta.inject.Singleton +import org.rsmod.api.death.NpcAttackValidateHook +import org.rsmod.api.death.NpcAttackValidateResult +import org.rsmod.api.death.PlayerDeathCleanupHook +import org.rsmod.api.death.PlayerDeathContext +import org.rsmod.api.death.PlayerDeathDrops.Companion.DROP_DURATION_STANDARD +import org.rsmod.api.death.PlayerDeathDrops.Companion.standardKeepCount +import org.rsmod.api.death.PlayerDeathHandling +import org.rsmod.api.death.PlayerDeathHook +import org.rsmod.api.death.UntradeableHandling +import org.rsmod.api.game.process.GameLifecycle +import org.rsmod.api.instances.events.InstancePlayerLeaveEvent +import org.rsmod.api.instances.events.instanceEventId +import org.rsmod.api.player.hook.PlayerPostTickHook +import org.rsmod.api.player.output.mes +import org.rsmod.api.script.onEvent +import org.rsmod.api.script.onPlayerLogout +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.Player +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +@Singleton +internal class VorkathAttackHook +@Inject +constructor(private val encounters: VorkathEncounterManager) : NpcAttackValidateHook { + override fun validate(player: Player, npc: Npc): NpcAttackValidateResult { + if (!encounters.isEncounterNpc(npc)) return NpcAttackValidateResult.Pass + if (!encounters.isOwnedBy(player, npc)) { + return NpcAttackValidateResult.Deny( + "That creature belongs to another adventurer's encounter." + ) + } + val denial = encounters.attackDenial(player, npc) + return if (denial == null) NpcAttackValidateResult.BypassSingleWayPvnRestriction + else NpcAttackValidateResult.Deny(denial) + } +} + +@Singleton +internal class VorkathPlayerDeathHook +@Inject +constructor(private val encounters: VorkathEncounterManager) : PlayerDeathHook { + override fun handleDeath(context: PlayerDeathContext): PlayerDeathHandling? { + if (!encounters.isActive(context.player)) return null + return PlayerDeathHandling( + keepCount = standardKeepCount(context.hasProtectItem), + dropReceiver = context.player, + dropDuration = DROP_DURATION_STANDARD, + revealDelay = 0, + supplyPile = false, + untradeableHandling = UntradeableHandling.DROP, + ) + } +} + +@Singleton +internal class VorkathDeathCleanup +@Inject +constructor(private val encounters: VorkathEncounterManager) : PlayerDeathCleanupHook { + override fun cleanup(player: Player) { + encounters.abort(player, "player death", teleport = false) + } +} + +internal class VorkathLifecycle +@Inject +constructor( + private val encounters: VorkathEncounterManager, + private val storage: VorkathDeathStorage, +) : PluginScript(), PlayerPostTickHook { + override fun ScriptContext.startup() { + // Encounter visuals must be produced before zone/player update buffers are sent. + onEvent { encounters.tickAll() } + onEvent(instanceEventId(VORKATH_INSTANCE_KEY)) { + encounters.abort(player, "instance membership ended", teleport = false) + } + onPlayerLogout { + encounters.abort(player, "logout or disconnect", teleport = false, logout = true) + } + } + + override fun onPostTick(player: Player) { + if (storage.hasItems(player) && !player.attr.has(VORKATH_STORAGE_REMINDER_SHOWN)) { + player.attr[VORKATH_STORAGE_REMINDER_SHOWN] = true + player.mes( + "Torfinn is holding ${storage.count(player)} item stacks from your Vorkath death." + ) + } + } +} diff --git a/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathModule.kt b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathModule.kt new file mode 100644 index 000000000..44fe614d7 --- /dev/null +++ b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathModule.kt @@ -0,0 +1,18 @@ +package org.rsmod.content.bosses.vorkath + +import org.rsmod.api.death.NpcAttackValidateHook +import org.rsmod.api.death.PlayerDeathCleanupHook +import org.rsmod.api.death.PlayerDeathHook +import org.rsmod.api.death.PlayerDeathStorageHook +import org.rsmod.api.player.hook.PlayerPostTickHook +import org.rsmod.plugin.module.PluginModule + +public class VorkathModule : PluginModule() { + override fun bind() { + addSetBinding(VorkathAttackHook::class.java) + addSetBinding(VorkathPlayerDeathHook::class.java) + addSetBinding(VorkathDeathStorage::class.java) + addSetBinding(VorkathDeathCleanup::class.java) + addSetBinding(VorkathLifecycle::class.java) + } +} diff --git a/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathProjectiles.kt b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathProjectiles.kt new file mode 100644 index 000000000..003fae6e3 --- /dev/null +++ b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathProjectiles.kt @@ -0,0 +1,110 @@ +package org.rsmod.content.bosses.vorkath + +import kotlin.math.hypot +import kotlin.math.roundToInt +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.Player +import org.rsmod.game.proj.ProjAnim +import org.rsmod.map.CoordGrid + +/** + * Native revision-240 spot effects and raw packet geometry from the five RSProx captures. + * End time is in client cycles. Gameplay impacts use floor(end / 30), as witnessed in capture 3279. + */ +internal data class VorkathProjectile( + val spot: Int, + val start: Int, + val end: Int, + val angle: Int, + val startHeight: Int, + val endHeight: Int, + val progress: Int = 128, + val distanceAdjusted: Boolean = false, + val tileTargeted: Boolean = false, +) { + val impactTicks: Int get() = end / 30 + + fun build( + source: Npc, + tile: CoordGrid, + target: Player? = null, + facingTile: CoordGrid = target?.coords ?: tile, + ): ProjAnim = build(source.coords, source.size, tile, target?.slotId, facingTile) + + fun build( + source: CoordGrid, + sourceSize: Int, + tile: CoordGrid, + targetSlot: Int? = null, + facingTile: CoordGrid = tile, + ): ProjAnim = fromMouth(VorkathProjectiles.mouth(source, sourceSize, facingTile), tile, targetSlot) + + /** A fixed launch tile also permits direct replay of the captured mouth coordinates. */ + fun fromMouth(mouth: CoordGrid, tile: CoordGrid, targetSlot: Int? = null): ProjAnim = + ProjAnim( + spotanim = spot, + startHeight = startHeight, + endHeight = endHeight, + startTime = start, + endTime = if (distanceAdjusted) 70 + 5 * mouth.chebyshevDistance(tile) else end, + angle = angle, + progress = progress, + // Inferred: keep the captured launch tile fixed instead of reattaching it to the NPC. + // The decoded source block does not expose the raw source attachment index. + sourceIndex = 0, + targetIndex = if (tileTargeted) 0 else targetSlot?.let { -(it + 1) } ?: 0, + startCoord = mouth, + endCoord = tile, + ) +} + +internal object VorkathProjectiles { + // The stored end values are the request's capture witnesses. Flight uses the observed + // distance formula (2,547 matching packets), including ice; they are not fixed style speeds. + val RANGED = VorkathProjectile(1477, 30, 95, 14, 142, 124, distanceAdjusted = true) + val MAGIC = VorkathProjectile(1479, 30, 80, 14, 142, 124, distanceAdjusted = true) + val DRAGONFIRE = VorkathProjectile(393, 30, 80, 14, 142, 124, distanceAdjusted = true) + val VENOM = VorkathProjectile(1470, 30, 80, 14, 142, 124, distanceAdjusted = true) + val PRAYER = VorkathProjectile(1471, 30, 80, 14, 142, 124, distanceAdjusted = true) + val ICE = VorkathProjectile(395, 30, 80, 14, 142, 124, distanceAdjusted = true) + val FIREBALL = VorkathProjectile(1481, 0, 120, 46, 340, 38, tileTargeted = true) + val ACID = VorkathProjectile(1483, 32, 90, 46, 340, 0, tileTargeted = true) + val RAPID_FIRE = VorkathProjectile(1482, 0, 30, 22, 138, 30, tileTargeted = true) + val SPAWN = VorkathProjectile(1484, 32, 120, 46, 340, 0, tileTargeted = true) + + /** + * The captures place the mouth two tiles ahead of the seven-tile boss's centre and turn it + * with the player, including during acid. This smooth projection's rounding between observed + * directions is inferred: decoded packets do not expose the NPC's interpolated model facing. + * [facingTile] is the player position, not an individual pool/spawn landing coordinate. + */ + fun mouth(source: CoordGrid, sourceSize: Int, facingTile: CoordGrid): CoordGrid { + val centre = source.translate(sourceSize / 2, sourceSize / 2) + val dx = (facingTile.x - centre.x).toDouble() + val dz = (facingTile.z - centre.z).toDouble() + val distance = hypot(dx, dz) + if (distance == 0.0) return centre.translate(0, -2) + return centre.translate((2.0 * dx / distance).roundToInt(), (2.0 * dz / distance).roundToInt()) + } + + fun standard(attack: VorkathStandardAttack): VorkathProjectile? = when (attack) { + VorkathStandardAttack.MELEE -> null + VorkathStandardAttack.RANGED -> RANGED + VorkathStandardAttack.MAGIC -> MAGIC + VorkathStandardAttack.DRAGONFIRE -> DRAGONFIRE + VorkathStandardAttack.VENOM_DRAGONFIRE -> VENOM + VorkathStandardAttack.PRAYER_DRAGONFIRE -> PRAYER + VorkathStandardAttack.FIREBALL -> FIREBALL + } + + fun impact(attack: VorkathStandardAttack): Int? = when (attack) { + VorkathStandardAttack.RANGED -> 1478 + VorkathStandardAttack.MAGIC -> 1480 + VorkathStandardAttack.DRAGONFIRE -> 1466 + VorkathStandardAttack.VENOM_DRAGONFIRE -> 1472 + VorkathStandardAttack.PRAYER_DRAGONFIRE -> 1473 + else -> null + } + + val all = listOf(RANGED, MAGIC, DRAGONFIRE, VENOM, PRAYER, ICE, FIREBALL, ACID, RAPID_FIRE, SPAWN) +} diff --git a/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathRules.kt b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathRules.kt new file mode 100644 index 000000000..8d0d9bec1 --- /dev/null +++ b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathRules.kt @@ -0,0 +1,84 @@ +package org.rsmod.content.bosses.vorkath + +import kotlin.math.max + +internal object VorkathRules { + fun isCraterWall(localX: Int, localZ: Int): Boolean = + localZ in VORKATH_WALL_APPROACH_MIN_LOCAL_Z..VORKATH_WALL_APPROACH_MAX_LOCAL_Z && + localX in VORKATH_WALL_MIN_LOCAL_X..VORKATH_WALL_MAX_LOCAL_X + + fun isPublicCraterApproach(worldX: Int, worldZ: Int, level: Int): Boolean = + level == 0 && + (worldX ushr 6) == VORKATH_SOURCE_REGION_X && + (worldZ ushr 6) == VORKATH_SOURCE_REGION_Z && + isCraterWall(localX = worldX and 0x3F, localZ = worldZ and 0x3F) + + fun firstSpecial(roll: Int): VorkathSpecial = + when (roll and 1) { + 0 -> VorkathSpecial.ACID + else -> VorkathSpecial.ZOMBIFIED_SPAWN + } + + fun nextSpecial(current: VorkathSpecial): VorkathSpecial = + when (current) { + VorkathSpecial.ACID -> VorkathSpecial.ZOMBIFIED_SPAWN + VorkathSpecial.ZOMBIFIED_SPAWN -> VorkathSpecial.ACID + } + + /** + * Wiki/Mod Ash (20 February 2018): magic:ranged relative weights are 3:4. The complete + * melee/dragonfire distribution is not established by the supplied captures: their positive + * unit weights are retained as inferred policy, never a melee-only override. + */ + fun standardWeights(adjacent: Boolean): Map = buildMap { + if (adjacent) put(VorkathStandardAttack.MELEE, 1) + put(VorkathStandardAttack.RANGED, 4) + put(VorkathStandardAttack.MAGIC, 3) + put(VorkathStandardAttack.DRAGONFIRE, 1) + put(VorkathStandardAttack.VENOM_DRAGONFIRE, 1) + put(VorkathStandardAttack.PRAYER_DRAGONFIRE, 1) + put(VorkathStandardAttack.FIREBALL, 1) + } + + fun fireballMaximum(distanceFromTarget: Int): Int = fireballDamage(121, distanceFromTarget) + + /** Wiki-derived: halve the same sampled bomb hit on adjacent tiles; two tiles is safe. */ + fun fireballDamage(rawDamage: Int, distanceFromTarget: Int): Int = + when { + distanceFromTarget <= 0 -> rawDamage.coerceAtLeast(0) + distanceFromTarget == 1 -> rawDamage.coerceAtLeast(0) / 2 + else -> 0 + } + + fun dragonfireMaximum(baseMaximum: Int, attack: VorkathStandardAttack): Int = + when (attack) { + VorkathStandardAttack.VENOM_DRAGONFIRE, + VorkathStandardAttack.PRAYER_DRAGONFIRE -> (baseMaximum - 5).coerceAtLeast(0) + else -> baseMaximum.coerceAtLeast(0) + } + + fun zombifiedSpawnMaximum(remainingHitpoints: Int): Int = + zombifiedSpawnDamage(remainingHitpoints) + + /** + * Wiki-derived fixed health-scaled explosion, not a random roll up to this value. Full health + * is 60; integer truncation for partial health remains inferred. + */ + fun zombifiedSpawnDamage(remainingHitpoints: Int): Int = + ((remainingHitpoints.coerceIn(0, 38) * 60) / 38).coerceIn(0, 60) + + fun acidDamage(rawDamage: Int): Int = max(0, rawDamage) / 2 + + fun formatTicks(ticks: Int): String { + val tenths = ticks.coerceAtLeast(0) * 6 + val minutes = tenths / 600 + val seconds = (tenths / 10) % 60 + val decimal = tenths % 10 + return if (minutes > 0) "%d:%02d.%d".format(minutes, seconds, decimal) + else "%d.%d seconds".format(seconds, decimal) + } + + fun isGuaranteedHeadKill(killcount: Int): Boolean = killcount == 50 + + fun storageFeeAffordable(coins: Int): Boolean = coins >= VORKATH_DEATH_FEE +} diff --git a/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathTimeline.kt b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathTimeline.kt new file mode 100644 index 000000000..b2d9ef3e3 --- /dev/null +++ b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathTimeline.kt @@ -0,0 +1,93 @@ +package org.rsmod.content.bosses.vorkath + +/** Encounter clock; all deadlines are absolute game ticks, never projectile helper durations. */ +internal class VorkathTimeline(firstSpecial: VorkathSpecial) { + var state = VorkathState.SLEEPING + var nextSpecial = firstSpecial + var standardAttacks = 0 + private set + var nextAttackCycle = Int.MAX_VALUE + private set + var wakeCycle = Int.MAX_VALUE + private set + var specialStartCycle = Int.MAX_VALUE + private set + var shotsFired = 0 + private set + + fun wake(now: Int) { + check(state == VorkathState.SLEEPING) + state = VorkathState.AWAKENING + wakeCycle = now + 7 // RSProx 3279: 5085 -> 5092. + } + + fun activate(now: Int) { + check(state == VorkathState.AWAKENING) + state = VorkathState.ACTIVE + nextAttackCycle = now + 1 + } + + fun attackDue(now: Int): Boolean = + state == VorkathState.ACTIVE && now >= nextAttackCycle + + fun standardLaunched(now: Int) { + check(attackDue(now) && standardAttacks < VORKATH_STANDARD_ATTACKS) + standardAttacks++ + nextAttackCycle = now + VORKATH_ATTACK_RATE + } + + fun beginSpecial(now: Int): VorkathSpecial { + check(attackDue(now) && standardAttacks == VORKATH_STANDARD_ATTACKS) + val selected = nextSpecial + nextSpecial = VorkathRules.nextSpecial(selected) + standardAttacks = 0 + specialStartCycle = now + shotsFired = 0 + state = when (selected) { + VorkathSpecial.ACID -> VorkathState.ACID_SPECIAL + VorkathSpecial.ZOMBIFIED_SPAWN -> VorkathState.ZOMBIFIED_SPAWN_SPECIAL + } + return selected + } + + fun rapidShotDue(now: Int): Boolean = + state == VorkathState.ACID_SPECIAL && + shotsFired < VORKATH_ACID_SHOTS && + now >= specialStartCycle + 4 + shotsFired + + fun rapidLaunched(now: Int) { + check(rapidShotDue(now)) + shotsFired++ + } + + fun finishSpecial(now: Int, recoveryTicks: Int) { + check(state == VorkathState.ACID_SPECIAL || state == VorkathState.ZOMBIFIED_SPAWN_SPECIAL) + state = VorkathState.ACTIVE + nextAttackCycle = now + recoveryTicks + specialStartCycle = Int.MAX_VALUE + } + + fun reset(firstSpecial: VorkathSpecial) { + state = VorkathState.SLEEPING + nextSpecial = firstSpecial + standardAttacks = 0 + shotsFired = 0 + nextAttackCycle = Int.MAX_VALUE + wakeCycle = Int.MAX_VALUE + specialStartCycle = Int.MAX_VALUE + } + + /** Staff diagnostics still use the same transition and launch paths, with rewards disabled. */ + fun forceSpecial(now: Int, special: VorkathSpecial) { + check(state == VorkathState.ACTIVE) + nextSpecial = special + standardAttacks = VORKATH_STANDARD_ATTACKS + nextAttackCycle = now + } + + fun forceAttack(now: Int) { + check(state == VorkathState.ACTIVE) + if (standardAttacks == VORKATH_STANDARD_ATTACKS) standardAttacks = 0 + nextAttackCycle = now + } +} diff --git a/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathWorld.kt b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathWorld.kt new file mode 100644 index 000000000..ea23da1c9 --- /dev/null +++ b/content/bosses/vorkath/src/main/kotlin/org/rsmod/content/bosses/vorkath/VorkathWorld.kt @@ -0,0 +1,147 @@ +package org.rsmod.content.bosses.vorkath + +import dev.openrune.ServerCacheManager +import jakarta.inject.Inject +import org.rsmod.api.config.constants +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.hook.TeleportType +import org.rsmod.api.player.output.mes +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onEvent +import org.rsmod.api.script.onOpLoc1 +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.api.script.onOpNpc3 +import org.rsmod.api.script.onOpNpc4 +import org.rsmod.game.entity.npc.NpcStateEvents +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +internal class VorkathWorld +@Inject +constructor( + private val encounters: VorkathEncounterManager, + private val storage: VorkathDeathStorage, + private val access: VorkathAccessPolicy, +) : PluginScript() { + override fun ScriptContext.startup() { + encounters.validateAssets() + onEvent { + if ( + npc.type.internalName == VorkathAssets.SLEEPING || + npc.type.internalName == VorkathAssets.SLEEPING_NOOP + ) { + npc.mode = null + npc.movementLocked = true + } + } + VORKATH_CRATER_ENTRANCE_LOC_IDS.forEach { id -> + onOpLoc1(requireNotNull(ServerCacheManager.getObject(id))) { + arriveDelay() + val onPublicApproach = + VorkathRules.isPublicCraterApproach( + worldX = player.coords.x, + worldZ = player.coords.z, + level = player.coords.level, + ) + if (encounters.isActive(player) || onPublicApproach) { + val crossingX = + player.coords.lx.coerceIn( + VORKATH_WALL_MIN_LOCAL_X, + VORKATH_WALL_MAX_LOCAL_X, + ) + crossIceWall(crossingX) + } else { + player.mes("Nothing interesting happens.") + } + } + } + onOpNpc1(VorkathAssets.SLEEPING) { + arriveDelay() + encounters.poke(player, it.npc) + } + + val ungaelTorfinn = listOf(VorkathAssets.TORFINN, VorkathAssets.TORFINN_COLLECT) + val rellekkaTorfinn = + listOf(VorkathAssets.TORFINN_RELLEKKA, VorkathAssets.TORFINN_COLLECT_RELLEKKA) + ungaelTorfinn.forEach { torfinn -> + onOpNpc1(torfinn) { talkToTorfinn(it.npc) } + onOpNpc3(torfinn) { + arriveDelay() + encounters.teleportRellekka(player) + } + onOpNpc4(torfinn) { reclaimFromTorfinn(it.npc) } + } + rellekkaTorfinn.forEach { torfinn -> + onOpNpc1(torfinn) { talkToTorfinn(it.npc) } + onOpNpc3(torfinn) { + arriveDelay() + encounters.teleportOutside(player) + } + onOpNpc4(torfinn) { reclaimFromTorfinn(it.npc) } + } + } + + private suspend fun ProtectedAccess.crossIceWall(localX: Int) { + if (encounters.isActive(player)) { + val destination = encounters.wallTile(player, localX, inside = false) + if (destination == null) { + player.mes("The way over the ice chunks is unavailable.") + return + } + anim(VorkathAssets.ICE_WALL_JUMP_ANIM) + exactMove( + player.coords, + destination, + delay1 = 0, + delay2 = VORKATH_WALL_CROSS_CLIENT_CYCLES, + dir = constants.em_face_south, + teleportType = TeleportType.Exempt, + ) + delay(2) + encounters.escape(player, localX) + return + } + if (!access.canAccess(player)) { + player.mes("You must complete Dragon Slayer II before fighting Vorkath.") + return + } + if (!encounters.enter(player, localX)) return + val destination = encounters.wallTile(player, localX, inside = true) + if (destination == null) { + encounters.abort(player, "ice-wall crossing failed", teleport = true) + return + } + anim(VorkathAssets.ICE_WALL_JUMP_ANIM) + exactMove( + player.coords, + destination, + delay1 = 0, + delay2 = VORKATH_WALL_CROSS_CLIENT_CYCLES, + dir = constants.em_face_north, + teleportType = TeleportType.Exempt, + ) + delay(2) + } + + private suspend fun ProtectedAccess.talkToTorfinn(npc: org.rsmod.game.entity.Npc) = + startDialogue(npc) { + if (!storage.hasItems(player)) { + chatNpc(neutral, "I am not holding any of your belongings.") + } else { + reclaimDialogue() + } + } + + private suspend fun ProtectedAccess.reclaimFromTorfinn(npc: org.rsmod.game.entity.Npc) = + startDialogue(npc) { reclaimDialogue() } + + private suspend fun Dialogue.reclaimDialogue() { + if (!storage.hasItems(player)) { + chatNpc(neutral, "I am not holding any of your belongings.") + return + } + chatNpc(neutral, "I recovered your belongings from Ungael. My fee is 100,000 coins.") + val choice = choice2("Pay 100,000 coins.", 1, "Not now.", 2) + if (choice == 1) storage.reclaim(player) + } +} diff --git a/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathAcidLayoutTest.kt b/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathAcidLayoutTest.kt new file mode 100644 index 000000000..8a4da4163 --- /dev/null +++ b/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathAcidLayoutTest.kt @@ -0,0 +1,87 @@ +package org.rsmod.content.bosses.vorkath + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.rsmod.map.CoordGrid + +class VorkathAcidLayoutTest { + private val boss = CoordGrid(2269, 4062) + + @Test + fun firstCapturedLayoutReplaysEveryProjectileDestinationInOrder() { + // rsprox-3279:118924..119092, acid cast t5169. Final destination is the player tile. + val captured = listOf( + 2263 to 4056, 2267 to 4057, 2270 to 4056, 2272 to 4057, 2274 to 4055, + 2277 to 4055, 2282 to 4055, 2263 to 4060, 2266 to 4060, 2270 to 4058, + 2271 to 4059, 2276 to 4058, 2278 to 4058, 2281 to 4060, 2262 to 4062, + 2266 to 4063, 2269 to 4061, 2272 to 4061, 2276 to 4063, 2278 to 4062, + 2282 to 4063, 2264 to 4065, 2265 to 4066, 2268 to 4065, 2276 to 4064, + 2278 to 4064, 2281 to 4066, 2264 to 4067, 2266 to 4068, 2268 to 4067, + 2273 to 4069, 2274 to 4069, 2279 to 4069, 2282 to 4069, 2264 to 4072, + 2267 to 4070, 2268 to 4072, 2272 to 4071, 2274 to 4071, 2277 to 4071, + 2281 to 4072, 2264 to 4075, 2266 to 4075, 2269 to 4075, 2272 to 4074, + 2276 to 4073, 2277 to 4075, 2282 to 4075, + 2266 to 4054, 2277 to 4054, 2267 to 4076, 2277 to 4076, + 2261 to 4060, 2261 to 4071, 2283 to 4060, 2283 to 4070, + 2271 to 4061, + ).map { (x, z) -> CoordGrid(x, z) } + var index = 0 + val selected = VorkathAcidLayout.select(boss, captured.last(), { true }) { candidates -> + val tile = captured[index++] + assertTrue(tile in candidates, "Capture destination $tile missing from cell $index") + tile + } + assertEquals(56, index) + assertEquals(captured, selected.toList()) + } + + @Test + fun playerAlreadySelectedProduces56PoolsWithoutADuplicateProjectile() { + val player = CoordGrid(2262, 4055) + val pools = VorkathAcidLayout.select(boss, player, { true }, List::first) + assertEquals(56, pools.size) + assertTrue(player in pools) + } + + @Test + fun centreExitLaneSurvivesEveryCellChoiceExceptTheForcedPlayerTile() { + val expectedLane = (2269..2275).mapTo(linkedSetOf()) { CoordGrid(it, 4054) } + assertEquals(expectedLane, VorkathAcidLayout.exitLane(boss)) + for (offset in 0..8) { + val pools = VorkathAcidLayout.select(boss, CoordGrid(2271, 4061), { true }) { + it[offset % it.size] + } + assertTrue(pools.size in 56..57) + assertTrue(pools.none(expectedLane::contains)) + assertTrue(pools.none { it.z == 4053 }) + assertTrue(pools.none { it.x in 2269..2275 && it.z in 4062..4068 }) + } + val standingInLane = CoordGrid(2272, 4054) + val pools = VorkathAcidLayout.select(boss, standingInLane, { true }, List::first) + assertEquals(setOf(standingInLane), pools.intersect(expectedLane)) + } + + @Test + fun instanceTranslationPreservesTheNativeLayout() { + val player = CoordGrid(2271, 4061) + val native = VorkathAcidLayout.select(boss, player, { true }, List::first) + val translated = VorkathAcidLayout.select( + boss.translate(4096, 2048), player.translate(4096, 2048), + { true }, List::first, + ) + assertEquals(native.mapTo(linkedSetOf()) { it.translate(4096, 2048) }, translated) + } + + @Test + fun blockedCellsAndPlayerTileCannotCreateUnwalkablePoolsOrRerollForever() { + val player = CoordGrid(2271, 4061) + val blocked = (2262..2264).flatMap { x -> + (4055..4057).map { z -> CoordGrid(x, z) } + }.toSet() + player + val pools = VorkathAcidLayout.select(boss, player, { it !in blocked }, List::first) + assertEquals(55, pools.size) + assertFalse(pools.any(blocked::contains)) + } +} diff --git a/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathDeathStorageTest.kt b/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathDeathStorageTest.kt new file mode 100644 index 000000000..41df9e332 --- /dev/null +++ b/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathDeathStorageTest.kt @@ -0,0 +1,83 @@ +@file:OptIn(dev.openrune.types.util.UncheckedType::class) + +package org.rsmod.content.bosses.vorkath + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.rsmod.api.death.PlayerDeathContext +import org.rsmod.api.death.PlayerDeathDrops +import org.rsmod.game.entity.Player +import org.rsmod.game.inv.InvObj +import org.rsmod.map.CoordGrid + +class VorkathDeathStorageTest { + @Test + fun laterVorkathDeathReplacesPreviouslyStoredItems() { + val player = Player() + player.attr[VORKATH_DEATH_STORAGE] = + VorkathStorageCodec.encode(listOf(InvObj(1, 1))) + val encounters = mock(VorkathEncounterManager::class.java) + `when`(encounters.isActive(player)).thenReturn(true) + val storage = VorkathDeathStorage(encounters) + + assertTrue(storage.store(context(player), drops(InvObj(2, 3)))) + assertEquals( + listOf(InvObj(2, 3)), + VorkathStorageCodec.decode(player.attr[VORKATH_DEATH_STORAGE]), + ) + } + + @Test + fun emptyLaterVorkathDeathClearsPreviouslyStoredItems() { + val player = Player() + player.attr[VORKATH_DEATH_STORAGE] = + VorkathStorageCodec.encode(listOf(InvObj(1, 1))) + val encounters = mock(VorkathEncounterManager::class.java) + `when`(encounters.isActive(player)).thenReturn(true) + val storage = VorkathDeathStorage(encounters) + + assertFalse(storage.store(context(player), drops())) + assertFalse(player.attr.has(VORKATH_DEATH_STORAGE)) + } + + @Test + fun unsafeDeathClearsPreviouslyStoredItems() { + val player = Player() + player.attr[VORKATH_DEATH_STORAGE] = + VorkathStorageCodec.encode(listOf(InvObj(1, 1))) + val encounters = mock(VorkathEncounterManager::class.java) + `when`(encounters.isActive(player)).thenReturn(false) + val storage = VorkathDeathStorage(encounters) + + assertFalse(storage.store(context(player), drops(InvObj(2, 3)))) + assertFalse(player.attr.has(VORKATH_DEATH_STORAGE)) + } + + private fun context(player: Player) = + PlayerDeathContext( + player = player, + coords = CoordGrid.ZERO, + inWilderness = false, + wildernessLevel = -1, + inRevenantCaves = false, + inInstance = true, + isSkulled = false, + hasProtectItem = false, + recentPvpDamage = false, + gamemode = 0, + killer = null, + ) + + private fun drops(vararg lost: InvObj) = + PlayerDeathDrops.DeathDropResult( + kept = emptyList(), + supplyPile = emptyList(), + lostTradeable = lost.toList(), + lostUntradeable = emptyList(), + coinsForKiller = 0, + ) +} diff --git a/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathDragonfireTest.kt b/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathDragonfireTest.kt new file mode 100644 index 000000000..d072c580f --- /dev/null +++ b/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathDragonfireTest.kt @@ -0,0 +1,94 @@ +package org.rsmod.content.bosses.vorkath + +import kotlin.test.Test +import kotlin.test.assertEquals + +class VorkathDragonfireTest { + @Test + fun everyProtectionCombinationRestoresTheUnreducedRollAndMatchesTheWikiCaps() { + for (shield in listOf(false, true)) { + for (protect in listOf(false, true)) { + for (antifire in listOf(false, true)) { + for (superAntifire in listOf(false, true)) { + val snapshot = + VorkathDragonfire.snapshot(shield, protect, antifire, superAntifire) + val cap = + when { + shield -> 20 + protect -> 30 + else -> 80 + } + val resistedCap = if (shield || protect) cap else 50 + val reduction = + when { + superAntifire -> 20 + antifire -> 10 + else -> 0 + } + assertEquals(cap, snapshot.maximum) + assertEquals(resistedCap, snapshot.resistedMaximum) + assertEquals(reduction, snapshot.potionReduction) + for (raw in 0..cap) { + assertEquals( + (raw - reduction).coerceAtLeast(0), + snapshot.damage(raw, VorkathStandardAttack.DRAGONFIRE), + ) + for (variant in + listOf( + VorkathStandardAttack.VENOM_DRAGONFIRE, + VorkathStandardAttack.PRAYER_DRAGONFIRE, + )) { + assertEquals( + (raw - reduction - 5).coerceAtLeast(0), + snapshot.damage(raw, variant), + ) + } + } + } + } + } + } + } + + @Test + fun shieldAndRegularAntifireProduceElevenZeroOutcomesOutOfTwentyOneRolls() { + val snapshot = VorkathDragonfire.snapshot(true, false, true, false) + val outcomes = + (0..snapshot.maximum).map { snapshot.damage(it, VorkathStandardAttack.DRAGONFIRE) } + assertEquals(11, outcomes.count { it == 0 }) + assertEquals((1..10).toList(), outcomes.filter { it > 0 }) + } + + @Test + fun shieldAndSuperAntifireAbsorbEveryBreathDamageOutcome() { + val snapshot = VorkathDragonfire.snapshot(true, true, true, true) + for (raw in 0..snapshot.maximum) { + assertEquals(0, snapshot.damage(raw, VorkathStandardAttack.DRAGONFIRE)) + assertEquals(0, snapshot.damage(raw, VorkathStandardAttack.PRAYER_DRAGONFIRE)) + assertEquals(0, snapshot.damage(raw, VorkathStandardAttack.VENOM_DRAGONFIRE)) + } + } + + @Test + fun prayerAndSuperAntifireRetainTenNormalAndFiveVariantMaximum() { + val snapshot = VorkathDragonfire.snapshot(false, true, false, true) + assertEquals(10, snapshot.damage(snapshot.maximum, VorkathStandardAttack.DRAGONFIRE)) + assertEquals(5, snapshot.damage(snapshot.maximum, VorkathStandardAttack.PRAYER_DRAGONFIRE)) + assertEquals(5, snapshot.damage(snapshot.maximum, VorkathStandardAttack.VENOM_DRAGONFIRE)) + } + + @Test + fun bareResistanceStillDealsReducedDamageInsteadOfAlwaysSplashing() { + val snapshot = VorkathDragonfire.snapshot(false, false, false, false) + assertEquals(80, snapshot.maximum) + assertEquals(50, snapshot.resistedMaximum) + assertEquals( + 50, + snapshot.damage(snapshot.resistedMaximum, VorkathStandardAttack.DRAGONFIRE), + ) + assertEquals( + 45, + snapshot.damage(snapshot.resistedMaximum, VorkathStandardAttack.PRAYER_DRAGONFIRE), + ) + } +} diff --git a/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathEncounterManagerTest.kt b/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathEncounterManagerTest.kt new file mode 100644 index 000000000..527e00baf --- /dev/null +++ b/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathEncounterManagerTest.kt @@ -0,0 +1,644 @@ +package org.rsmod.content.bosses.vorkath + +import dev.openrune.ServerCacheManager +import kotlin.test.* +import org.junit.jupiter.api.BeforeAll +import org.mockito.Answers +import org.mockito.Mockito.* +import org.rsmod.annotations.InternalApi +import org.rsmod.api.bossbar.plugin.BossHpBarScript +import org.rsmod.api.combat.formulas.AccuracyFormulae +import org.rsmod.api.config.refs.params +import org.rsmod.api.instances.* +import org.rsmod.api.instances.region.InstanceAreaResolver +import org.rsmod.api.npc.interact.AiPlayerInteractions +import org.rsmod.api.npc.owner.assignSpawnOwner +import org.rsmod.api.player.hit.modifier.StandardPlayerHitModifier +import org.rsmod.api.player.hit.queueHit +import org.rsmod.api.random.DefaultGameRandom +import org.rsmod.api.registry.npc.NpcRegistry +import org.rsmod.api.repo.loc.LocRepository +import org.rsmod.api.repo.npc.NpcRepository +import org.rsmod.api.repo.world.WorldRepository +import org.rsmod.api.route.StepFactory +import org.rsmod.events.EventBus +import org.rsmod.game.MapClock +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.NpcList +import org.rsmod.game.entity.Player +import org.rsmod.game.entity.player.PlayerUid +import org.rsmod.game.hit.Hit +import org.rsmod.game.hit.HitBuilder +import org.rsmod.game.hit.HitType +import org.rsmod.game.loc.LocEntity +import org.rsmod.game.loc.LocInfo +import org.rsmod.game.proj.ProjAnim +import org.rsmod.game.queue.PlayerQueueList +import org.rsmod.map.CoordGrid +import org.rsmod.routefinder.collision.CollisionFlagMap +import org.rsmod.routefinder.flag.CollisionFlag + +/** + * Real revision-240 entities, hit queues, and NPC registry; scene output and sessions are recorded. + */ +class VorkathEncounterManagerTest { + @Test + fun nativeSpawnRequiresCrumbleIdentityException() { + val spawn = assertNotNull(ServerCacheManager.getNpc(8063)) + val undead = spawn.param(params.undead) + println("VORKATH_CRUMBLE_CONTRACT id=${spawn.id} internalName=${spawn.internalName} undead=$undead") + assertTrue(spawn.isType("npc.vorkath_spawn")) + assertEquals("npc.vorkath_spawn", spawn.internalName) + assertEquals(0, undead, "The native spawn needs its explicit Crumble Undead exception") + } + + @Test + fun spawnRejectsStuckLandingTilesAndClipsAlongTheCapturedBossEdge() { + val f = VorkathFixture() + f.player.coords = CoordGrid(2272, 4061) + f.launchSpawn() + f.run.pendingSpawnTile = CoordGrid(2271, 4069) + f.advance(4) + val spawn = assertNotNull(f.run.spawn) + f.collision.add(2272, 4061, 0, CollisionFlag.BLOCK_NPCS) + assertFalse(f.manager.spawnCanReach(f.run, spawn.coords)) + f.npcs.del(spawn, Int.MAX_VALUE) + f.player.coords = CoordGrid(2264, 4068) + var tile = CoordGrid(2272, 4069) + for (x in 2271 downTo 2268) { + tile = f.manager.nextSpawnStep(f.run, tile) + assertEquals(CoordGrid(x, 4069), tile, "rsprox-3279 ticks13746..13749") + } + assertTrue(f.manager.spawnCanReach(f.run, CoordGrid(2272, 4069))) + } + + @Test + fun spawnFinalStepOverlapsFrozenPlayerButStillRespectsMapObjects() { + val f = VorkathFixture() + f.launchSpawn() + f.advance(4) + val spawn = assertNotNull(f.run.spawn) + val target = f.player.coords + spawn.coords = target.translate(1, 0) + f.collision.add(target.x, target.z, target.level, CollisionFlag.BLOCK_NPCS) + f.advance() + val steps = StepFactory(f.collision) + val strategy = assertNotNull(spawn.collisionStrategy) + assertEquals(target, steps.validated(spawn, spawn.coords, target, strategy)) + f.collision.add(target.x, target.z, target.level, CollisionFlag.LOC) + assertEquals(CoordGrid.NULL, f.manager.nextSpawnStep(f.run, spawn.coords)) + assertNotEquals(target, steps.validated(spawn, spawn.coords, target, strategy)) + } + + @Test + fun wakeUsesMappedArenaBoundsInsteadOfTheAllocationId() { + val f = VorkathFixture() + assertEquals(setOf(0), f.session.regionIds) + assertTrue(f.manager.forceReset(f.player)) + assertTrue(f.manager.poke(f.player, f.run.boss)) + assertEquals(VorkathState.AWAKENING, f.run.state) + } + + @Test + fun wakeRejectsCoordinatesOutsideMappedArenaAndWrongPlane() { + val f = VorkathFixture() + assertTrue(f.manager.forceReset(f.player)) + for (tile in listOf(CoordGrid(2239, 4057), CoordGrid(2304, 4057), + CoordGrid(2272, 4031), CoordGrid(2272, 4096), CoordGrid(2272, 4057, 1))) { + f.player.coords = tile + assertFalse(f.manager.poke(f.player, f.run.boss), "$tile must not wake this instance") + assertEquals(VorkathState.SLEEPING, f.run.state) + } + } + + @Test + fun wakeRejectsAnotherSessionEvenWithMatchingMappedCoordinates() { + val f = VorkathFixture() + assertTrue(f.manager.forceReset(f.player)) + val other = InstanceSession(InstanceId(2), mutableSetOf(0), 2L, + f.session.key, f.session.spec, f.session.placement, InstanceAccess.Private) + `when`(f.instances.sessionForPlayer(f.player)).thenReturn(other) + assertFalse(f.manager.poke(f.player, f.run.boss)) + assertEquals(VorkathState.SLEEPING, f.run.state) + } + + @Test + fun acidPoolArrivalAndBarrageUseTheSameEncounterClock() { + val f = VorkathFixture() + assertTrue(f.manager.forceSpecial(f.player, VorkathSpecial.ACID)) + val cast = f.clock.cycle + val poolCount = f.run.pendingAcidPools.size + assertTrue(poolCount in 56..57) + assertTrue(f.run.pendingAcidPools.all { it.impactCycle == cast + 3 }) + assertTrue(f.player.coords in f.run.pendingAcidPools.map { it.tile }) + assertTrue( + f.run.acidSafeLane.intersect(f.run.pendingAcidPools.map { it.tile }.toSet()).isEmpty() + ) + f.player.coords = f.run.acidSafeLane.first() + f.advance(2) + assertTrue(f.run.acidTiles.isEmpty()) + assertEquals(0, f.run.shotsFired) + f.advance() + assertEquals(poolCount, f.run.acidTiles.size) + assertEquals(poolCount, f.run.acidVisuals.size) + assertEquals(0, f.run.shotsFired) + f.advance() + assertEquals(1, f.run.shotsFired) + f.advance(24) + assertEquals(25, f.run.shotsFired) + assertEquals(25, f.projectiles().count { it.spotanim == 1482 }) + assertTrue(f.run.acidTiles.isEmpty()) + assertTrue(f.run.acidVisuals.isEmpty()) + f.advance(5) + assertEquals(VorkathState.ACTIVE, f.run.state) + assertEquals(25, f.projectiles().count { it.spotanim == 1482 }) + assertEquals(cast + 33, f.run.timeline.nextAttackCycle) + } + + @Test + fun spawnArrivesFourTicksAfterLaunchThenReleasesFreezeOnNativeDeathResolution() { + val f = VorkathFixture() + val launch = f.launchSpawn() + assertTrue(f.player.frozen) + assertEquals(VorkathState.ZOMBIFIED_SPAWN_SPECIAL, f.run.state) + assertNotNull(f.manager.attackDenial(f.player, f.run.boss)) + val tile = assertNotNull(f.run.pendingSpawnTile) + assertEquals(8, tile.chebyshevDistance(f.player.coords)) + f.advance(3) + assertNull(f.run.spawn) + f.advance() + val spawn = assertNotNull(f.run.spawn) + assertEquals(launch + 4, f.clock.cycle) + assertEquals(38, spawn.hitpoints) + assertEquals(tile, spawn.coords) + spawn.hitpoints = 0 + f.manager.beginSpawnDeath(spawn) + f.advance(2) + assertTrue(f.player.frozen) + f.advance() + assertFalse(f.player.frozen) + assertEquals(VorkathState.ACTIVE, f.run.state) + assertNull(f.manager.attackDenial(f.player, f.run.boss)) + assertNull(f.run.spawn) + assertTrue(spawn in f.run.retiringSpawns) + assertTrue(f.playerHits().isEmpty()) + f.advance(2) + assertFalse(spawn.isSlotAssigned) + assertTrue(f.run.retiringSpawns.isEmpty()) + } + + @Test + fun fullHealthSpawnExplosionDealsSixtyAndCombatResumesOnItsDamageTick() { + val f = VorkathFixture() + f.launchSpawn() + f.advance(4) + val spawn = assertNotNull(f.run.spawn) + spawn.coords = f.player.coords + f.advance() + assertEquals(f.clock.cycle + 1, f.run.spawnExplosionCycle) + assertFalse(f.player.frozen) + f.advance() + val hit = f.playerHits().single { it.damage == 60 } + assertEquals(60, hit.damage) + assertEquals(1, f.playerQueues().single { it.args === hit }.remainingCycles) + assertEquals(VorkathState.ACTIVE, f.run.state) + assertTrue(f.run.timeline.attackDue(f.clock.cycle)) + assertNull(f.run.spawn) + assertFalse(spawn.isSlotAssigned) + } + + @Test + fun walkingOntoThePlayerStartsContactDuringMovementWithoutAnExtraManagerTick() { + val f = VorkathFixture() + f.launchSpawn() + f.advance(5) + val spawn = assertNotNull(f.run.spawn) + assertTrue(f.player.frozen) + val contactCycle = f.clock.cycle + // The engine advances MapClock before the NPC post-tick arrival callback. + f.clock.tick() + f.completeMovement(spawn, f.player.coords) + assertFalse(f.player.frozen) + assertEquals(contactCycle + 1, f.run.spawnExplosionCycle) + assertTrue(f.playerHits().isEmpty()) + f.manager.tick(f.player) + assertEquals(60, f.playerHits().single().damage) + assertEquals(VorkathState.ACTIVE, f.run.state) + assertFalse(spawn.isSlotAssigned) + } + + @Test + fun aSpawnArrivalCallbackFromBeforeResetCannotStartAnExplosion() { + val f = VorkathFixture() + f.launchSpawn() + f.advance(5) + val spawn = assertNotNull(f.run.spawn) + assertTrue(f.manager.forceReset(f.player)) + f.completeMovement(spawn, f.player.coords) + assertFalse(f.player.frozen) + assertEquals(Int.MAX_VALUE, f.run.spawnExplosionCycle) + assertEquals(VorkathState.SLEEPING, f.run.state) + assertTrue(f.playerHits().isEmpty()) + } + + @Test + fun lethalSpawnHitAfterContactCancelsThePendingExplosion() { + val f = VorkathFixture() + f.launchSpawn() + f.advance(4) + val spawn = assertNotNull(f.run.spawn) + spawn.coords = f.player.coords + f.advance() + spawn.hitpoints = 0 + f.manager.beginSpawnDeath(spawn) + f.advance(3) + assertTrue(f.playerHits().isEmpty()) + assertFalse(f.player.frozen) + assertEquals(VorkathState.ACTIVE, f.run.state) + assertNull(f.run.spawn) + } + + @Test + fun incomingDamageReductionAndImmunityFollowOnlyTheCurrentPhase() { + val f = VorkathFixture() + for (state in VorkathState.entries) { + f.run.state = state + val hit = f.incomingHit(41) + f.manager.modifyNpcHit(f.player, f.run.boss, hit) + val expected = + when (state) { + VorkathState.ACTIVE -> 41 + VorkathState.ACID_SPECIAL -> 20 + else -> 0 + } + assertEquals(expected, hit.damage, state.name) + } + } + + @Test + fun fireballSnapshotsAtProjectileLaunchAndResetCancelsItsImpact() { + val f = VorkathFixture() + assertTrue(f.manager.forceAttack(f.player, VorkathStandardAttack.FIREBALL)) + assertTrue(f.projectiles().isEmpty()) + val launchTile = f.player.coords.translate(1, 0) + f.player.coords = launchTile + f.advance() + val projectile = f.projectiles().single { it.spotanim == 1481 } + assertEquals(launchTile, projectile.endCoord) + assertEquals(0, projectile.targetIndex) + assertEquals(1, f.run.pendingHits.size) + assertEquals(launchTile, f.run.pendingHits.single().tile) + assertTrue(f.manager.forceReset(f.player)) + assertTrue(f.run.pendingFireballs.isEmpty()) + assertTrue(f.run.pendingHits.isEmpty()) + assertEquals(VorkathState.SLEEPING, f.run.state) + f.advance(8) + assertTrue(f.playerHits().isEmpty()) + assertEquals(1, f.projectiles().count { it.spotanim == 1481 }) + } + + @Test + fun fireballImpactQueuesForThisPlayerCycleAndResetClearsTheQueuedHit() { + val f = VorkathFixture() + assertTrue(f.manager.forceAttack(f.player, VorkathStandardAttack.FIREBALL)) + f.advance() + val impactCycle = f.run.pendingHits.single().impactCycle + f.advance(impactCycle - f.clock.cycle - 1) + assertTrue(f.playerHits().isEmpty()) + f.advance() + val hit = f.playerHits().single() + assertEquals(1, f.playerQueues().single { it.args === hit }.remainingCycles) + assertTrue(f.run.pendingHits.isEmpty()) + assertTrue(f.manager.forceReset(f.player)) + assertTrue(f.playerHits().isEmpty()) + } + + @Test + fun abortRemovesEncounterEffectsAndClearsAlreadyQueuedHits() { + val f = VorkathFixture() + f.seedMechanics() + val spawn = assertNotNull(f.run.spawn) + val boss = f.run.boss + f.manager.abort(f.player, "test leave", teleport = false) + assertNull(f.manager.auditRun(f.player)) + f.assertClean() + assertFalse(spawn.isSlotAssigned) + assertFalse(boss.isSlotAssigned) + verify(f.bars).onClose(f.player, boss, instant = true) + verify(f.instances).leave(f.player, f.session, f.clock.cycle) + verify(f.locs).del(f.poolVisual, Int.MAX_VALUE) + } + + @Test + fun leavingTheRegionAndLosingTheSessionRunTheSameCleanup() { + for (loseSession in listOf(false, true)) { + val f = VorkathFixture() + f.seedMechanics() + if (loseSession) { + `when`(f.instances.sessionForPlayer(f.player)).thenReturn(null) + } else { + f.player.coords = CoordGrid(3200, 3200) + } + f.manager.tick(f.player) + assertNull(f.manager.auditRun(f.player)) + f.assertClean() + } + } + + @Test + fun logoutAndDisconnectClearEffectsAndRouteToTheCorrectInstanceCleanup() { + for (loggingOut in listOf(false, true)) { + val f = VorkathFixture() + f.seedMechanics() + if (loggingOut) f.player.loggingOut = true else f.player.slotId = -1 + f.manager.tick(f.player) + assertNull(f.manager.auditRun(f.player)) + f.assertClean() + if (loggingOut) verify(f.instances).handleLogout(f.player, f.clock.cycle) + else verify(f.instances).leave(f.player, f.session, f.clock.cycle) + } + } + + @Test + fun playerDeathClearsHazardsButRetainsMembershipForTheExistingDeathStorageHook() { + val f = VorkathFixture() + f.seedMechanics() + f.player.statMap.setCurrentLevel("stat.hitpoints", 0) + f.manager.tick(f.player) + assertSame(f.run, f.manager.auditRun(f.player)) + assertEquals(VorkathState.ENDED, f.run.state) + f.assertClean() + verify(f.bars).onClose(f.player, f.run.boss, instant = true) + f.manager.abort(f.player, "death hook", teleport = false) + assertNull(f.manager.auditRun(f.player)) + } + + @Test + fun bossDeathClearsSpecialEffectsAndRejectsDuplicateDeathAndStaleDamage() { + val f = VorkathFixture() + f.seedMechanics() + f.run.rewardEligible = false + assertSame(f.run, f.manager.beginBossDeath(f.run.boss)) + assertEquals(VorkathState.DYING, f.run.state) + f.assertClean() + assertNull(f.manager.beginBossDeath(f.run.boss)) + assertTrue(f.manager.canFinishDeath(f.run, f.run.boss)) + f.manager.abort(f.player, "leave during death", teleport = false) + assertFalse(f.manager.canFinishDeath(f.run, f.run.boss)) + } + + @Test + fun lethalBossDamageCancelsDueEffectsBeforeTheLaterDeathQueueRuns() { + val f = VorkathFixture() + f.seedMechanics() + f.run.rewardEligible = false + f.run.boss.hitpoints = 0 + f.advance() + assertSame(f.run, f.manager.auditRun(f.player)) + f.assertClean() + assertTrue(f.projectiles().isEmpty()) + assertTrue(f.playerHits().isEmpty()) + assertSame(f.run, f.manager.beginBossDeath(f.run.boss)) + assertEquals(VorkathState.DYING, f.run.state) + } + + @Test + fun removedBossAbortsInsteadOfLaunchingPendingEffects() { + val f = VorkathFixture() + f.seedMechanics() + f.npcs.del(f.run.boss, Int.MAX_VALUE) + f.advance() + assertNull(f.manager.auditRun(f.player)) + f.assertClean() + assertTrue(f.projectiles().isEmpty()) + assertTrue(f.playerHits().isEmpty()) + } + + @Test + fun resetAndReawakeningStartAFreshBossWithoutOldSpecialState() { + val f = VorkathFixture() + f.seedMechanics() + assertTrue(f.manager.forceReset(f.player)) + f.assertClean() + assertEquals(8059, f.run.boss.id) + assertEquals(VorkathState.SLEEPING, f.run.state) + assertEquals(0, f.run.standardAttacks) + assertTrue(f.manager.forceWake(f.player)) + f.advance(7) + assertEquals(8061, f.run.boss.id) + assertEquals(750, f.run.boss.hitpoints) + assertEquals(VorkathState.ACTIVE, f.run.state) + assertEquals(0, f.run.standardAttacks) + assertEquals(0, f.run.shotsFired) + assertTrue(f.run.pendingHits.isEmpty()) + assertTrue(f.run.pendingStandardEffects.isEmpty()) + } + + companion object { + @JvmStatic + @BeforeAll + fun cache() { + if (ServerCacheManager.objectSize() == 0) ServerCacheManager.init(240).close() + } + } +} + +@OptIn(InternalApi::class) +private class VorkathFixture { + val clock = MapClock(100) + val collision = CollisionFlagMap() + private val npcList = NpcList() + val npcs = NpcRepository(clock, NpcRegistry(npcList, collision, EventBus()), npcList) + val instances = mock(InstanceManager::class.java) + val bars = mock(BossHpBarScript::class.java) + val world = mock(WorldRepository::class.java) + val playerHitModifier = StandardPlayerHitModifier(EventBus()) + val locs = + mock(LocRepository::class.java) { call -> + if (call.method.returnType == LocInfo::class.java) { + LocInfo(2, CoordGrid(call.getArgument(0)), LocEntity(32000, 10, 0)) + } else Answers.RETURNS_DEFAULTS.answer(call) + } + val player = + Player().apply { + uuid = 1L + observerUUID = 1L + slotId = 1 + assignUid() + coords = CoordGrid(2272, 4057) + for (name in + listOf("hitpoints", "prayer", "attack", "strength", "defence", "ranged", "magic")) { + statMap.setBaseLevel("stat.$name", 99) + statMap.setCurrentLevel("stat.$name", 99) + } + inv = invMap.getOrPut("inv.inv") + worn = invMap.getOrPut("inv.worn") + } + private val placement = + (InstanceAreaResolver().resolve(VORKATH_AREA) as InstanceAreaResolver.Result.Ready) + .placement + val session = + InstanceSession( + InstanceId(1), + mutableSetOf(0), // RegionRegistry allocation ID, not the source map-square ID. + 1L, + VORKATH_INSTANCE_KEY, + InstanceSpec(0, 1, 100, 100, area = VORKATH_AREA, settingsRowId = -1), + placement, + InstanceAccess.Private, + ) + private val boss = Npc(requireNotNull(ServerCacheManager.getNpc(8061)), CoordGrid(2269, 4062)) + val run = VorkathEncounterManager.Run(player, session, 1L, boss) + val manager = + VorkathEncounterManager( + clock, + instances, + npcs, + locs, + mock(AiPlayerInteractions::class.java), + collision, + DefaultGameRandom(42L), + mock(AccuracyFormulae::class.java), + world, + playerHitModifier, + bars, + ) + val poolVisual = LocInfo(2, player.coords, LocEntity(32000, 10, 0)) + + init { + for (x in 2240..2303 step 8) for (z in 4032..4095 step 8) { + collision.allocateIfAbsent(x, z, 0) + } + npcs.add(boss, Int.MAX_VALUE) + boss.assignSpawnOwner(player, clock.cycle) + boss.hitpoints = 750 + run.timeline.wake(92) + run.timeline.activate(99) + `when`(instances.sessionForPlayer(player)).thenReturn(session) + `when`(instances.leave(player, session, clock.cycle)).thenReturn(VORKATH_OUTSIDE) + `when`(instances.localCoord(session, RegionLocal(0, 35, 63, 0, 0))) + .thenReturn(CoordGrid(2240, 4032)) + for (x in 19..43) for (z in 20..44) { + `when`(instances.localCoord(session, RegionLocal(0, 35, 63, x, z))) + .thenReturn(CoordGrid(2240 + x, 4032 + z)) + } + val field = VorkathEncounterManager::class.java.getDeclaredField("runs") + field.isAccessible = true + @Suppress("UNCHECKED_CAST") + val runs = field.get(manager) as MutableMap + runs[player.uid] = run + } + + fun advance(ticks: Int = 1) = + repeat(ticks) { + clock.tick() + player.currentMapClock = clock.cycle + manager.tick(player) + } + + fun completeMovement(npc: Npc, destination: CoordGrid) { + npc.coords = destination + npc.routeDestination.clear() + npc.processArrivalAction() + } + fun launchSpawn(): Int { + assertTrue(manager.forceSpecial(player, VorkathSpecial.ZOMBIFIED_SPAWN)) + val launch = run.spawnLaunchCycle + advance(launch - clock.cycle) + assertEquals(launch + 4, run.spawnArrivalCycle) + return launch + } + + fun projectiles(): List = + mockingDetails(world) + .invocations + .filter { it.method.name == "projAnim" } + .map { it.arguments[0] as ProjAnim } + + fun playerHits(): List = playerQueues().mapNotNull { it.args as? Hit } + + fun playerQueues(): List { + val queues = mutableListOf() + val iterator = player.queueList.iterator() + if (iterator != null) + while (iterator.hasNext()) { + queues += iterator.next() + } + return queues + } + + fun incomingHit(damage: Int) = + HitBuilder( + type = HitType.Typeless, + damage = damage, + sourceUid = player.uid.packed, + sourceSlot = player.slotId, + isFromNpc = false, + isFromPlayer = true, + clientDelay = 0, + righthandType = null, + secondaryType = null, + targetHitmark = 0, + sourceHitmark = 0, + publicHitmark = null, + zeroDamageHitmarkLit = null, + zeroDamageHitmarkTint = null, + maxDamageHitmarkLit = null, + targetMaxDamageThreshold = Int.MAX_VALUE, + sourceMaxDamageThreshold = Int.MAX_VALUE, + ) + + fun seedMechanics() { + val hit = incomingHit(40) + manager.modifyNpcHit(player, boss, hit) + assertEquals(40, hit.damage) + run.state = VorkathState.ZOMBIFIED_SPAWN_SPECIAL + run.acidTiles += player.coords + run.acidVisuals += poolVisual + run.acidSafeLane += player.coords.translate(1, 0) + run.pendingAcidPools += PendingAcidPool(clock.cycle + 3, player.coords) + run.pendingHits += + PendingTileHit(clock.cycle + 5, player.coords, 0, 121, VorkathTileAttack.FIREBALL) + run.pendingFireballs += PendingFireball(clock.cycle + 1) + run.pendingStandardEffects += + PendingStandardEffect(clock.cycle + 1, VorkathStandardAttack.PRAYER_DRAGONFIRE) + run.pendingHeals += (clock.cycle + 1) to 10 + run.pendingSpawnTile = player.coords.translate(8, 0) + val spawn = + Npc(requireNotNull(ServerCacheManager.getNpc(8063)), player.coords.translate(2, 0)) + npcs.add(spawn, Int.MAX_VALUE) + spawn.assignSpawnOwner(player, clock.cycle) + run.spawn = spawn + player.queueHit( + source = boss, + delay = 10, + type = HitType.Typeless, + damage = 1, + modifier = playerHitModifier, + ) + player.frozen = true + run.ownsFreeze = true + } + + fun assertClean() { + assertTrue(run.pendingHits.isEmpty()) + assertTrue(run.pendingFireballs.isEmpty()) + assertTrue(run.pendingStandardEffects.isEmpty()) + assertTrue(run.pendingHeals.isEmpty()) + assertTrue(run.pendingAcidPools.isEmpty()) + assertTrue(run.acidTiles.isEmpty()) + assertTrue(run.acidVisuals.isEmpty()) + assertTrue(run.acidSafeLane.isEmpty()) + assertTrue(run.retiringSpawns.isEmpty()) + assertNull(run.spawn) + assertNull(run.pendingSpawnTile) + assertFalse(run.ownsFreeze) + assertFalse(player.frozen) + assertTrue(playerHits().isEmpty()) + assertEquals(Int.MAX_VALUE, run.spawnArrivalCycle) + assertEquals(Int.MAX_VALUE, run.spawnLaunchCycle) + assertEquals(Int.MAX_VALUE, run.spawnExplosionCycle) + assertEquals(Int.MAX_VALUE, run.spawnDeathCycle) + assertEquals(Int.MAX_VALUE, run.freezeCycle) + } +} diff --git a/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathProjectilesTest.kt b/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathProjectilesTest.kt new file mode 100644 index 000000000..1f94bd8f8 --- /dev/null +++ b/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathProjectilesTest.kt @@ -0,0 +1,117 @@ +package org.rsmod.content.bosses.vorkath + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.rsmod.map.CoordGrid + +class VorkathProjectilesTest { + private val boss = CoordGrid(2269, 4062) + private val southMouth = CoordGrid(2272, 4063) + + @Test + fun everyAttackSelectsItsNativeTravelAndImpact() { + val expected = mapOf( + VorkathStandardAttack.MELEE to (null to null), + VorkathStandardAttack.RANGED to (1477 to 1478), + VorkathStandardAttack.MAGIC to (1479 to 1480), + VorkathStandardAttack.DRAGONFIRE to (393 to 1466), + VorkathStandardAttack.VENOM_DRAGONFIRE to (1470 to 1472), + VorkathStandardAttack.PRAYER_DRAGONFIRE to (1471 to 1473), + VorkathStandardAttack.FIREBALL to (1481 to null), + ) + assertEquals(VorkathStandardAttack.entries.toSet(), expected.keys) + for ((attack, ids) in expected) { + assertEquals(ids.first, VorkathProjectiles.standard(attack)?.spot, attack.name) + assertEquals(ids.second, VorkathProjectiles.impact(attack), attack.name) + } + assertNull(VorkathProjectiles.standard(VorkathStandardAttack.MELEE)) + } + + @Test + fun suppliedNormalWitnessesReplayTheirRawGeometryAndImpactTicks() { + // rsprox-3279:117607: ranged at t5093, damage t5096. + val ranged = VorkathProjectiles.RANGED.fromMouth(southMouth, CoordGrid(2271, 4058), 1908) + assertEquals(listOf(1477, 30, 95, 14, 128, 142, 124), + listOf(ranged.spotanim, ranged.startTime, ranged.endTime, ranged.angle, + ranged.progress, ranged.startHeight, ranged.endHeight)) + assertEquals(3, ranged.endTime / 30) + assertEquals(-1909, ranged.targetIndex) + // rsprox-3279:118410: magic at t5139, E80. + val magic = VorkathProjectiles.MAGIC.fromMouth(southMouth, CoordGrid(2271, 4061), 1908) + assertEquals(80, magic.endTime) + assertEquals(2, magic.endTime / 30) + } + + @Test + fun standardBreathsAndIceUseDistanceInsteadOfAFixedStyleDelay() { + val specs = listOf(VorkathProjectiles.RANGED, VorkathProjectiles.MAGIC, + VorkathProjectiles.DRAGONFIRE, VorkathProjectiles.VENOM, + VorkathProjectiles.PRAYER, VorkathProjectiles.ICE) + // Observed union across all 2,547 supplied normal/ice packets. + val fixtures = listOf(2 to 80, 3 to 85, 4 to 90, 5 to 95, + 6 to 100, 7 to 105, 8 to 110, 9 to 115) + for (spec in specs) { + for ((distance, end) in fixtures) { + val shot = spec.fromMouth(southMouth, southMouth.translate(0, -distance), 0) + assertEquals(end, shot.endTime, "spot=${spec.spot}, distance=$distance") + assertEquals(30, shot.startTime) + assertEquals(14, shot.angle) + assertEquals(128, shot.progress) + assertEquals(142, shot.startHeight) + assertEquals(124, shot.endHeight) + } + } + } + + @Test + fun highArcsAndRapidShotsKeepDistinctGeometryWithoutHoming() { + val fixtures = listOf( + VorkathProjectiles.FIREBALL to listOf(1481, 0, 120, 46, 128, 340, 38), + VorkathProjectiles.ACID to listOf(1483, 32, 90, 46, 128, 340, 0), + VorkathProjectiles.RAPID_FIRE to listOf(1482, 0, 30, 22, 128, 138, 30), + VorkathProjectiles.SPAWN to listOf(1484, 32, 120, 46, 128, 340, 0), + ) + val landing = CoordGrid(2279, 4054) + for ((spec, expected) in fixtures) { + for (tile in listOf(landing, landing.translate(10, 10))) { + val shot = spec.fromMouth(southMouth, tile, targetSlot = 1908) + assertEquals(expected, listOf(shot.spotanim, shot.startTime, shot.endTime, + shot.angle, shot.progress, shot.startHeight, shot.endHeight)) + assertEquals(0, shot.targetIndex) + assertEquals(0, shot.sourceIndex) + assertEquals(tile, shot.endCoord) + assertEquals(southMouth, shot.startCoord) + } + } + } + + @Test + fun nativeMouthCardinalWitnessesRotateAroundTheBossCentre() { + assertEquals(CoordGrid(2272, 4063), + VorkathProjectiles.mouth(boss, 7, CoordGrid(2271, 4061))) + assertEquals(CoordGrid(2272, 4067), + VorkathProjectiles.mouth(boss, 7, CoordGrid(2273, 4069))) + assertEquals(CoordGrid(2274, 4065), + VorkathProjectiles.mouth(boss, 7, CoordGrid(2278, 4064))) + assertEquals(CoordGrid(2271, 4063), + VorkathProjectiles.mouth(boss, 7, CoordGrid(2269, 4061))) + } + + @Test + fun acidAndSpawnFaceThePlayerInsteadOfTheirIndividualLandingTiles() { + val player = CoordGrid(2271, 4061) + val west = CoordGrid(2263, 4071) + val east = CoordGrid(2282, 4069) + for (spec in listOf(VorkathProjectiles.ACID, VorkathProjectiles.SPAWN)) { + val first = spec.build(boss, 7, west, facingTile = player) + val second = spec.build(boss, 7, east, facingTile = player) + assertEquals(southMouth, first.startCoord) + assertEquals(first.startCoord, second.startCoord) + assertEquals(west, first.endCoord) + assertEquals(east, second.endCoord) + assertTrue(first.startCoord != boss) + } + } +} diff --git a/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathRulesTest.kt b/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathRulesTest.kt new file mode 100644 index 000000000..1c9f8d776 --- /dev/null +++ b/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathRulesTest.kt @@ -0,0 +1,181 @@ +package org.rsmod.content.bosses.vorkath + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class VorkathRulesTest { + @Test + fun craterEntranceWallAcceptsItsFullApproachArea() { + assertTrue(VorkathRules.isCraterWall(25, 19)) + assertTrue(VorkathRules.isCraterWall(32, 20)) + assertTrue(VorkathRules.isCraterWall(38, 22)) + } + + @Test + fun craterEntranceWallRejectsOtherUngaelIceChunks() { + assertFalse(VorkathRules.isCraterWall(24, 20)) + assertFalse(VorkathRules.isCraterWall(39, 20)) + assertFalse(VorkathRules.isCraterWall(29, 18)) + assertFalse(VorkathRules.isCraterWall(29, 23)) + assertFalse(VorkathRules.isCraterWall(44, 26)) + } + + @Test + fun screenshotTileIsRecognizedAsPublicCraterApproach() { + assertTrue(VorkathRules.isPublicCraterApproach(worldX = 2272, worldZ = 4052, level = 0)) + } + + @Test + fun matchingLocalTileInAnotherRegionIsRejected() { + assertFalse( + VorkathRules.isPublicCraterApproach( + worldX = (34 * 64) + 32, + worldZ = (63 * 64) + 20, + level = 0, + ) + ) + } + + @Test + fun craterApproachOnAnotherPlaneIsRejected() { + assertFalse(VorkathRules.isPublicCraterApproach(worldX = 2272, worldZ = 4052, level = 1)) + } + + @Test + fun firstSpecialCanBeAcid() = assertEquals(VorkathSpecial.ACID, VorkathRules.firstSpecial(0)) + + @Test + fun firstSpecialCanBeSpawn() = + assertEquals(VorkathSpecial.ZOMBIFIED_SPAWN, VorkathRules.firstSpecial(1)) + + @Test + fun acidAlternatesToSpawn() = + assertEquals(VorkathSpecial.ZOMBIFIED_SPAWN, VorkathRules.nextSpecial(VorkathSpecial.ACID)) + + @Test + fun spawnAlternatesToAcid() = + assertEquals(VorkathSpecial.ACID, VorkathRules.nextSpecial(VorkathSpecial.ZOMBIFIED_SPAWN)) + + @Test fun fireballDirectMaximum() = assertEquals(121, VorkathRules.fireballMaximum(0)) + + @Test fun fireballAdjacentMaximum() = assertEquals(60, VorkathRules.fireballMaximum(1)) + + @Test fun fireballTwoTilesAvoids() = assertEquals(0, VorkathRules.fireballMaximum(2)) + + @Test fun fireballFarAvoids() = assertEquals(0, VorkathRules.fireballMaximum(20)) + + @Test + fun standardDragonfireKeepsItsMaximum() = + assertEquals(80, VorkathRules.dragonfireMaximum(80, VorkathStandardAttack.DRAGONFIRE)) + + @Test + fun venomDragonfireHasTheFivePointLowerMaximum() = + assertEquals(75, VorkathRules.dragonfireMaximum(80, VorkathStandardAttack.VENOM_DRAGONFIRE)) + + @Test + fun prayerDragonfireHasTheFivePointLowerMaximum() = + assertEquals( + 45, + VorkathRules.dragonfireMaximum(50, VorkathStandardAttack.PRAYER_DRAGONFIRE), + ) + + @Test fun spawnFullHealthMaximum() = assertEquals(60, VorkathRules.zombifiedSpawnMaximum(38)) + + @Test fun spawnHalfHealthScales() = assertEquals(30, VorkathRules.zombifiedSpawnMaximum(19)) + + @Test fun spawnOneHealthScales() = assertEquals(1, VorkathRules.zombifiedSpawnMaximum(1)) + + @Test + fun spawnZeroHealthCannotExplode() = assertEquals(0, VorkathRules.zombifiedSpawnMaximum(0)) + + @Test fun spawnHealthIsCapped() = assertEquals(60, VorkathRules.zombifiedSpawnMaximum(100)) + + @Test fun acidHalvesEvenDamage() = assertEquals(20, VorkathRules.acidDamage(40)) + + @Test fun acidRoundsOddDamageDown() = assertEquals(20, VorkathRules.acidDamage(41)) + + @Test fun acidZeroStaysZero() = assertEquals(0, VorkathRules.acidDamage(0)) + + @Test fun acidNegativeCannotHeal() = assertEquals(0, VorkathRules.acidDamage(-5)) + + @Test + fun meleeIsOnlyAdjacent() = + assertTrue(VorkathStandardAttack.MELEE in VorkathRules.standardWeights(true)) + + @Test + fun meleeIsNeverRanged() = + assertFalse(VorkathStandardAttack.MELEE in VorkathRules.standardWeights(false)) + + @Test + fun adjacentMagicUsesSupportedRelativeWeight() = + assertEquals(3, VorkathRules.standardWeights(true)[VorkathStandardAttack.MAGIC]) + + @Test + fun rangedIsFavouredAtRange() = + assertEquals(4, VorkathRules.standardWeights(false)[VorkathStandardAttack.RANGED]) + + @Test + fun allFourBreathsPresentAdjacent() = + assertEquals( + 4, + VorkathRules.standardWeights(true).keys.count { + it.name.contains("DRAGONFIRE") || it == VorkathStandardAttack.FIREBALL + }, + ) + + @Test fun rangedPoolHasSixAttacks() = assertEquals(6, VorkathRules.standardWeights(false).size) + + @Test fun fortyNineIsNotGuaranteedHead() = assertFalse(VorkathRules.isGuaranteedHeadKill(49)) + + @Test fun fiftiethIsGuaranteedHead() = assertTrue(VorkathRules.isGuaranteedHeadKill(50)) + + @Test fun hundredthIsNotGuaranteedHead() = assertFalse(VorkathRules.isGuaranteedHeadKill(100)) + + @Test fun zeroIsNotGuaranteedHead() = assertFalse(VorkathRules.isGuaranteedHeadKill(0)) + + @Test fun feeAtThresholdIsAffordable() = assertTrue(VorkathRules.storageFeeAffordable(100_000)) + + @Test + fun feeBelowThresholdIsNotAffordable() = assertFalse(VorkathRules.storageFeeAffordable(99_999)) + + @Test fun zeroTicksFormats() = assertEquals("0.0 seconds", VorkathRules.formatTicks(0)) + + @Test + fun tenTicksFormatsSixSeconds() = assertEquals("6.0 seconds", VorkathRules.formatTicks(10)) + + @Test + fun oneHundredTicksFormatsOneMinute() = assertEquals("1:00.0", VorkathRules.formatTicks(100)) + + @Test + fun magicRangedRatioHoldsInBothPoolsWithoutForcingMelee() { + for (adjacent in listOf(false, true)) { + val weights = VorkathRules.standardWeights(adjacent) + assertEquals( + 3 * weights.getValue(VorkathStandardAttack.RANGED), + 4 * weights.getValue(VorkathStandardAttack.MAGIC), + ) + assertTrue(weights.values.all { it > 0 }) + assertTrue(weights.keys.any { it != VorkathStandardAttack.MELEE }) + } + } + + @Test + fun fireballHalvesTheSampledHitAndDoesNotReroll() { + for (damage in 0..121) { + assertEquals(damage, VorkathRules.fireballDamage(damage, 0)) + assertEquals(damage / 2, VorkathRules.fireballDamage(damage, 1)) + assertEquals(0, VorkathRules.fireballDamage(damage, 2)) + } + assertEquals(0, VorkathRules.fireballDamage(-10, 0)) + } + + @Test + fun spawnExplosionUsesCurrentHealthAndLethalResolutionPreventsDamage() { + assertEquals(60, VorkathRules.zombifiedSpawnDamage(38)) + assertEquals(30, VorkathRules.zombifiedSpawnDamage(19)) + assertEquals(0, VorkathRules.zombifiedSpawnDamage(0)) + assertEquals(0, VorkathRules.zombifiedSpawnDamage(-1)) + } +} diff --git a/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathSafeLaneTest.kt b/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathSafeLaneTest.kt new file mode 100644 index 000000000..10d50c6a5 --- /dev/null +++ b/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathSafeLaneTest.kt @@ -0,0 +1,37 @@ +package org.rsmod.content.bosses.vorkath + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.rsmod.map.CoordGrid + +class VorkathSafeLaneTest { + private val boss = CoordGrid(2269, 4062) + + @Test + fun capturedCentreExitLaneContainsSevenContiguousTiles() { + val lane = VorkathAcidLayout.exitLane(boss) + assertEquals((2269..2275).map { CoordGrid(it, 4054) }, lane.toList()) + } + + @Test + fun blockedExitTileIsExcludedFromUsableLane() { + val blocked = CoordGrid(2272, 4054) + val player = CoordGrid(2271, 4061) + val pools = VorkathAcidLayout.select(boss, player, { it != blocked }, List::first) + val usable = VorkathAcidLayout.exitLane(boss).filter { it != blocked && it !in pools } + assertEquals(6, usable.size) + assertFalse(blocked in usable) + } + + @Test + fun standingOnTheExitLanePlacesOnePoolWithoutRandomlyBlockingTheRemainingLane() { + val player = CoordGrid(2272, 4054) + val pools = VorkathAcidLayout.select(boss, player, { true }, List::first) + val lane = VorkathAcidLayout.exitLane(boss) + assertEquals(setOf(player), pools.intersect(lane)) + assertTrue((2269..2271).all { CoordGrid(it, 4054) !in pools }) + assertTrue((2273..2275).all { CoordGrid(it, 4054) !in pools }) + } +} diff --git a/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathStorageCodecTest.kt b/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathStorageCodecTest.kt new file mode 100644 index 000000000..dd6194b18 --- /dev/null +++ b/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathStorageCodecTest.kt @@ -0,0 +1,48 @@ +@file:OptIn(dev.openrune.types.util.UncheckedType::class) + +package org.rsmod.content.bosses.vorkath + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.rsmod.game.inv.InvObj + +class VorkathStorageCodecTest { + @Test + fun emptyStorageRoundTrips() = + assertTrue(VorkathStorageCodec.decode(VorkathStorageCodec.encode(emptyList())).isEmpty()) + + @Test + fun oneStackRoundTrips() = + assertEquals( + listOf(InvObj(995, 100_000, 0)), + VorkathStorageCodec.decode(VorkathStorageCodec.encode(listOf(InvObj(995, 100_000, 0)))), + ) + + @Test + fun itemVarsRoundTrip() = + assertEquals( + 42, + VorkathStorageCodec.decode(VorkathStorageCodec.encode(listOf(InvObj(1, 1, 42)))) + .single() + .vars, + ) + + @Test + fun severalStacksPreserveOrder() = + assertEquals( + listOf(1, 2, 3), + VorkathStorageCodec.decode( + VorkathStorageCodec.encode(listOf(InvObj(1, 1), InvObj(2, 2), InvObj(3, 3))) + ) + .map { it.id }, + ) + + @Test + fun truncatedPersistenceRecordIsIgnored() = + assertTrue(VorkathStorageCodec.decode(listOf(1, 2)).isEmpty()) + + @Test + fun nonPositiveStackIsIgnored() = + assertTrue(VorkathStorageCodec.decode(listOf(1, 0, 0)).isEmpty()) +} diff --git a/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathTimelineTest.kt b/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathTimelineTest.kt new file mode 100644 index 000000000..bd1ad9493 --- /dev/null +++ b/content/bosses/vorkath/src/test/kotlin/org/rsmod/content/bosses/vorkath/VorkathTimelineTest.kt @@ -0,0 +1,152 @@ +package org.rsmod.content.bosses.vorkath + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class VorkathTimelineTest { + @Test + fun sixNormalLaunchesAreExactlyFiveTicksApartBeforeTheSpecial() { + val timeline = activeTimeline(VorkathSpecial.ACID) + val normals = mutableListOf() + val specials = mutableListOf() + for (tick in 8..38) { + if (!timeline.attackDue(tick)) continue + if (timeline.standardAttacks == 6) { + timeline.beginSpecial(tick) + specials += tick + } else { + timeline.standardLaunched(tick) + normals += tick + } + } + assertEquals(listOf(8, 13, 18, 23, 28, 33), normals) + assertEquals(listOf(5, 5, 5, 5, 5), normals.zipWithNext { a, b -> b - a }) + assertEquals(listOf(38), specials) + assertFalse(timeline.attackDue(43)) + assertFailsWith { timeline.standardLaunched(43) } + } + + @Test + fun bothInitialSelectionsAlternateThroughSeveralCompleteNormalCycles() { + for (initial in VorkathSpecial.entries) { + val timeline = activeTimeline(initial) + val actual = mutableListOf() + var tick = 8 + repeat(4) { + repeat(6) { + timeline.standardLaunched(tick) + tick += 5 + } + actual += timeline.beginSpecial(tick) + timeline.finishSpecial(tick + 40, recoveryTicks = 5) + tick += 45 + } + val alternate = VorkathRules.nextSpecial(initial) + assertEquals(listOf(initial, alternate, initial, alternate), actual) + } + } + + @Test + fun acidStartsItsTwentyFiveOneTickShotsFourTicksAfterTheCast() { + val timeline = activeTimeline(VorkathSpecial.ACID) + launchSix(timeline) + timeline.beginSpecial(38) + val launches = mutableListOf() + for (tick in 38..100) { + if (!timeline.rapidShotDue(tick)) continue + timeline.rapidLaunched(tick) + launches += tick + assertFalse(timeline.rapidShotDue(tick), "Duplicate rapid shot at tick $tick") + assertFalse(timeline.attackDue(tick), "Normal attack during acid at tick $tick") + } + assertEquals((42..66).toList(), launches) + assertEquals(25, timeline.shotsFired) + assertEquals(List(24) { 1 }, launches.zipWithNext { a, b -> b - a }) + assertFailsWith { timeline.rapidLaunched(101) } + } + + @Test + fun wakeStartsAtTheCapturedTickAndDoesNotAttackBeforeActivation() { + val timeline = VorkathTimeline(VorkathSpecial.ACID) + timeline.wake(5085) + assertEquals(5092, timeline.wakeCycle) + assertFalse(timeline.attackDue(5092)) + timeline.activate(5092) + assertFalse(timeline.attackDue(5092)) + assertTrue(timeline.attackDue(5093)) + } + + @Test + fun invalidStatesCannotLaunchOrResolveAnAttack() { + val sleeping = VorkathTimeline(VorkathSpecial.ACID) + assertFailsWith { sleeping.standardLaunched(100) } + assertFailsWith { sleeping.beginSpecial(100) } + assertFailsWith { sleeping.rapidLaunched(100) } + assertFailsWith { sleeping.finishSpecial(100, 1) } + assertFailsWith { sleeping.activate(100) } + val active = activeTimeline(VorkathSpecial.ZOMBIFIED_SPAWN) + assertFailsWith { active.beginSpecial(8) } + assertFailsWith { active.standardLaunched(7) } + assertFailsWith { active.rapidLaunched(8) } + assertFailsWith { active.wake(8) } + launchSix(active) + assertFailsWith { active.standardLaunched(38) } + active.beginSpecial(38) + assertFalse(active.rapidShotDue(42)) + } + + @Test + fun allResetStatesDiscardDeadlinesAndReentryHasAFreshCycle() { + for (state in VorkathState.entries) { + val timeline = activeTimeline(VorkathSpecial.ACID) + launchSix(timeline) + timeline.beginSpecial(38) + timeline.rapidLaunched(42) + timeline.state = state + timeline.reset(VorkathSpecial.ZOMBIFIED_SPAWN) + assertEquals(VorkathState.SLEEPING, timeline.state) + assertEquals(VorkathSpecial.ZOMBIFIED_SPAWN, timeline.nextSpecial) + assertEquals(0, timeline.standardAttacks) + assertEquals(0, timeline.shotsFired) + assertEquals(Int.MAX_VALUE, timeline.wakeCycle) + assertEquals(Int.MAX_VALUE, timeline.nextAttackCycle) + assertEquals(Int.MAX_VALUE, timeline.specialStartCycle) + assertFalse(timeline.attackDue(1000)) + assertFalse(timeline.rapidShotDue(1000)) + timeline.wake(1000) + timeline.activate(1007) + assertFalse(timeline.attackDue(1007)) + assertTrue(timeline.attackDue(1008)) + timeline.standardLaunched(1008) + assertEquals(1, timeline.standardAttacks) + } + } + + @Test + fun specialResolutionRestoresNormalAttacksAtItsRecoveryDeadline() { + for (special in VorkathSpecial.entries) { + val timeline = activeTimeline(special) + launchSix(timeline) + timeline.beginSpecial(38) + timeline.finishSpecial(70, recoveryTicks = 5) + assertEquals(VorkathState.ACTIVE, timeline.state) + assertFalse(timeline.attackDue(74)) + assertTrue(timeline.attackDue(75)) + assertFalse(timeline.rapidShotDue(75)) + assertEquals(Int.MAX_VALUE, timeline.specialStartCycle) + } + } + + private fun activeTimeline(first: VorkathSpecial): VorkathTimeline = + VorkathTimeline(first).also { + it.wake(0) + it.activate(7) + } + + private fun launchSix(timeline: VorkathTimeline) { + for (tick in 8..33 step 5) timeline.standardLaunched(tick) + } +} diff --git a/content/skills/magic/spell-attacks/build.gradle.kts b/content/skills/magic/spell-attacks/build.gradle.kts index 262cfefda..76f07c3df 100644 --- a/content/skills/magic/spell-attacks/build.gradle.kts +++ b/content/skills/magic/spell-attacks/build.gradle.kts @@ -6,4 +6,5 @@ dependencies { implementation(projects.api.combat.combatManager) implementation(projects.api.pluginCommons) implementation(projects.api.spells) + testImplementation(kotlin("test")) } diff --git a/content/skills/magic/spell-attacks/src/main/kotlin/org/rsmod/content/skills/magic/spell/attacks/SpellAttacksModule.kt b/content/skills/magic/spell-attacks/src/main/kotlin/org/rsmod/content/skills/magic/spell/attacks/SpellAttacksModule.kt index 494e37269..3fafe3e98 100644 --- a/content/skills/magic/spell-attacks/src/main/kotlin/org/rsmod/content/skills/magic/spell/attacks/SpellAttacksModule.kt +++ b/content/skills/magic/spell-attacks/src/main/kotlin/org/rsmod/content/skills/magic/spell/attacks/SpellAttacksModule.kt @@ -1,11 +1,13 @@ package org.rsmod.content.skills.magic.spell.attacks import org.rsmod.api.spells.attack.SpellAttackMap +import org.rsmod.content.skills.magic.spell.attacks.standard.CrumbleUndeadSpells import org.rsmod.content.skills.magic.spell.attacks.standard.ElementalSpells import org.rsmod.plugin.module.PluginModule class SpellAttacksModule : PluginModule() { override fun bind() { + addSetBinding(CrumbleUndeadSpells::class.java) addSetBinding(ElementalSpells::class.java) } } diff --git a/content/skills/magic/spell-attacks/src/main/kotlin/org/rsmod/content/skills/magic/spell/attacks/standard/CrumbleUndeadSpells.kt b/content/skills/magic/spell-attacks/src/main/kotlin/org/rsmod/content/skills/magic/spell/attacks/standard/CrumbleUndeadSpells.kt new file mode 100644 index 000000000..f3fb84d09 --- /dev/null +++ b/content/skills/magic/spell-attacks/src/main/kotlin/org/rsmod/content/skills/magic/spell/attacks/standard/CrumbleUndeadSpells.kt @@ -0,0 +1,108 @@ +package org.rsmod.content.skills.magic.spell.attacks.standard + +import dev.openrune.types.ItemServerType +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.manager.MagicRuneManager.Companion.isFailure +import org.rsmod.api.config.refs.params +import org.rsmod.api.player.bonus.WornBonuses +import org.rsmod.api.player.output.mes +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.spells.attack.SpellAttack +import org.rsmod.api.spells.attack.SpellAttackManager +import org.rsmod.api.spells.attack.SpellAttackMap +import org.rsmod.api.spells.attack.SpellAttackRepository +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.Player +import org.rsmod.game.type.getOrNull + +/** Shared Crumble Undead spell, including Vorkath's zombified-spawn accuracy exception. */ +internal class CrumbleUndeadSpells @Inject constructor(private val bonuses: WornBonuses) : + SpellAttackMap { + override fun SpellAttackRepository.register(manager: SpellAttackManager) { + register(spell = "obj.39_crumble_undead", attack = CrumbleUndeadAttack(manager, bonuses)) + } +} + +internal object CrumbleUndeadRules { + const val MAX_HIT = 15 + const val SPAWN_ACCURACY_THRESHOLD = -64 + + fun isVorkathSpawn(internalName: String): Boolean = + internalName.removePrefix("npc.") == "vorkath_spawn" + + fun forceHitSpawn(internalName: String, magicAttackBonus: Int): Boolean = + isVorkathSpawn(internalName) && magicAttackBonus > SPAWN_ACCURACY_THRESHOLD +} + +private class CrumbleUndeadAttack( + private val manager: SpellAttackManager, + private val bonuses: WornBonuses, +) : SpellAttack { + override suspend fun ProtectedAccess.attack(target: Npc, attack: CombatAttack.Spell) { + val vorkathSpawn = CrumbleUndeadRules.isVorkathSpawn(target.type.internalName) + // Revision 240 does not set the generic undead parameter on Vorkath's spawn. + if (!vorkathSpawn && target.visType.param(params.undead) == 0) { + mes("This spell only affects the undead.") + manager.stopCombat(this) + return + } + val castResult = manager.attemptCast(this, attack) + if (castResult.isFailure()) return + + player.anim(getOrNull(attack.weapon).castAnim(), priority = 6) + spotanim("spotanim.crumbleundead_casting", height = 92) + val proj = + manager.spawnProjectile( + this, + target, + "spotanim.crumbleundead_travel", + "projanim.magic_spell", + ) + val (serverDelay, clientDelay) = proj.durations + val forceHit = + CrumbleUndeadRules.forceHitSpawn( + target.type.internalName, + bonuses.offensiveMagicBonus(player), + ) + val splash = !forceHit && manager.rollSplash(this, target, attack, castResult) + if (splash) { + manager.playSplashFx(this, target, clientDelay, "synth.crumble_cast_and_fire", 8) + manager.queueSplashHit(this, target, attack.spell.obj, clientDelay, serverDelay) + manager.continueCombatIfAutocast(this, target) + return + } + + val damage = + if (vorkathSpawn) { + target.hitpoints + } else { + manager.rollMaxHit(this, target, attack, castResult, CrumbleUndeadRules.MAX_HIT) + } + manager.playHitFx( + source = this, + target = target, + clientDelay = clientDelay, + castSound = "synth.crumble_cast_and_fire", + soundRadius = 8, + hitSpot = "spotanim.crumbleundead_impact", + hitSpotHeight = 124, + hitSound = "synth.crumble_hit", + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueMagicHit(this, target, attack.spell.obj, damage, clientDelay, serverDelay) + manager.continueCombatIfAutocast(this, target) + } + + override suspend fun ProtectedAccess.attack(target: Player, attack: CombatAttack.Spell) { + mes("This spell only affects the undead.") + manager.stopCombat(this) + } + + private fun ItemServerType?.castAnim(): String = + if (this != null && isCategoryType("category.staff")) { + "seq.human_castcrumbleundead_staff" + } else { + "seq.human_castcrumbleundead" + } +} diff --git a/content/skills/magic/spell-attacks/src/test/kotlin/org/rsmod/content/skills/magic/spell/attacks/standard/CrumbleUndeadRulesTest.kt b/content/skills/magic/spell-attacks/src/test/kotlin/org/rsmod/content/skills/magic/spell/attacks/standard/CrumbleUndeadRulesTest.kt new file mode 100644 index 000000000..7ff435670 --- /dev/null +++ b/content/skills/magic/spell-attacks/src/test/kotlin/org/rsmod/content/skills/magic/spell/attacks/standard/CrumbleUndeadRulesTest.kt @@ -0,0 +1,31 @@ +package org.rsmod.content.skills.magic.spell.attacks.standard + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CrumbleUndeadRulesTest { + @Test + fun identifiesVorkathSpawn() = assertTrue(CrumbleUndeadRules.isVorkathSpawn("vorkath_spawn")) + + @Test + fun identifiesNativeRevision240Spawn() = + assertTrue(CrumbleUndeadRules.isVorkathSpawn("npc.vorkath_spawn")) + + @Test fun rejectsOtherUndead() = assertFalse(CrumbleUndeadRules.isVorkathSpawn("skeleton")) + + @Test + fun bonusMinus63ForcesHit() = assertTrue(CrumbleUndeadRules.forceHitSpawn("npc.vorkath_spawn", -63)) + + @Test + fun bonusMinus64DoesNotForceHit() = + assertFalse(CrumbleUndeadRules.forceHitSpawn("npc.vorkath_spawn", -64)) + + @Test + fun veryLowBonusDoesNotForceHit() = + assertFalse(CrumbleUndeadRules.forceHitSpawn("npc.vorkath_spawn", -200)) + + @Test + fun otherUndeadNeverUsesSpawnException() = + assertFalse(CrumbleUndeadRules.forceHitSpawn("zombie", 100)) +}