diff --git a/data/area/wilderness/wilderness.areas.toml b/data/area/wilderness/wilderness.areas.toml index f7cecc58a0..563f2ccb15 100644 --- a/data/area/wilderness/wilderness.areas.toml +++ b/data/area/wilderness/wilderness.areas.toml @@ -89,3 +89,8 @@ tags = ["teleport", "obelisk"] [wilderness_fishing_area] x = [3347, 3368] y = [3793, 3817] + +[clan_wars] +x = [3264, 3279] +y = [3672, 3695] +tags = ["safe_zone"] diff --git a/data/area/wilderness/wilderness.npc-spawns.toml b/data/area/wilderness/wilderness.npc-spawns.toml index b77d197eac..0a96aaf1f3 100644 --- a/data/area/wilderness/wilderness.npc-spawns.toml +++ b/data/area/wilderness/wilderness.npc-spawns.toml @@ -295,10 +295,6 @@ spawns = [ { id = "black_salamander", x = 3321, y = 3661 }, { id = "red_dragon", x = 3212, y = 3820 }, { id = "red_dragon", x = 3222, y = 3829 }, - { id = "skeleton_axe_shield", x = 3275, y = 3686 }, - { id = "skeleton_sword_shield", x = 3270, y = 3681 }, - { id = "skeleton_longsword", x = 3275, y = 3676 }, - { id = "skeleton_flail", x = 3269, y = 3672 }, { id = "waterfiend", x = 3362, y = 3642, members = true }, { id = "waterfiend", x = 3363, y = 3647, members = true }, { id = "waterfiend", x = 3365, y = 3639, members = true }, @@ -375,7 +371,6 @@ spawns = [ { id = "giant_rat_wilderness_2", x = 3248, y = 3553, members = true }, { id = "giant_rat_wilderness_2", x = 3256, y = 3541, members = true }, { id = "goblin_musician", x = 3140, y = 3642 }, - { id = "skeleton_mace_shield", x = 3268, y = 3688 }, { id = "green_dragon", x = 2982, y = 3618 }, { id = "green_dragon", x = 3118, y = 3820 }, { id = "green_dragon", x = 3338, y = 3676 }, diff --git a/data/entity/player/modal/chat_box/warning.varbits.toml b/data/entity/player/modal/chat_box/warning.varbits.toml index 5b30d06012..d5a8f322d5 100644 --- a/data/entity/player/modal/chat_box/warning.varbits.toml +++ b/data/entity/player/modal/chat_box/warning.varbits.toml @@ -101,7 +101,7 @@ format = "int" [warning_clan_wars_ffa_safe] id = 5294 persist = true -format = "boolean" +format = "int" [warning_ranging_guild_tower] id = 3871 @@ -146,7 +146,7 @@ format = "int" [warning_clan_wars_ffa_dangerous] id = 5295 persist = true -format = "boolean" +format = "int" [warning_living_rock_caverns] id = 6500 diff --git a/data/entity/player/modal/chat_box/warnings.ifaces.toml b/data/entity/player/modal/chat_box/warnings.ifaces.toml index 3c2aefaa52..dc26471e9a 100644 --- a/data/entity/player/modal/chat_box/warnings.ifaces.toml +++ b/data/entity/player/modal/chat_box/warnings.ifaces.toml @@ -441,6 +441,19 @@ id = 82 [.living_rock_caverns] id = 83 +[warning_clan_wars_ffa_safe] +id = 793 +type = "main_screen" + +[.yes] +id = 15 + +[.no] +id = 14 + +[.dont_ask] +id = 9 + [warning_godwars_wilderness_agility_route] id = 600 type = "main_screen" diff --git a/data/minigame/clan_wars/clan_wars.areas.toml b/data/minigame/clan_wars/clan_wars.areas.toml index 22f5f9b388..8caa4a15b9 100644 --- a/data/minigame/clan_wars/clan_wars.areas.toml +++ b/data/minigame/clan_wars/clan_wars.areas.toml @@ -2,3 +2,25 @@ x = [3266, 3270] y = [3679, 3682] tags = ["teleport"] + +[clan_wars_ffa] +x = [2740, 3090] +y = [5490, 5640] + +[clan_wars_ffa_safe_arena] +x = [2756, 2878] +y = [5512, 5630] + +[clan_wars_ffa_safe_multi] +x = [2756, 2878] +y = [5571, 5630] +tags = ["multi_combat"] + +[clan_wars_ffa_dangerous_arena] +x = [2948, 3071] +y = [5512, 5631] + +[clan_wars_ffa_dangerous_multi] +x = [2948, 3071] +y = [5571, 5631] +tags = ["multi_combat"] diff --git a/data/minigame/clan_wars/clan_wars.ifaces.toml b/data/minigame/clan_wars/clan_wars.ifaces.toml index 88026e5d00..0871554253 100644 --- a/data/minigame/clan_wars/clan_wars.ifaces.toml +++ b/data/minigame/clan_wars/clan_wars.ifaces.toml @@ -1,5 +1,6 @@ [clan_wars] id = 789 +type = "overlay" [clan_wars_defeat] id = 790 @@ -10,6 +11,3 @@ id = 791 [clan_wars_overview] id = 792 -[clan_wars_respawn] -id = 793 - diff --git a/data/minigame/clan_wars/clan_wars.objs.toml b/data/minigame/clan_wars/clan_wars.objs.toml new file mode 100644 index 0000000000..a6d475b8bb --- /dev/null +++ b/data/minigame/clan_wars/clan_wars.objs.toml @@ -0,0 +1,15 @@ +[clan_wars_portal_ffa_safe] +id = 38698 +examine = "A portal to a safe free-for-all fighting area." + +[clan_wars_portal_ffa_dangerous] +id = 38699 +examine = "A portal to a dangerous free-for-all fighting area." + +[clan_wars_challenge_portal] +id = 28213 +examine = "A portal to the Clan Wars arena." + +[clan_wars_portal_ffa_safe_exit] +id = 38700 +examine = "Leave the free-for-all fighting area." diff --git a/data/minigame/clan_wars/clan_wars.varbits.toml b/data/minigame/clan_wars/clan_wars.varbits.toml new file mode 100644 index 0000000000..76fe0026e3 --- /dev/null +++ b/data/minigame/clan_wars/clan_wars.varbits.toml @@ -0,0 +1,3 @@ +[clan_wars_ffa_portal] +id = 5279 +format = "int" diff --git a/data/skill/range/ammo_groups.toml b/data/skill/range/ammo_groups.toml index b6761d588d..d653f14d0b 100644 --- a/data/skill/range/ammo_groups.toml +++ b/data/skill/range/ammo_groups.toml @@ -72,6 +72,10 @@ items = [ "rune_arrow", "rune_fire_arrows_lit", "rune_fire_arrows_unlit", + "dragon_arrow", + "dragon_arrow_p", + "dragon_arrow_p+", + "dragon_arrow_p++", "ice_arrows", "broad_arrows", "saradomin_arrows", diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/data/AccountManager.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/data/AccountManager.kt index 4353a05984..541917bf13 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/data/AccountManager.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/data/AccountManager.kt @@ -41,7 +41,6 @@ class AccountManager( get() = Tile(Settings["world.home.x", 0], Settings["world.home.y", 0], Settings["world.home.level", 0]) fun create(name: String, passwordHash: String): Player = Player(tile = homeTile, accountName = name, passwordHash = passwordHash).apply { - this["creation"] = System.currentTimeMillis() this["new_player"] = true } diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/combat/CombatMovement.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/combat/CombatMovement.kt index 7961124222..7a922d6fd1 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/combat/CombatMovement.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/combat/CombatMovement.kt @@ -16,9 +16,7 @@ import world.gregs.voidps.engine.entity.character.player.chat.cantReach import world.gregs.voidps.engine.entity.item.Item import world.gregs.voidps.engine.get import world.gregs.voidps.engine.map.Overlap -import world.gregs.voidps.type.Direction import world.gregs.voidps.type.Tile -import world.gregs.voidps.type.random import kotlin.math.abs /** @@ -60,7 +58,6 @@ class CombatMovement( if (!attack()) { var skip: Boolean if (Overlap.isUnder(character.tile, character.size, target.tile, target.size)) { - stepOut() skip = true } else { val wasEmpty = character.steps.isEmpty() @@ -81,17 +78,8 @@ class CombatMovement( } } - private fun stepOut() { - clearSteps() - if (target.mode is CombatMovement || target.mode is Interact) { - return - } - val direction = Direction.cardinal.random(random) - if (!canStep(direction.delta.x, direction.delta.y)) { - return - } - character.steps.queueStep(strategy.tile.add(direction)) - } + override fun shouldQueueStepOut(): Boolean = + target.mode !is CombatMovement && target.mode !is Interact private fun attack(): Boolean { val attackRange = attackRange() diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/interact/Interact.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/interact/Interact.kt index 51d4d0bd7a..e2dc9308d3 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/interact/Interact.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/interact/Interact.kt @@ -86,6 +86,11 @@ open class Interact( return } updateRange = false + val target = target + if (stepOut()) { + super.tick() + return + } calculate() val interacted = processInteraction() if (interacted && interactionFinished()) { diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/move/Movement.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/move/Movement.kt index f6e66dc622..ef447a78c6 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/move/Movement.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/move/Movement.kt @@ -26,6 +26,7 @@ import world.gregs.voidps.type.Delta import world.gregs.voidps.type.Direction import world.gregs.voidps.type.Tile import world.gregs.voidps.type.equals +import world.gregs.voidps.type.random import kotlin.math.sign open class Movement( @@ -53,6 +54,32 @@ open class Movement( needsCalculation = false } + /** + * Clears steps and queues a random cardinal step when an NPC overlaps its character target and isn't permitted to stand there. + */ + protected open fun stepOut(): Boolean { + val strategy = strategy ?: return false + if (strategy.shape != -2) return false + val npc = character as? NPC ?: return false + if (npc.def["allowed_under", false]) return false + if (!Overlap.isUnder(npc.tile, npc.size, npc.size, strategy.tile, strategy.width, strategy.height)) return false + clearSteps() + if (shouldQueueStepOut()) { + for (direction in Direction.cardinal.shuffled(random)) { + if (canStep(direction.delta.x, direction.delta.y)) { + character.steps.queueStep(npc.tile.add(direction)) + break + } + } + } + return true + } + + /** + * Whether [stepOut] should queue a random step after clearing, or let normal recalculation handle repositioning. + */ + protected open fun shouldQueueStepOut(): Boolean = true + override fun tick() { val character = character if (character is Player && character.viewport?.loaded == false) { @@ -61,7 +88,9 @@ open class Movement( if (hasDelay() && !canMove() && !character.steps.destination.noCollision) { return } - calculate() + if (!stepOut()) { + calculate() + } if (step(runStep = false) && character.running) { if (character.steps.isNotEmpty()) { step(runStep = true) diff --git a/engine/src/test/kotlin/world/gregs/voidps/engine/data/AccountManagerTest.kt b/engine/src/test/kotlin/world/gregs/voidps/engine/data/AccountManagerTest.kt index 813b11793a..b356cb7d1b 100644 --- a/engine/src/test/kotlin/world/gregs/voidps/engine/data/AccountManagerTest.kt +++ b/engine/src/test/kotlin/world/gregs/voidps/engine/data/AccountManagerTest.kt @@ -85,9 +85,7 @@ class AccountManagerTest : KoinMock() { @Test fun `Create a new player`() { - val start = System.currentTimeMillis() val player = manager.create("name", "hash") - assertTrue(player["creation", 0L] >= start) assertTrue(player["new_player", false]) assertEquals(Tile(1234, 5432), player.tile) } diff --git a/game/src/main/kotlin/content/area/karamja/tzhaar_city/TzhaarFightCave.kt b/game/src/main/kotlin/content/area/karamja/tzhaar_city/TzhaarFightCave.kt index 749cffc664..94c708c687 100644 --- a/game/src/main/kotlin/content/area/karamja/tzhaar_city/TzhaarFightCave.kt +++ b/game/src/main/kotlin/content/area/karamja/tzhaar_city/TzhaarFightCave.kt @@ -123,6 +123,9 @@ class TzhaarFightCave( return@npcDespawn } val wave = killer.wave + if (wave == -1) { + return@npcDespawn + } if (wave == 63 && id == "tztok_jad") { killer.leave(wave, true) } else if (wave < 63) { diff --git a/game/src/main/kotlin/content/entity/player/bank/BankTabCollapse.kt b/game/src/main/kotlin/content/entity/player/bank/BankTabCollapse.kt index 40ffe3b45b..a1895519d0 100644 --- a/game/src/main/kotlin/content/entity/player/bank/BankTabCollapse.kt +++ b/game/src/main/kotlin/content/entity/player/bank/BankTabCollapse.kt @@ -2,7 +2,6 @@ package content.entity.player.bank import content.entity.player.bank.Bank.tabIndex import world.gregs.voidps.engine.Script -import world.gregs.voidps.engine.inv.transact.operation.ShiftItem.shiftToFreeIndex class BankTabCollapse : Script { @@ -11,9 +10,17 @@ class BankTabCollapse : Script { val tab = it.component.removePrefix("tab_").toInt() - 1 val tabIndex = tabIndex(this, tab) val count: Int = get("bank_tab_$tab", 0) + val lastIndex = bank.count - 1 val collapsed = bank.transaction { - repeat(count) { - shiftToFreeIndex(tabIndex) + // Save the tab items being collapsed + val tabItems = (0 until count).map { inventory[tabIndex + it] } + // Shift all items after the tab left by count to close the gap + for (i in tabIndex until lastIndex - count + 1) { + set(i, inventory[i + count]) + } + // Place the collapsed tab items at the end + for (i in tabItems.indices) { + set(lastIndex - count + 1 + i, tabItems[i]) } } if (collapsed) { diff --git a/game/src/main/kotlin/content/entity/player/bank/BankTabs.kt b/game/src/main/kotlin/content/entity/player/bank/BankTabs.kt index 2a4dec67c0..daad8f40e8 100644 --- a/game/src/main/kotlin/content/entity/player/bank/BankTabs.kt +++ b/game/src/main/kotlin/content/entity/player/bank/BankTabs.kt @@ -38,7 +38,7 @@ class BankTabs : Script { set("bank_item_mode", if (value == "insert") "swap" else "insert") } - interfaceSwap("bank:tab_#") { _, toId, fromSlot, _ -> + interfaceSwap("bank:inventory", "bank:tab_#") { _, toId, fromSlot, _ -> val fromTab = Bank.getTab(this, fromSlot) val toTab = toId.substringAfter(":").removePrefix("tab_").toInt() - 1 val toIndex = if (toTab == Bank.MAIN_TAB) bank.freeIndex() else Bank.tabIndex(this, toTab + 1) diff --git a/game/src/main/kotlin/content/minigame/clan_wars/ClanWarsFreeForAll.kt b/game/src/main/kotlin/content/minigame/clan_wars/ClanWarsFreeForAll.kt new file mode 100644 index 0000000000..f8b7be9028 --- /dev/null +++ b/game/src/main/kotlin/content/minigame/clan_wars/ClanWarsFreeForAll.kt @@ -0,0 +1,120 @@ +package content.minigame.clan_wars + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.close +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.data.definition.Areas +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.player.chat.ChatType +import world.gregs.voidps.engine.entity.character.player.combatLevel +import world.gregs.voidps.type.Tile + +class ClanWarsFreeForAll : Script { + + val outside = Tile(3272, 3692, 0) + val safeArena = Tile(2815, 5511, 0) + val dangerousArena = Tile(3007, 5511, 0) + + init { + // Entry portals - varbit 5279 selects safe (0) or dangerous (1) so the client shows the correct text + objectOperate("Enter", "clan_wars_portal_ffa_safe") { + if (combatLevel < 30) { + message("You need a combat level of at least 30 to enter this portal.") + return@objectOperate + } + set("clan_wars_ffa_portal", 0) + if (get("warning_clan_wars_ffa_safe", 0) == 1) { + tele(safeArena) + return@objectOperate + } + open("warning_clan_wars_ffa_safe") + } + + objectOperate("Enter", "clan_wars_portal_ffa_dangerous") { + if (combatLevel < 30) { + message("You need a combat level of at least 30 to enter this portal.") + return@objectOperate + } + set("clan_wars_ffa_portal", 1) + if (get("warning_clan_wars_ffa_dangerous", 0) == 1) { + tele(dangerousArena) + return@objectOperate + } + open("warning_clan_wars_ffa_safe") + } + + // "Go in" button - reads varbit 5279 to pick the arena + interfaceOption("Go in", "warning_clan_wars_ffa_safe:yes") { + close("warning_clan_wars_ffa_safe") + tele(if (get("clan_wars_ffa_portal", 0) == 1) dangerousArena else safeArena) + } + + // "Cancel" button + interfaceOption("Cancel", "warning_clan_wars_ffa_safe:no") { + close("warning_clan_wars_ffa_safe") + } + + // "Don't show again" checkbox - toggles the varbit for whichever portal opened the dialog + interfaceOption("Toggle warning", "warning_clan_wars_ffa_safe:dont_ask") { + val key = if (get("clan_wars_ffa_portal", 0) == 1) "warning_clan_wars_ffa_dangerous" else "warning_clan_wars_ffa_safe" + set(key, if (get(key, 0) == 1) 0 else 1) + } + + // Clan Wars challenge portal - not yet implemented + objectOperate("Enter", "clan_wars_challenge_portal") { + message("Clan Wars is still under construction.") + } + + // Exit portal - reads varbit 5279 to determine which arena the player entered from + objectOperate("Leave", "clan_wars_portal_ffa_safe_exit") { + tele(outside) + val type = if (get("clan_wars_ffa_portal", 0) == 1) "Dangerous" else "Safe" + message("You have left the Clan Wars Free-For-All ($type).", ChatType.Filter) + } + + // Overlay: covers both arenas including the lobby at y=5511 + entered("clan_wars_ffa") { + open("clan_wars") + } + + exited("clan_wars_ffa") { + close("clan_wars") + } + + // PvP - safe arena + entered("clan_wars_ffa_safe_arena") { + set("in_pvp", true) + options.set(1, "Attack") + } + + exited("clan_wars_ffa_safe_arena") { + clear("in_pvp") + options.remove("Attack") + } + + // PvP - dangerous arena + entered("clan_wars_ffa_dangerous_arena") { + set("in_pvp", true) + options.set(1, "Attack") + } + + exited("clan_wars_ffa_dangerous_arena") { + clear("in_pvp") + options.remove("Attack") + } + + // On death in safe arena: keep items, respawn outside + playerDeath { + if (tile !in Areas["clan_wars_ffa_safe_arena"]) return@playerDeath + it.dropItems = false + it.teleport = outside + } + + // On death in dangerous arena: drop items, respawn outside + playerDeath { + if (tile !in Areas["clan_wars_ffa_dangerous_arena"]) return@playerDeath + it.teleport = outside + } + } +} diff --git a/game/src/main/kotlin/content/skill/melee/weapon/special/DragonClaws.kt b/game/src/main/kotlin/content/skill/melee/weapon/special/DragonClaws.kt index e4f69d4e24..4bca735489 100644 --- a/game/src/main/kotlin/content/skill/melee/weapon/special/DragonClaws.kt +++ b/game/src/main/kotlin/content/skill/melee/weapon/special/DragonClaws.kt @@ -5,6 +5,7 @@ import content.entity.combat.hit.Hit import content.entity.combat.hit.hit import content.skill.melee.weapon.weapon import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.timer.TICKS import world.gregs.voidps.type.random class DragonClaws : Script { @@ -13,33 +14,40 @@ class DragonClaws : Script { specialAttack("slice_and_dice") { target, id -> anim("${id}_special") gfx("${id}_special") - val weapon = weapon var (hit1, hit2, hit3, hit4) = intArrayOf(0, 0, 0, 0) val maxHit = Damage.maximum(this, target, "melee", weapon) if (Hit.success(this, target, "melee", weapon, special = true)) { - hit1 = random.nextInt(maxHit / 2, maxHit - 10) + // First hit lands: high damage, half, then two hits adding up to the second. + // e.g. 300-150-70-80 + hit1 = random.nextInt(maxHit / 2, maxHit + 1) hit2 = hit1 / 2 - hit3 = hit2 / 2 - hit4 = hit3 + if (random.nextBoolean()) 10 else 0 + hit3 = random.nextInt(0, hit2 + 1) + hit4 = hit2 - hit3 } else if (Hit.success(this, target, "melee", weapon, special = true)) { - hit2 = random.nextDouble(maxHit * 0.375, maxHit * 0.875).toInt() + // First misses, second hits: 3rd and 4th each deal half of the 2nd. + // e.g. 0-300-150-150 + hit2 = random.nextInt(maxHit / 2, maxHit + 1) hit3 = hit2 / 2 - hit4 = hit3 + if (random.nextBoolean()) 10 else 0 + hit4 = hit2 - hit3 } else if (Hit.success(this, target, "melee", weapon, special = true)) { - hit3 = random.nextDouble(maxHit * 0.25, maxHit * 0.75).toInt() - hit4 = hit3 + if (random.nextBoolean()) 10 else 0 + // First two miss: 3rd and 4th are regular hits, capped at 75% max. + // e.g. 0-0-300-300 + val cappedMax = (maxHit * 3) / 4 + hit3 = random.nextInt(cappedMax + 1) + hit4 = random.nextInt(cappedMax + 1) } else if (Hit.success(this, target, "melee", weapon, special = true)) { - hit4 = random.nextDouble(maxHit * 0.25, maxHit * 1.25).toInt() + // First three miss, fourth hits with 50% damage boost. + // e.g. 0-0-0-450 + hit4 = random.nextInt((maxHit * 3) / 2 + 1) } else { - hit3 = if (random.nextBoolean()) 10 else 0 - hit4 = if (random.nextBoolean()) 10 else 0 + // All four miss: fourth hit almost always lands between 1 and 7. + hit4 = random.nextInt(1, 8) } - hit(target, damage = hit1) hit(target, damage = hit2) - hit(target, damage = hit3, delay = 30) - hit(target, damage = hit4, delay = 30) + hit(target, damage = hit3, delay = TICKS.toClientTicks(2)) + hit(target, damage = hit4, delay = TICKS.toClientTicks(2)) } } } diff --git a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt index f7eab10371..a911d14b8b 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatMovementTest.kt @@ -129,6 +129,16 @@ internal class CombatMovementTest : WorldTest() { assertTrue(npc.mode is CombatMovement) } + @Test + fun `Npc spawned under player steps out to attack`() { + val player = createPlayer(emptyTile) + val npc = createNPC("guard_falador", emptyTile) + npc.interactPlayer(player, "Attack") + tick(2) + assertTrue(npc.tile != emptyTile) + assertTrue(npc.mode is CombatMovement) + } + companion object { private const val MAX_EXP = 14000000.0 }