From cdf43d01f86cc3e5e1d2bd5719dcf9108608d9e8 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Wed, 29 Apr 2026 21:55:41 +0300 Subject: [PATCH] Performance tests --- .../kotlin/content/CombatPerformanceTests.kt | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 Server/src/test/kotlin/content/CombatPerformanceTests.kt diff --git a/Server/src/test/kotlin/content/CombatPerformanceTests.kt b/Server/src/test/kotlin/content/CombatPerformanceTests.kt new file mode 100644 index 000000000..c69c41fb1 --- /dev/null +++ b/Server/src/test/kotlin/content/CombatPerformanceTests.kt @@ -0,0 +1,186 @@ +package content + +import TestUtils +import core.game.node.entity.combat.CombatMovementIntents +import core.game.node.entity.combat.equipment.WeaponInterface +import core.game.node.entity.player.Player +import core.game.node.entity.skill.Skills +import core.game.world.GameWorld +import core.game.world.map.Location +import core.game.world.update.UpdateSequence +import core.net.packet.PacketProcessor +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.concurrent.TimeUnit + +class CombatPerformanceTests { + init { + TestUtils.preTestSetup() + } + + @Test + fun serverTickStaysWithinBudgetWithLiveSizedCombatLoad() { + val load = createCombatLoad() + try { + assertEquals(REAL_PLAYER_COUNT, load.players.count { !it.isArtificial }) + assertEquals(BOT_PLAYER_COUNT, load.players.count { it.isArtificial }) + + repeat(WARMUP_TICKS) { + measureCombatTick(load, it) + } + + val durations = LongArray(MEASURED_TICKS) { tick -> + measureCombatTick(load, tick + WARMUP_TICKS) + } + val sorted = durations.sorted() + val p90Index = ((sorted.size * 9 + 9) / 10 - 1).coerceIn(0, sorted.lastIndex) + val p90 = sorted[p90Index] + val max = sorted.last() + val durationText = durations.joinToString(prefix = "[", postfix = "]") + + assertTrue( + p90 <= HEADROOM_TICK_BUDGET_MILLIS, + "650-player combat p90 tick time should leave room for slower live hardware. " + + "durations=${durationText}ms, p90=${p90}ms, " + + "budget=${HEADROOM_TICK_BUDGET_MILLIS}ms" + ) + assertTrue( + max <= LIVE_TICK_BUDGET_MILLIS, + "650-player combat tick should remain under the live 600ms server tick budget. " + + "durations=${durationText}ms, max=${max}ms" + ) + } finally { + load.close() + } + } + + private fun createCombatLoad(): CombatLoad { + val players = ArrayList(TOTAL_PLAYER_COUNT) + val closeables = ArrayList(TOTAL_PLAYER_COUNT) + val previousWildPvp = GameWorld.settings!!.wild_pvp_enabled + + for (i in 0 until TOTAL_PLAYER_COUNT) { + val player = TestUtils.getMockPlayer("combat_perf_$i", isBot = i >= REAL_PLAYER_COUNT) + players.add(player) + closeables.add(player) + configureMeleePlayer(player) + } + + val pairs = players.chunked(2).mapIndexed { index, pair -> + CombatPair(pair[0], pair[1], pairOrigin(index)) + } + val load = CombatLoad(players, closeables, pairs, previousWildPvp) + + GameWorld.settings!!.wild_pvp_enabled = true + load.resetPairPositionsAndMovement(0) + for (pair in pairs) { + pair.first.attack(pair.second) + pair.second.attack(pair.first) + } + return load + } + + private fun measureCombatTick(load: CombatLoad, tick: Int): Long { + PacketProcessor.queue.clear() + load.resetPairPositionsAndMovement(tick) + load.requestAllCombatMovement() + + assertEquals( + TOTAL_PLAYER_COUNT, + CombatMovementIntents.pendingCount(), + "The performance fixture should exercise one combat movement intent per loaded player." + ) + + val start = System.nanoTime() + GameWorld.majorUpdateWorker.handleTickActions(false) + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) + } + + private fun configureMeleePlayer(player: Player) { + player.properties.attackStyle = WeaponInterface.AttackStyle( + WeaponInterface.STYLE_AGGRESSIVE, + WeaponInterface.BONUS_CRUSH + ) + player.properties.combatPulse.updateStyle() + player.properties.combatLevel = 126 + player.skills.setStaticLevel(Skills.HITPOINTS, 10_000) + player.skills.lifepoints = 10_000 + player.settings.runEnergy = 100.0 + player.settings.setRunToggled(true) + player.skullManager.isWilderness = true + player.skullManager.level = 50 + } + + private data class CombatPair( + val first: Player, + val second: Player, + val origin: Location + ) + + private class CombatLoad( + val players: List, + private val closeables: List, + private val pairs: List, + private val previousWildPvp: Boolean + ) : AutoCloseable { + + fun resetPairPositionsAndMovement(tick: Int) { + val direction = if (tick % 2 == 0) 1 else -1 + for (pair in pairs) { + val firstLocation = pair.origin + val secondLocation = pair.origin.transform(1, 0, 0) + place(pair.first, firstLocation) + place(pair.second, secondLocation) + queueRun(pair.first, firstLocation.transform(-8 * direction, 0, 0)) + queueRun(pair.second, secondLocation.transform(8 * direction, 0, 0)) + } + } + + fun requestAllCombatMovement() { + CombatMovementIntents.clear() + for (pair in pairs) { + CombatMovementIntents.request(pair.first, pair.second) + CombatMovementIntents.request(pair.second, pair.first) + } + } + + override fun close() { + CombatMovementIntents.clear() + for (closeable in closeables.asReversed()) { + closeable.close() + } + GameWorld.Pulser.updateAll() + UpdateSequence.renderablePlayers.sync() + PacketProcessor.queue.clear() + GameWorld.settings!!.wild_pvp_enabled = previousWildPvp + } + + private fun place(player: Player, location: Location) { + player.location = location + player.walkingQueue.reset() + } + + private fun queueRun(player: Player, destination: Location) { + player.walkingQueue.reset(true) + player.walkingQueue.addPath(destination.x, destination.y) + } + } + + private companion object { + const val REAL_PLAYER_COUNT = 150 + const val BOT_PLAYER_COUNT = 500 + const val TOTAL_PLAYER_COUNT = REAL_PLAYER_COUNT + BOT_PLAYER_COUNT + const val WARMUP_TICKS = 3 + const val MEASURED_TICKS = 10 + const val LIVE_TICK_BUDGET_MILLIS = 600L + const val HEADROOM_TICK_BUDGET_MILLIS = 350L + + fun pairOrigin(index: Int): Location { + val columns = 25 + val column = index % columns + val row = index / columns + return Location.create(3200 + column * 12, 3600 + row * 6, 0) + } + } +}