From add17eef5ef19b9611ee9fb81659570b0807cab4 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 17:16:19 -0700 Subject: [PATCH 1/4] Add box trap and deadfall trap hunting --- .../feldip_hills/feldip_hills.npc-spawns.toml | 30 +-- .../feldip_hills/feldip_hills.npcs.toml | 2 +- data/entity/npc/hunt_modes.toml | 14 ++ data/skill/hunter/hunter.anims.toml | 25 +- data/skill/hunter/hunter.npcs.toml | 6 +- data/skill/hunter/hunter.objs.toml | 36 ++- data/skill/hunter/hunter.sounds.toml | 12 + data/skill/hunter/hunter.tables.toml | 82 +++++-- .../kotlin/content/skill/hunter/BoxTrap.kt | 229 ++++++++++++++++++ .../content/skill/hunter/DeadfallTrap.kt | 191 +++++++++++++++ .../kotlin/content/skill/hunter/NetTrap.kt | 19 +- .../main/kotlin/content/skill/hunter/Traps.kt | 15 ++ .../content/skill/hunter/BoxTrapTest.kt | 206 ++++++++++++++++ .../content/skill/hunter/DeadfallTest.kt | 145 +++++++++++ 14 files changed, 954 insertions(+), 58 deletions(-) create mode 100644 game/src/main/kotlin/content/skill/hunter/BoxTrap.kt create mode 100644 game/src/main/kotlin/content/skill/hunter/DeadfallTrap.kt create mode 100644 game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt create mode 100644 game/src/test/kotlin/content/skill/hunter/DeadfallTest.kt diff --git a/data/area/kandarin/feldip_hills/feldip_hills.npc-spawns.toml b/data/area/kandarin/feldip_hills/feldip_hills.npc-spawns.toml index c5b27b7ad9..6e4df04ab2 100644 --- a/data/area/kandarin/feldip_hills/feldip_hills.npc-spawns.toml +++ b/data/area/kandarin/feldip_hills/feldip_hills.npc-spawns.toml @@ -159,21 +159,21 @@ spawns = [ { id = "black_warlock_feldip_hills", x = 2558, y = 2914 }, { id = "black_warlock_feldip_hills", x = 2563, y = 2920 }, { id = "black_warlock_feldip_hills", x = 2566, y = 2886 }, - { id = "barb_tailed_kebbit_feldip_hills", x = 2565, y = 2903 }, - { id = "barb_tailed_kebbit_feldip_hills", x = 2567, y = 2903 }, - { id = "barb_tailed_kebbit_feldip_hills", x = 2572, y = 2929 }, - { id = "barb_tailed_kebbit_feldip_hills", x = 2572, y = 2931 }, - { id = "barb_tailed_kebbit_feldip_hills", x = 2573, y = 2896 }, - { id = "barb_tailed_kebbit_feldip_hills", x = 2573, y = 2898 }, - { id = "barb_tailed_kebbit_feldip_hills", x = 2574, y = 2909 }, - { id = "barb_tailed_kebbit_feldip_hills", x = 2574, y = 2911 }, - { id = "barb_tailed_kebbit_feldip_hills", x = 2575, y = 2916 }, - { id = "barb_tailed_kebbit_feldip_hills", x = 2575, y = 2926 }, - { id = "barb_tailed_kebbit_feldip_hills", x = 2577, y = 2926 }, - { id = "barb_tailed_kebbit_feldip_hills", x = 2581, y = 2884 }, - { id = "barb_tailed_kebbit_feldip_hills", x = 2581, y = 2886 }, - { id = "barb_tailed_kebbit_feldip_hills", x = 2584, y = 2912 }, - { id = "barb_tailed_kebbit_feldip_hills", x = 2584, y = 2914 }, + { id = "barb_tailed_kebbit", x = 2565, y = 2903 }, + { id = "barb_tailed_kebbit", x = 2567, y = 2903 }, + { id = "barb_tailed_kebbit", x = 2572, y = 2929 }, + { id = "barb_tailed_kebbit", x = 2572, y = 2931 }, + { id = "barb_tailed_kebbit", x = 2573, y = 2896 }, + { id = "barb_tailed_kebbit", x = 2573, y = 2898 }, + { id = "barb_tailed_kebbit", x = 2574, y = 2909 }, + { id = "barb_tailed_kebbit", x = 2574, y = 2911 }, + { id = "barb_tailed_kebbit", x = 2575, y = 2916 }, + { id = "barb_tailed_kebbit", x = 2575, y = 2926 }, + { id = "barb_tailed_kebbit", x = 2577, y = 2926 }, + { id = "barb_tailed_kebbit", x = 2581, y = 2884 }, + { id = "barb_tailed_kebbit", x = 2581, y = 2886 }, + { id = "barb_tailed_kebbit", x = 2584, y = 2912 }, + { id = "barb_tailed_kebbit", x = 2584, y = 2914 }, { id = "spined_larupia", x = 2544, y = 2910, members = true }, { id = "spined_larupia", x = 2550, y = 2904, members = true }, { id = "spined_larupia", x = 2556, y = 2895, members = true }, diff --git a/data/area/kandarin/feldip_hills/feldip_hills.npcs.toml b/data/area/kandarin/feldip_hills/feldip_hills.npcs.toml index 2cfdabb789..9e431d7379 100644 --- a/data/area/kandarin/feldip_hills/feldip_hills.npcs.toml +++ b/data/area/kandarin/feldip_hills/feldip_hills.npcs.toml @@ -55,7 +55,7 @@ respawn_delay = 10 collision = "sky" examine = "A black warlock. The air seems to distort wherever it passes." -[barb_tailed_kebbit_feldip_hills] +[barb_tailed_kebbit] id = 5088 examine = "It uses its tail to hunt and skewer fish." diff --git a/data/entity/npc/hunt_modes.toml b/data/entity/npc/hunt_modes.toml index 2e5d655560..8256e0b795 100644 --- a/data/entity/npc/hunt_modes.toml +++ b/data/entity/npc/hunt_modes.toml @@ -148,3 +148,17 @@ id = "swamp_lizard,orange_salamander,red_salamander,black_salamander" check_visual = "line_of_sight" check_not_busy = true find_keep_hunting = true + +[box_trap] +type = "npc" +id = "chinchompa,carnivorous_chinchompa,ferret,pawya,grenwall" +check_visual = "line_of_sight" +check_not_busy = true +find_keep_hunting = true + +[deadfall] +type = "npc" +id = "wild_kebbit,barb_tailed_kebbit,prickly_kebbit,sabre_toothed_kebbit" +check_visual = "line_of_sight" +check_not_busy = true +find_keep_hunting = true diff --git a/data/skill/hunter/hunter.anims.toml b/data/skill/hunter/hunter.anims.toml index 01d91822df..c466e59648 100644 --- a/data/skill/hunter/hunter.anims.toml +++ b/data/skill/hunter/hunter.anims.toml @@ -35,11 +35,32 @@ id = 5172 [chinchompa_catch] id = 5184 +[chinchompa_fail] +id = 5185 + +[ferret_catch] +id = 5191 + +[ferret_fail] +id = 5192 + +[pawya_catch] +id = 8615 + +[pawya_fail] +id = 8611 + +[grenwall_catch] +id = 8602 + +[grenwall_fail] +id = 8602 + [kebbit_catch] -id = 5208 +id = 5275 [kebbit_fail] -id = 5208 +id = 5277 [net_catch] id = 827 diff --git a/data/skill/hunter/hunter.npcs.toml b/data/skill/hunter/hunter.npcs.toml index c4768ed732..67a452f613 100644 --- a/data/skill/hunter/hunter.npcs.toml +++ b/data/skill/hunter/hunter.npcs.toml @@ -8,7 +8,7 @@ id = 5071 [hunting_box_trap_npc] id = 5077 -hunt_mode = "hunter_trap" +hunt_mode = "box_trap" hunt_range = 2 [hunting_box_trap_npc_off] @@ -16,7 +16,7 @@ id = 5078 [hunting_deadfall_trap_npc] id = 5090 -hunt_mode = "hunter_trap" +hunt_mode = "deadfall" hunt_range = 3 [hunting_deadfall_trap_npc_off] @@ -48,7 +48,7 @@ id = 5119 [afr_hunting_deadfall_trap_npc] # As a first resort id = 7037 -hunt_mode = "hunter_trap" +hunt_mode = "deadfall" hunt_range = 3 [afr_hunting_deadfall_trap_npc_off] diff --git a/data/skill/hunter/hunter.objs.toml b/data/skill/hunter/hunter.objs.toml index fe1f3e4d73..bd325fef18 100644 --- a/data/skill/hunter/hunter.objs.toml +++ b/data/skill/hunter/hunter.objs.toml @@ -128,10 +128,16 @@ id = 19178 id = 28930 # Box trap — triggered states -[box_trap_grey_chinchompa] +[box_trap_catching] +id = 19188 + +[box_trap_ferret] +id = 19190 + +[box_trap_chinchompa] id = 28557 -[box_trap_red_chinchompa] +[box_trap_carnivorous_chinchompa] id = 28558 [box_trap_pawya] @@ -140,10 +146,32 @@ id = 28567 [box_trap_grenwall] id = 28906 -# Boulder trap — triggered state -[boulder_trap_barb_tailed_kebbit] +# Boulder trap — falling boulder states (per creature, boulder mid-crush) +[boulder_trap_wild_kebbit_catching] +id = 19213 + +[boulder_trap_barb_tailed_kebbit_catching] +id = 19211 + +[boulder_trap_prickly_kebbit_catching] id = 19207 +[boulder_trap_sabre_toothed_kebbit_catching] +id = 19209 + +# Boulder trap — caught states (boulder dropped on creature) +[boulder_trap_wild_kebbit] +id = 19215 + +[boulder_trap_sabre_toothed_kebbit] +id = 19216 + +[boulder_trap_barb_tailed_kebbit] +id = 19217 + +[boulder_trap_prickly_kebbit] +id = 19218 + [pitfall_sabre_toothed_kyatt_0] id = 19253 diff --git a/data/skill/hunter/hunter.sounds.toml b/data/skill/hunter/hunter.sounds.toml index 2c426504f0..4a84be2413 100644 --- a/data/skill/hunter/hunter.sounds.toml +++ b/data/skill/hunter/hunter.sounds.toml @@ -45,3 +45,15 @@ id = 666 [big_cat_death] id = 667 + +[lay_box_trap] +id = 2636 + +[box_trap_catch] +id = 2627 + +[set_deadfall] +id = 2645 + +[deadfall_catch] +id = 2631 diff --git a/data/skill/hunter/hunter.tables.toml b/data/skill/hunter/hunter.tables.toml index 3fa68fbe8b..05198e8adf 100644 --- a/data/skill/hunter/hunter.tables.toml +++ b/data/skill/hunter/hunter.tables.toml @@ -33,11 +33,7 @@ npc = "hunting_box_trap_npc" [.boulder_trap] level = 23 -#trap = "boulder_trap_setup" -#fail = "boulder_trap_fail" -#setup_anim = "lay_trap" -#take_down_anim = "lay_trap" -#requires = ["knife"] +requires = ["knife"] items = ["logs"] max = 1 npc = "hunting_deadfall_trap_npc" @@ -90,6 +86,9 @@ level = "int" # minimum hunter level to lure xp = "int" # xp * 0.1 (stored as tenths for int precision) loot = "list" # items awarded on catch chance = "range" +bait = "item" # bait required before the creature will approach the trap +catch_anim = "anim" # animation the creature plays on a successful catch +fail_anim = "anim" # animation the creature plays on a failed catch [.crimson_swift] trap = "bird_snare" @@ -139,30 +138,75 @@ xp = 1670 loot = ["wimpy_feather", "raw_bird_meat", "bones"] chance = [65, 350] -[.grey_chinchompa] +[.ferret] trap = "box_trap" -caught_obj = "box_trap_grey_chinchompa" +caught_obj = "box_trap_ferret" +level = 27 +xp = 1152 +catch_anim = "ferret_catch" +fail_anim = "ferret_fail" +loot = ["box_trap", "ferret"] +chance = [41, 255] + +[.chinchompa] +trap = "box_trap" +caught_obj = "box_trap_chinchompa" level = 53 xp = 1984 -#catch_anim = "chinchompa_catch" +catch_anim = "chinchompa_catch" +fail_anim = "chinchompa_fail" loot = ["box_trap", "chinchompa"] +chance = [145, 268] -[.red_chinchompa] +[.carnivorous_chinchompa] trap = "box_trap" -caught_obj = "box_trap_red_chinchompa" +caught_obj = "box_trap_carnivorous_chinchompa" level = 63 xp = 2650 -#catch_anim = "chinchompa_catch" +catch_anim = "chinchompa_catch" +fail_anim = "chinchompa_fail" loot = ["box_trap", "red_chinchompa"] +chance = [115, 228] + +[.wild_kebbit] +trap = "boulder_trap" +caught_obj = "boulder_trap_wild_kebbit" +level = 23 +xp = 1280 +catch_anim = "kebbit_catch" +fail_anim = "kebbit_fail" +loot = ["bones", "kebbit_claws"] +chance = [105, 354] [.barb_tailed_kebbit] trap = "boulder_trap" caught_obj = "boulder_trap_barb_tailed_kebbit" -level = 23 +level = 33 xp = 1680 -#catch_anim = "kebbit_catch" -#fail_anim = "kebbit_fail" +catch_anim = "kebbit_catch" +fail_anim = "kebbit_fail" loot = ["bones", "barb_tail_harpoon"] +chance = [110, 326] + +[.prickly_kebbit] +trap = "boulder_trap" +caught_obj = "boulder_trap_prickly_kebbit" +level = 37 +xp = 2040 +catch_anim = "kebbit_catch" +fail_anim = "kebbit_fail" +loot = ["bones", "kebbit_spike"] +chance = [115, 298] + +[.sabre_toothed_kebbit] +trap = "boulder_trap" +caught_obj = "boulder_trap_sabre_toothed_kebbit" +level = 51 +xp = 2000 +catch_anim = "kebbit_catch" +fail_anim = "kebbit_fail" +loot = ["bones", "kebbit_teeth"] +chance = [120, 268] [.swamp_lizard] trap = "swamp_net" @@ -197,15 +241,21 @@ trap = "box_trap" caught_obj = "box_trap_pawya" level = 66 xp = 4000 -#catch_anim = "chinchompa_catch" +catch_anim = "pawya_catch" +fail_anim = "pawya_fail" loot = ["bones", "raw_pawya_meat"] +chance = [100, 215] +bait = "papaya_fruit" [.grenwall] trap = "box_trap" caught_obj = "box_trap_grenwall" level = 77 xp = 11000 -#catch_anim = "chinchompa_catch" +catch_anim = "grenwall_catch" +fail_anim = "grenwall_fail" +chance = [15, 130] +bait = "raw_pawya_meat" loot = ["grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes"] [.rabbit] diff --git a/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt b/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt new file mode 100644 index 0000000000..b0d162c7ae --- /dev/null +++ b/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt @@ -0,0 +1,229 @@ +package content.skill.hunter + +import content.entity.effect.transform +import content.entity.player.inv.item.drop +import content.quest.questCompleted +import net.pearx.kasechange.toLowerSpaceCase +import world.gregs.voidps.cache.definition.Params +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.chat.plural +import world.gregs.voidps.engine.data.config.RowDefinition +import world.gregs.voidps.engine.data.definition.Areas +import world.gregs.voidps.engine.data.definition.Rows +import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.areaSound +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.character.player.chat.ChatType +import world.gregs.voidps.engine.entity.character.player.chat.noInterest +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.exp.exp +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has +import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.entity.item.floor.FloorItem +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.entity.obj.* +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove + +class BoxTrap : Script { + init { + itemOption("Lay", "box_trap") { + layTrap(null) + } + + floorItemOperate("Lay") { (item) -> + if (item.id == "box_trap") { + layTrap(item) + } + } + + objectOperate("Dismantle", "box_trap,box_trap_fail") { (target) -> + dismantleTrap(target, null) + } + + objectOperate("Check", "box_trap_ferret,box_trap_chinchompa,box_trap_carnivorous_chinchompa,box_trap_pawya,box_trap_grenwall") { (target) -> + dismantleTrap(target, creature = Rows.get("creatures.${target.id.removePrefix("box_trap_")}")) + } + + objectOperate("Investigate", "box_trap") { (target) -> + val id = Tables.npc("traps.box_trap.npc") + val npc = NPCs.find(target.tile, id) + Traps.investigate(this, npc) + } + + itemOnObjectOperate("*", "box_trap") { + when { + it.item.id == "unlit_torch" -> message("I should light the torch before using it to smoke the trap.") + it.item.id == "torch_lit" -> Traps.smoke(this, "box_trap", it.target.tile) + it.item.id == "papaya_fruit" || it.item.id == "raw_pawya_meat" -> bait(it.item, it.target) + it.item.def.contains(Params.HEALS) -> message("I don't think I'd catch much using that as bait.") + else -> noInterest() + } + } + + huntNPC("box_trap") { target -> + if (transform.endsWith("_off")) { + return@huntNPC + } + val creature = Rows.getOrNull("creatures.${target.id}") ?: return@huntNPC + val account: String = get("owner") ?: return@huntNPC + val player = Players.findByAccount(account) ?: return@huntNPC + if (!player.has(Skill.Hunter, creature.int("level"))) { + return@huntNPC + } + if (target.id == "ferret" && !player.questCompleted("eagles_peak")) { + return@huntNPC + } + val required = creature.itemOrNull("bait") + if (required != null) { + val bait: String? = get("bait") + if (bait != required) { + return@huntNPC + } + } + if (tile.distanceTo(target.tile) > 2) { + return@huntNPC + } + transform("${id}_off") + val chance = Traps.chance(this, creature) + val success = Level.success(player.levels.get(Skill.Hunter), chance) + target.walkToDelay(tile) + target.walkOverDelay(tile) + despawn(100) + val trap = GameObjects.getLayer(tile, ObjectLayer.GROUND) ?: return@huntNPC + val catching = trap.replace("box_trap_catching") + target.anim(if (success) creature.anim("catch_anim") else creature.anim("fail_anim")) + target.delay(1) + if (!success) { + catching.replace("box_trap_fail") + return@huntNPC + } + target.levels.set(Skill.Constitution, 0) + catching.replace(Tables.obj("creatures.${target.id}.caught_obj")) + player.message("Something has been caught in your trap!") + areaSound("box_trap_catch", tile) + } + + npcDespawn("hunting_box_trap_npc") { + val player = owner ?: return@npcDespawn + val trap = GameObjects.getLayer(tile, ObjectLayer.GROUND) ?: return@npcDespawn + player.dec("trap_count") + GameObjects.remove(trap) + val drop = if (lifecycle == 0) { + player.message("The box trap that you laid has fallen over.") + true + } else { + player["logged_out", false] + } + if (drop) { + player.drop(trap.tile, "box_trap") + val bait: String? = get("bait") + if (bait != null) { + player.drop(trap.tile, bait) + } + } + } + } + + private fun Player.bait(item: Item, trap: GameObject) { + val npc = NPCs.find(trap.tile, "hunting_box_trap_npc") + if (npc["owner", ""] != accountName) { + message("This isn't your trap.") + return + } + if (npc.contains("bait")) { + message("You've already baited this trap.") + return + } + if (!inventory.remove(item.id)) { + return + } + anim("lay_trap_small") + sound("drop_item", delay = 25) + npc["bait"] = item.id + message("You bait the trap with ${item.id.toLowerSpaceCase()}.") + } + + private suspend fun Player.layTrap(floorItem: FloorItem?) { + val trap = Rows.getOrNull("traps.box_trap") ?: return + val level = levels.get(Skill.Hunter) + if (!has(Skill.Hunter, trap.int("level"), message = true)) { + return + } + if (Areas.get(tile.zone).any { it.tags.contains("bank") } || GameObjects.getLayer(tile, ObjectLayer.GROUND) != null) { + message("You can't lay a trap here.", ChatType.Filter) + return + } + val max = Traps.max(level, trap.int("max")) + val trapCount = get("trap_count", 0) + if (trapCount >= max) { + message("You may setup only $max ${"trap".plural(max)} at a time at your Hunter level.") + return + } + arriveDelay() + message("You begin setting up ${if (max == 1) "the" else "a"} trap.", ChatType.Filter) + anim("lay_trap") + sound("lay_box_trap") + delay(3) + if (floorItem != null) { + FloorItems.remove(floorItem) + } else { + inventory.remove("box_trap") + } + inc("trap_count") + NPCs.add("hunting_box_trap_npc", tile, ticks = 100, owner = this) + val obj = GameObjects.add("box_trap", tile) + stepAway(obj) + } + + private suspend fun Player.dismantleTrap(target: GameObject, creature: RowDefinition?) { + val npc = NPCs.findOrNull(target.tile, "hunting_box_trap_npc") ?: return + if (npc["owner", ""] != accountName) { + message("This is not your trap.") + return + } + val loot = creature?.itemList("loot") ?: emptyList() + val bait: String? = npc["bait"] + val items = mutableListOf() + if (loot.isEmpty()) { + items.add("box_trap") + if (bait != null) { + items.add(bait) + } + } + val size = items.size + loot.size + if (inventory.spaces < size) { + val slots = size - inventory.spaces + message("You don't have enough inventory space. You need $slots more free ${"slot".plural(slots)}.") + return + } + anim("take_trap") + sound("trap_dismantle", delay = 25) + delay(2) + collapse(npc, target) + for (item in items) { + inventory.add(item) + } + message("You dismantle the trap.", ChatType.Filter) + if (creature != null) { + for (item in loot) { + inventory.add(item) + } + exp(Skill.Hunter, creature.int("xp") / 10.0) + message("You've caught a ${creature.rowId.toLowerSpaceCase()}!", ChatType.Filter) + } + } + + private fun Player.collapse(npc: NPC, target: GameObject) { + dec("trap_count") + NPCs.remove(npc) + GameObjects.remove(target) + } +} diff --git a/game/src/main/kotlin/content/skill/hunter/DeadfallTrap.kt b/game/src/main/kotlin/content/skill/hunter/DeadfallTrap.kt new file mode 100644 index 0000000000..c6e4c493bd --- /dev/null +++ b/game/src/main/kotlin/content/skill/hunter/DeadfallTrap.kt @@ -0,0 +1,191 @@ +package content.skill.hunter + +import content.entity.effect.transform +import content.entity.player.inv.item.drop +import net.pearx.kasechange.toLowerSpaceCase +import world.gregs.voidps.cache.definition.Params +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.chat.plural +import world.gregs.voidps.engine.data.config.RowDefinition +import world.gregs.voidps.engine.data.definition.Rows +import world.gregs.voidps.engine.data.definition.Tables +import world.gregs.voidps.engine.entity.character.areaSound +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.character.player.chat.ChatType +import world.gregs.voidps.engine.entity.character.player.chat.noInterest +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.exp.exp +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has +import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.engine.entity.obj.* +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove + +class DeadfallTrap : Script { + init { + objectOperate("Set-trap", "boulder_trap") { (target) -> + setTrap(target) + } + + objectOperate("Dismantle", "boulder_trap_setup") { (target) -> + dismantleTrap(target, null) + } + + objectOperate("Check", "boulder_trap_wild_kebbit,boulder_trap_barb_tailed_kebbit,boulder_trap_prickly_kebbit,boulder_trap_sabre_toothed_kebbit") { (target) -> + dismantleTrap(target, creature = Rows.get("creatures.${target.id.removePrefix("boulder_trap_")}")) + } + + objectOperate("Investigate", "boulder_trap_setup") { (target) -> + val npc = NPCs.find(target.tile, "hunting_deadfall_trap_npc") + Traps.investigate(this, npc) + } + + itemOnObjectOperate("*", "boulder_trap_setup") { + when { + it.item.id == "unlit_torch" -> message("I should light the torch before using it to smoke the trap.") + it.item.id == "torch_lit" -> Traps.smoke(this, "boulder_trap", it.target.tile) + it.item.def.contains(Params.HEALS) -> message("I don't think I'd catch much using that as bait.") + else -> noInterest() + } + } + + huntNPC("deadfall") { target -> + if (transform.endsWith("_off")) { + return@huntNPC + } + val creature = Rows.getOrNull("creatures.${target.id}") ?: return@huntNPC + val account: String = get("owner") ?: return@huntNPC + val player = Players.findByAccount(account) ?: return@huntNPC + if (!player.has(Skill.Hunter, creature.int("level"))) { + return@huntNPC + } + if (tile.distanceTo(target.tile) > 3) { + return@huntNPC + } + transform("${id}_off") + val chance = Traps.chance(this, creature) + val success = Level.success(player.levels.get(Skill.Hunter), chance) + target.walkToDelay(tile) + despawn(100) + val trap = GameObjects.getLayer(tile, ObjectLayer.GROUND) ?: return@huntNPC + areaSound("deadfall_catch", tile) + if (!success) { + target.anim(creature.anim("fail_anim")) + val failing = trap.replace("boulder_trap_fail") + target.delay(2) + collapse(player, this, failing) + player.message("Your deadfall trap has collapsed.") + return@huntNPC + } + target.anim(creature.anim("catch_anim")) + val catching = trap.replace("${Tables.obj("creatures.${target.id}.caught_obj")}_catching") + target.delay(2) + target.levels.set(Skill.Constitution, 0) + catching.replace(Tables.obj("creatures.${target.id}.caught_obj")) + player.message("Something has been caught in your trap!") + } + + npcDespawn("hunting_deadfall_trap_npc") { + val player = owner ?: return@npcDespawn + val trap = GameObjects.getLayer(tile, ObjectLayer.GROUND) ?: return@npcDespawn + if (trap.id == "boulder_trap") { + return@npcDespawn + } + player.dec("trap_count") + player.dec("deadfall_count") + GameObjects.remove(trap) + val drop = if (lifecycle == 0) { + player.message("The deadfall trap that you constructed has collapsed.") + true + } else { + player["logged_out", false] + } + if (drop) { + player.drop(trap.tile, "logs") + } + } + } + + private suspend fun Player.setTrap(target: GameObject) { + val trap = Rows.getOrNull("traps.boulder_trap") ?: return + val level = levels.get(Skill.Hunter) + if (!has(Skill.Hunter, trap.int("level"), message = true)) { + return + } + for (item in trap.itemList("requires")) { + if (!inventory.contains(item)) { + message("You need a ${item.toLowerSpaceCase()} in order to set a deadfall trap.") + return + } + } + if (!inventory.contains("logs")) { + message("You need some logs in order to set a deadfall trap.") + return + } + if (get("deadfall_count", 0) >= trap.int("max")) { + message("You can only set up one deadfall trap at a time.") + return + } + val max = Traps.max(level, 5) + val trapCount = get("trap_count", 0) + if (trapCount >= max) { + message("You may setup only $max ${"trap".plural(max)} at a time at your Hunter level.") + return + } + arriveDelay() + message("You begin setting up the trap.", ChatType.Filter) + anim("lay_trap") + sound("set_deadfall") + delay(3) + inventory.remove("logs") + inc("trap_count") + inc("deadfall_count") + NPCs.add("hunting_deadfall_trap_npc", target.tile, ticks = 100, owner = this) + target.replace("boulder_trap_setup") + stepAway(target) + } + + private suspend fun Player.dismantleTrap(target: GameObject, creature: RowDefinition?) { + val npc = NPCs.findOrNull(target.tile, "hunting_deadfall_trap_npc") ?: return + if (npc["owner", ""] != accountName) { + message("This is not your trap.") + return + } + val loot = creature?.itemList("loot") ?: emptyList() + val items = if (loot.isEmpty()) listOf("logs") else emptyList() + val size = items.size + loot.size + if (inventory.spaces < size) { + val slots = size - inventory.spaces + message("You don't have enough inventory space. You need $slots more free ${"slot".plural(slots)}.") + return + } + anim("take_trap") + sound("take_branches", delay = 25) + delay(2) + collapse(this, npc, target) + for (item in items) { + inventory.add(item) + } + message("You dismantle the trap.", ChatType.Filter) + if (creature != null) { + for (item in loot) { + inventory.add(item) + } + exp(Skill.Hunter, creature.int("xp") / 10.0) + message("You've caught a ${creature.rowId.toLowerSpaceCase()}!", ChatType.Filter) + } + } + + private fun collapse(player: Player, npc: NPC, target: GameObject) { + player.dec("trap_count") + player.dec("deadfall_count") + NPCs.remove(npc) + GameObjects.remove(target) + } +} diff --git a/game/src/main/kotlin/content/skill/hunter/NetTrap.kt b/game/src/main/kotlin/content/skill/hunter/NetTrap.kt index 76cda5227a..46e6f33320 100644 --- a/game/src/main/kotlin/content/skill/hunter/NetTrap.kt +++ b/game/src/main/kotlin/content/skill/hunter/NetTrap.kt @@ -13,7 +13,6 @@ import world.gregs.voidps.engine.data.definition.Areas import world.gregs.voidps.engine.data.definition.Rows import world.gregs.voidps.engine.data.definition.Tables import world.gregs.voidps.engine.entity.character.areaSound -import world.gregs.voidps.engine.entity.character.npc.NPC import world.gregs.voidps.engine.entity.character.npc.NPCs import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.Players @@ -63,12 +62,12 @@ class NetTrap : Script { objectOperate("Investigate", "net") { (target) -> val npc = NPCs.find(target.tile, "hunting_sapling_trap_npc") - investigate(npc) + Traps.investigate(this, npc) } objectOperate("Investigate", "*_net_setup") { (target) -> val npc = NPCs.find(target.tile.add(target.direction()), "hunting_sapling_trap_npc") - investigate(npc) + Traps.investigate(this, npc) } itemOnObjectOperate("*", "net,*_net_setup") { (target, item) -> @@ -162,20 +161,6 @@ class NetTrap : Script { message("You place a blob of tar on the net as bait.") } - private fun Player.investigate(npc: NPC) { - val bait: String? = npc["bait"] - if (bait != null) { - message("This trap has been baited with ${bait.toLowerSpaceCase()}.") - } else { - message("This trap has been set without any bait.") - } - if (npc["smoked", false]) { - message("The scent on this trap has been masked.") - } else { - message("Your scent lingers around this trap.") - } - } - private suspend fun Player.layTrap(trapId: String, obj: GameObject) { val trap = Rows.getOrNull("traps.$trapId") ?: return val level = levels.get(Skill.Hunter) diff --git a/game/src/main/kotlin/content/skill/hunter/Traps.kt b/game/src/main/kotlin/content/skill/hunter/Traps.kt index 0e669743a3..36e882e6c4 100644 --- a/game/src/main/kotlin/content/skill/hunter/Traps.kt +++ b/game/src/main/kotlin/content/skill/hunter/Traps.kt @@ -1,5 +1,6 @@ package content.skill.hunter +import net.pearx.kasechange.toLowerSpaceCase import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.data.config.RowDefinition import world.gregs.voidps.engine.data.definition.Tables @@ -28,6 +29,20 @@ object Traps { return (chance.first + add)..(chance.last + add) } + fun investigate(player: Player, npc: NPC) { + val bait: String? = npc["bait"] + if (bait != null) { + player.message("This trap has been baited with ${bait.toLowerSpaceCase()}.") + } else { + player.message("This trap has been set without any bait.") + } + if (npc["smoked", false]) { + player.message("The scent on this trap has been masked.") + } else { + player.message("Your scent lingers around this trap.") + } + } + fun smoke(player: Player, trap: String, tile: Tile) { val id = Tables.npc("traps.$trap.npc") val npc = NPCs.find(tile, id) diff --git a/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt b/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt new file mode 100644 index 0000000000..f2e0ef14ad --- /dev/null +++ b/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt @@ -0,0 +1,206 @@ +package content.skill.hunter + +import FakeRandom +import WorldTest +import containsMessage +import itemOnObject +import itemOption +import objectOption +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNotNull +import org.junit.jupiter.api.assertNull +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.setRandom +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class BoxTrapTest : WorldTest() { + @ParameterizedTest + @ValueSource(strings = ["chinchompa", "carnivorous_chinchompa"]) + fun `Catch a chinchompa`(id: String) { + val player = createPlayer() + val start = player.tile + player.inventory.add("box_trap") + player.levels.set(Skill.Hunter, 99) + + player.itemOption("Lay", "box_trap") + tick(3) + assertTrue(GameObjects.at(start).any { it.id == "box_trap" }) + createNPC(id, player.tile.addY(2)) + + tick(22) + + val trap = GameObjects.at(start).firstOrNull { it.id == "box_trap_$id" } + assertNotNull(trap) + + player.objectOption(trap, "Check") + tick(3) + assertEquals(1, player.inventory.count("box_trap")) + assertNotEquals(0.0, player.experience.get(Skill.Hunter)) + } + + @ParameterizedTest + @ValueSource(strings = ["pawya", "grenwall"]) + fun `Catch with bait`(id: String) { + val bait = if (id == "pawya") "papaya_fruit" else "raw_pawya_meat" + val player = createPlayer() + val start = player.tile + player.inventory.add("box_trap") + player.inventory.add(bait) + player.levels.set(Skill.Hunter, 99) + + player.itemOption("Lay", "box_trap") + tick(3) + val laid = GameObjects.at(start).firstOrNull { it.id == "box_trap" } + assertNotNull(laid) + player.itemOnObject(laid, player.inventory.indexOf(bait)) + tick(2) + assertEquals(0, player.inventory.count(bait)) + createNPC(id, player.tile.addY(2)) + + tick(22) + + val trap = GameObjects.at(start).firstOrNull { it.id == "box_trap_$id" } + assertNotNull(trap) + + player.objectOption(trap, "Check") + tick(3) + assertNotEquals(0.0, player.experience.get(Skill.Hunter)) + if (id == "pawya") { + assertEquals(1, player.inventory.count("raw_pawya_meat")) + } else { + assertEquals(18, player.inventory.count("grenwall_spikes")) + } + } + + @Test + fun `Grenwall ignores unbaited trap`() { + val player = createPlayer() + val start = player.tile + player.inventory.add("box_trap") + player.levels.set(Skill.Hunter, 99) + + player.itemOption("Lay", "box_trap") + tick(3) + assertTrue(GameObjects.at(start).any { it.id == "box_trap" }) + createNPC("grenwall", player.tile.addY(2)) + + tick(22) + + assertNull(GameObjects.at(start).firstOrNull { it.id == "box_trap_grenwall" }) + assertTrue(GameObjects.at(start).any { it.id == "box_trap" }) + } + + @Test + fun `Ferret requires Eagles' Peak`() { + val player = createPlayer() + val start = player.tile + player.inventory.add("box_trap") + player.levels.set(Skill.Hunter, 99) + + player.itemOption("Lay", "box_trap") + tick(3) + createNPC("ferret", player.tile.addY(2)) + + tick(22) + + assertNull(GameObjects.at(start).firstOrNull { it.id == "box_trap_ferret" }) + } + + @Test + fun `Catch a ferret after Eagles' Peak`() { + val player = createPlayer() + val start = player.tile + player["eagles_peak"] = "completed" + player.inventory.add("box_trap") + player.levels.set(Skill.Hunter, 99) + + player.itemOption("Lay", "box_trap") + tick(3) + createNPC("ferret", player.tile.addY(2)) + + tick(22) + + val trap = GameObjects.at(start).firstOrNull { it.id == "box_trap_ferret" } + assertNotNull(trap) + + player.objectOption(trap, "Check") + tick(3) + assertEquals(1, player.inventory.count("box_trap")) + assertEquals(1, player.inventory.count("ferret")) + assertNotEquals(0.0, player.experience.get(Skill.Hunter)) + } + + @Test + fun `Can't lay without hunter level`() { + val player = createPlayer() + val start = player.tile + player.inventory.add("box_trap") + + player.itemOption("Lay", "box_trap") + tick(3) + assertFalse(GameObjects.at(start).any { it.id == "box_trap" }) + assertEquals(1, player.inventory.count("box_trap")) + } + + @Test + fun `Fail to catch`() { + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = if (until == 4) 0 else until - 1 + }) + val player = createPlayer() + val start = player.tile + player.inventory.add("box_trap") + player.levels.set(Skill.Hunter, 60) + + player.itemOption("Lay", "box_trap") + tick(3) + assertTrue(GameObjects.at(start).any { it.id == "box_trap" }) + createNPC("chinchompa", player.tile.addY(2)) + + tick(22) + + val trap = GameObjects.at(start).firstOrNull { it.id == "box_trap_fail" } + assertNotNull(trap) + + player.objectOption(trap, "Dismantle") + tick(3) + assertEquals(1, player.inventory.count("box_trap")) + assertEquals(0.0, player.experience.get(Skill.Hunter)) + } + + @Test + fun `Can't place more than two traps at level 27`() { + val player = createPlayer() + player.levels.set(Skill.Hunter, 27) + var start = player.tile + player.inventory.add("box_trap", 3) + + player.itemOption("Lay", "box_trap") + tick(3) + assertTrue(GameObjects.at(start).any { it.id == "box_trap" }) + + player.tele(start.x + 5, start.y) + tick() + start = player.tile + player.itemOption("Lay", "box_trap") + tick(3) + assertTrue(GameObjects.at(start).any { it.id == "box_trap" }) + + player.tele(start.x + 5, start.y) + tick() + start = player.tile + player.itemOption("Lay", "box_trap") + tick(3) + assertFalse(GameObjects.at(start).any { it.id == "box_trap" }) + assertTrue(player.containsMessage("only 2 traps at a time")) + } +} diff --git a/game/src/test/kotlin/content/skill/hunter/DeadfallTest.kt b/game/src/test/kotlin/content/skill/hunter/DeadfallTest.kt new file mode 100644 index 0000000000..ff3c39f0f9 --- /dev/null +++ b/game/src/test/kotlin/content/skill/hunter/DeadfallTest.kt @@ -0,0 +1,145 @@ +package content.skill.hunter + +import FakeRandom +import WorldTest +import containsMessage +import objectOption +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNotNull +import org.junit.jupiter.api.assertNull +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.setRandom +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class DeadfallTest : WorldTest() { + @ParameterizedTest + @CsvSource( + "wild_kebbit, kebbit_claws", + "barb_tailed_kebbit, barb_tail_harpoon", + "prickly_kebbit, kebbit_spike", + "sabre_toothed_kebbit, kebbit_teeth", + ) + fun `Catch a kebbit`(id: String, loot: String) { + val player = createPlayer(Tile(2557, 2895)) + val boulder = createObject("boulder_trap", player.tile.addX(1)) + player.inventory.add("knife") + player.inventory.add("logs") + player.levels.set(Skill.Hunter, 99) + + player.objectOption(boulder, "Set-trap") + tick(10) + assertTrue(GameObjects.at(boulder.tile).any { it.id == "boulder_trap_setup" }) + assertEquals(0, player.inventory.count("logs")) + assertEquals(1, player.inventory.count("knife")) + createNPC(id, boulder.tile.addY(2)) + + tick(22) + + val trap = GameObjects.at(boulder.tile).firstOrNull { it.id == "boulder_trap_$id" } + assertNotNull(trap) + + player.objectOption(trap, "Check") + tick(10) + assertEquals(1, player.inventory.count("bones")) + assertEquals(1, player.inventory.count(loot)) + assertEquals(0, player.inventory.count("logs")) + assertNotEquals(0.0, player.experience.get(Skill.Hunter)) + assertFalse(GameObjects.at(boulder.tile).any { it.id.startsWith("boulder_trap_") }) + } + + @Test + fun `Can't set trap without a knife`() { + val player = createPlayer(Tile(2557, 2895)) + val boulder = createObject("boulder_trap", player.tile.addX(1)) + player.inventory.add("logs") + player.levels.set(Skill.Hunter, 99) + + player.objectOption(boulder, "Set-trap") + tick(10) + assertFalse(GameObjects.at(boulder.tile).any { it.id == "boulder_trap_setup" }) + assertTrue(player.containsMessage("You need a knife")) + } + + @Test + fun `Can't set trap without hunter level`() { + val player = createPlayer(Tile(2557, 2895)) + val boulder = createObject("boulder_trap", player.tile.addX(1)) + player.inventory.add("knife") + player.inventory.add("logs") + + player.objectOption(boulder, "Set-trap") + tick(10) + assertFalse(GameObjects.at(boulder.tile).any { it.id == "boulder_trap_setup" }) + } + + @Test + fun `Only one deadfall at a time`() { + val player = createPlayer(Tile(2557, 2895)) + val boulder = createObject("boulder_trap", player.tile.addX(1)) + val second = createObject("boulder_trap", player.tile.addX(-2)) + player.inventory.add("knife") + player.inventory.add("logs", 2) + player.levels.set(Skill.Hunter, 99) + + player.objectOption(boulder, "Set-trap") + tick(10) + assertTrue(GameObjects.at(boulder.tile).any { it.id == "boulder_trap_setup" }) + + player.objectOption(second, "Set-trap") + tick(10) + assertFalse(GameObjects.at(second.tile).any { it.id == "boulder_trap_setup" }) + assertTrue(player.containsMessage("only set up one deadfall")) + } + + @Test + fun `Fail to catch collapses the trap`() { + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = if (until == 4) 0 else until - 1 + }) + val player = createPlayer(Tile(2557, 2895)) + val boulder = createObject("boulder_trap", player.tile.addX(1)) + player.inventory.add("knife") + player.inventory.add("logs") + player.levels.set(Skill.Hunter, 50) + + player.objectOption(boulder, "Set-trap") + tick(10) + assertTrue(GameObjects.at(boulder.tile).any { it.id == "boulder_trap_setup" }) + createNPC("wild_kebbit", boulder.tile.addY(2)) + + tick(22) + + assertNull(GameObjects.at(boulder.tile).firstOrNull { it.id == "boulder_trap_wild_kebbit" }) + assertNull(GameObjects.at(boulder.tile).firstOrNull { it.id == "boulder_trap_setup" }) + assertEquals(0, player.inventory.count("logs")) + assertEquals(0.0, player.experience.get(Skill.Hunter)) + } + + @Test + fun `Dismantle returns the logs`() { + val player = createPlayer(Tile(2557, 2895)) + val boulder = createObject("boulder_trap", player.tile.addX(1)) + player.inventory.add("knife") + player.inventory.add("logs") + player.levels.set(Skill.Hunter, 99) + + player.objectOption(boulder, "Set-trap") + tick(10) + val trap = GameObjects.at(boulder.tile).firstOrNull { it.id == "boulder_trap_setup" } + assertNotNull(trap) + + player.objectOption(trap, "Dismantle") + tick(10) + assertEquals(1, player.inventory.count("logs")) + assertFalse(GameObjects.at(boulder.tile).any { it.id.startsWith("boulder_trap_") }) + } +} From cb217363971f3bfcb43f1cc9ae878941bac541dc Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 17:55:44 -0700 Subject: [PATCH 2/4] Fix box trap not being returned when checking a caught creature --- data/skill/hunter/hunter.tables.toml | 6 +++--- game/src/main/kotlin/content/skill/hunter/BoxTrap.kt | 9 +++------ game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt | 1 + 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/data/skill/hunter/hunter.tables.toml b/data/skill/hunter/hunter.tables.toml index 05198e8adf..94c011f1bf 100644 --- a/data/skill/hunter/hunter.tables.toml +++ b/data/skill/hunter/hunter.tables.toml @@ -145,7 +145,7 @@ level = 27 xp = 1152 catch_anim = "ferret_catch" fail_anim = "ferret_fail" -loot = ["box_trap", "ferret"] +loot = ["ferret"] chance = [41, 255] [.chinchompa] @@ -155,7 +155,7 @@ level = 53 xp = 1984 catch_anim = "chinchompa_catch" fail_anim = "chinchompa_fail" -loot = ["box_trap", "chinchompa"] +loot = ["chinchompa"] chance = [145, 268] [.carnivorous_chinchompa] @@ -165,7 +165,7 @@ level = 63 xp = 2650 catch_anim = "chinchompa_catch" fail_anim = "chinchompa_fail" -loot = ["box_trap", "red_chinchompa"] +loot = ["red_chinchompa"] chance = [115, 228] [.wild_kebbit] diff --git a/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt b/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt index b0d162c7ae..70a9d2080c 100644 --- a/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt +++ b/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt @@ -191,12 +191,9 @@ class BoxTrap : Script { } val loot = creature?.itemList("loot") ?: emptyList() val bait: String? = npc["bait"] - val items = mutableListOf() - if (loot.isEmpty()) { - items.add("box_trap") - if (bait != null) { - items.add(bait) - } + val items = mutableListOf("box_trap") + if (loot.isEmpty() && bait != null) { + items.add(bait) } val size = items.size + loot.size if (inventory.spaces < size) { diff --git a/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt b/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt index f2e0ef14ad..185aeadfdd 100644 --- a/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt +++ b/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt @@ -73,6 +73,7 @@ class BoxTrapTest : WorldTest() { player.objectOption(trap, "Check") tick(3) + assertEquals(1, player.inventory.count("box_trap")) assertNotEquals(0.0, player.experience.get(Skill.Hunter)) if (id == "pawya") { assertEquals(1, player.inventory.count("raw_pawya_meat")) From afed247742005cf8ba6e888e64ed8af28bb962e8 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 17:21:17 -0700 Subject: [PATCH 3/4] Add imp catching with magic boxes --- data/entity/npc/hunt_modes.toml | 7 + data/skill/hunter/hunter.anims.toml | 6 + data/skill/hunter/hunter.npcs.toml | 2 +- data/skill/hunter/hunter.objs.toml | 12 ++ data/skill/hunter/hunter.tables.toml | 15 ++ .../kotlin/content/skill/hunter/MagicBox.kt | 195 ++++++++++++++++++ .../content/skill/hunter/MagicBoxTest.kt | 117 +++++++++++ 7 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 game/src/main/kotlin/content/skill/hunter/MagicBox.kt create mode 100644 game/src/test/kotlin/content/skill/hunter/MagicBoxTest.kt diff --git a/data/entity/npc/hunt_modes.toml b/data/entity/npc/hunt_modes.toml index 8256e0b795..3bb61c8a4a 100644 --- a/data/entity/npc/hunt_modes.toml +++ b/data/entity/npc/hunt_modes.toml @@ -162,3 +162,10 @@ id = "wild_kebbit,barb_tailed_kebbit,prickly_kebbit,sabre_toothed_kebbit" check_visual = "line_of_sight" check_not_busy = true find_keep_hunting = true + +[magic_box] +type = "npc" +id = "imp" +check_visual = "line_of_sight" +check_not_busy = true +find_keep_hunting = true diff --git a/data/skill/hunter/hunter.anims.toml b/data/skill/hunter/hunter.anims.toml index c466e59648..73492f10dd 100644 --- a/data/skill/hunter/hunter.anims.toml +++ b/data/skill/hunter/hunter.anims.toml @@ -56,6 +56,12 @@ id = 8602 [grenwall_fail] id = 8602 +[imp_catch] +id = 5218 + +[imp_fail] +id = 5285 + [kebbit_catch] id = 5275 diff --git a/data/skill/hunter/hunter.npcs.toml b/data/skill/hunter/hunter.npcs.toml index 67a452f613..cb34749f82 100644 --- a/data/skill/hunter/hunter.npcs.toml +++ b/data/skill/hunter/hunter.npcs.toml @@ -24,7 +24,7 @@ id = 5091 [hunting_imptrap_npc] id = 5101 -hunt_mode = "hunter_trap" +hunt_mode = "magic_box" hunt_range = 2 [hunting_imptrap_npc_off] diff --git a/data/skill/hunter/hunter.objs.toml b/data/skill/hunter/hunter.objs.toml index bd325fef18..51f77ed98d 100644 --- a/data/skill/hunter/hunter.objs.toml +++ b/data/skill/hunter/hunter.objs.toml @@ -127,6 +127,18 @@ id = 19178 [snare_wimpy_bird] id = 28930 +[magic_box] +id = 19223 + +[magic_box_fail] +id = 19224 + +[magic_box_catching] +id = 19225 + +[magic_box_caught] +id = 19226 + # Box trap — triggered states [box_trap_catching] id = 19188 diff --git a/data/skill/hunter/hunter.tables.toml b/data/skill/hunter/hunter.tables.toml index 94c011f1bf..499bc07f5f 100644 --- a/data/skill/hunter/hunter.tables.toml +++ b/data/skill/hunter/hunter.tables.toml @@ -38,6 +38,11 @@ items = ["logs"] max = 1 npc = "hunting_deadfall_trap_npc" +[.magic_box] +level = 71 +items = ["magic_box"] +npc = "hunting_imptrap_npc" + [.swamp_net] level = 29 bait = "guam_tar" @@ -258,6 +263,16 @@ chance = [15, 130] bait = "raw_pawya_meat" loot = ["grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes", "grenwall_spikes"] +[.imp] +trap = "magic_box" +caught_obj = "magic_box_caught" +level = 71 +xp = 4500 +catch_anim = "imp_catch" +fail_anim = "imp_fail" +loot = ["imp_in_a_box_2"] +chance = [15, 130] + [.rabbit] trap = "rabbit_snare" caught_obj = "rabbit_snare_caught" diff --git a/game/src/main/kotlin/content/skill/hunter/MagicBox.kt b/game/src/main/kotlin/content/skill/hunter/MagicBox.kt new file mode 100644 index 0000000000..7016eff9d3 --- /dev/null +++ b/game/src/main/kotlin/content/skill/hunter/MagicBox.kt @@ -0,0 +1,195 @@ +package content.skill.hunter + +import content.entity.effect.transform +import content.entity.player.bank.BankDeposit +import content.entity.player.inv.item.drop +import net.pearx.kasechange.toLowerSpaceCase +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.chat.plural +import world.gregs.voidps.engine.data.config.RowDefinition +import world.gregs.voidps.engine.data.definition.Areas +import world.gregs.voidps.engine.data.definition.Rows +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.character.player.chat.ChatType +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.exp.exp +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has +import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.engine.entity.item.floor.FloorItem +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.entity.obj.* +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.inv.replace + +class MagicBox : Script { + init { + itemOption("Activate", "magic_box") { + layTrap(null) + } + + floorItemOperate("Lay") { (item) -> + if (item.id == "magic_box") { + layTrap(item) + } + } + + objectOperate("Deactivate", "magic_box,magic_box_fail") { (target) -> + dismantleTrap(target, null) + } + + objectOperate("Retrieve", "magic_box_caught") { (target) -> + dismantleTrap(target, creature = Rows.get("creatures.imp")) + } + + objectOperate("Investigate", "magic_box") { (target) -> + val npc = NPCs.find(target.tile, "hunting_imptrap_npc") + if (npc["owner", ""] == accountName) { + message("This is your magic box, ready to catch an imp.") + } else { + message("This isn't your magic box.") + } + } + + huntNPC("magic_box") { target -> + if (transform.endsWith("_off")) { + return@huntNPC + } + val creature = Rows.getOrNull("creatures.${target.id}") ?: return@huntNPC + val account: String = get("owner") ?: return@huntNPC + val player = Players.findByAccount(account) ?: return@huntNPC + if (!player.has(Skill.Hunter, creature.int("level"))) { + return@huntNPC + } + if (tile.distanceTo(target.tile) > 2) { + return@huntNPC + } + transform("${id}_off") + val chance = Traps.chance(this, creature) + val success = Level.success(player.levels.get(Skill.Hunter), chance) + target.walkToDelay(tile) + target.walkOverDelay(tile) + despawn(100) + val trap = GameObjects.getLayer(tile, ObjectLayer.GROUND) ?: return@huntNPC + target.anim(if (success) creature.anim("catch_anim") else creature.anim("fail_anim")) + target.gfx("imp") + target.delay(1) + if (!success) { + trap.replace("magic_box_fail") + return@huntNPC + } + target.levels.set(Skill.Constitution, 0) + val catching = trap.replace("magic_box_catching") + delay(1) + catching.replace("magic_box_caught") + player.message("Something has been caught in your trap!") + } + + npcDespawn("hunting_imptrap_npc") { + val player = owner ?: return@npcDespawn + val trap = GameObjects.getLayer(tile, ObjectLayer.GROUND) ?: return@npcDespawn + player.dec("trap_count") + GameObjects.remove(trap) + val drop = if (lifecycle == 0) { + player.message("The magic box that you activated has stopped working.") + true + } else { + player["logged_out", false] + } + if (drop) { + player.drop(trap.tile, "magic_box") + } + } + + itemOnItem("*", "imp_in_a_box_2,imp_in_a_box_1") { item, box -> + if (item.id.startsWith("imp_in_a_box") || item.id.startsWith("magic_box")) { + message("The imp refuses to take that to your bank.") + return@itemOnItem + } + BankDeposit.deposit(this, inventory, item, 1, check = false) + if (box.id == "imp_in_a_box_2") { + inventory.replace("imp_in_a_box_2", "imp_in_a_box_1") + message("The imp takes the item to your bank.") + } else { + inventory.replace("imp_in_a_box_1", "magic_box") + message("The imp takes the item to your bank and escapes from the box.") + } + } + } + + private suspend fun Player.layTrap(floorItem: FloorItem?) { + val trap = Rows.getOrNull("traps.magic_box") ?: return + val level = levels.get(Skill.Hunter) + if (!has(Skill.Hunter, trap.int("level"), message = true)) { + return + } + if (Areas.get(tile.zone).any { it.tags.contains("bank") } || GameObjects.getLayer(tile, ObjectLayer.GROUND) != null) { + message("You can't lay a trap here.", ChatType.Filter) + return + } + val max = Traps.max(level, trap.int("max")) + val trapCount = get("trap_count", 0) + if (trapCount >= max) { + message("You may setup only $max ${"trap".plural(max)} at a time at your Hunter level.") + return + } + arriveDelay() + message("You begin setting up ${if (max == 1) "the" else "a"} trap.", ChatType.Filter) + anim("lay_trap") + sound("lay_box_trap") + delay(3) + if (floorItem != null) { + FloorItems.remove(floorItem) + } else { + inventory.remove("magic_box") + } + inc("trap_count") + NPCs.add("hunting_imptrap_npc", tile, ticks = 100, owner = this) + val obj = GameObjects.add("magic_box", tile) + stepAway(obj) + } + + private suspend fun Player.dismantleTrap(target: GameObject, creature: RowDefinition?) { + val npc = NPCs.findOrNull(target.tile, "hunting_imptrap_npc") ?: return + if (npc["owner", ""] != accountName) { + message("This is not your trap.") + return + } + val loot = creature?.itemList("loot") ?: emptyList() + val items = if (loot.isEmpty()) listOf("magic_box") else emptyList() + val size = items.size + loot.size + if (inventory.spaces < size) { + val slots = size - inventory.spaces + message("You don't have enough inventory space. You need $slots more free ${"slot".plural(slots)}.") + return + } + anim("take_trap") + sound("trap_dismantle", delay = 25) + delay(2) + collapse(npc, target) + for (item in items) { + inventory.add(item) + } + if (creature != null) { + for (item in loot) { + inventory.add(item) + } + exp(Skill.Hunter, creature.int("xp") / 10.0) + message("You've caught an ${creature.rowId.toLowerSpaceCase()}!", ChatType.Filter) + } else { + message("You dismantle the trap.", ChatType.Filter) + } + } + + private fun Player.collapse(npc: NPC, target: GameObject) { + dec("trap_count") + NPCs.remove(npc) + GameObjects.remove(target) + } +} diff --git a/game/src/test/kotlin/content/skill/hunter/MagicBoxTest.kt b/game/src/test/kotlin/content/skill/hunter/MagicBoxTest.kt new file mode 100644 index 0000000000..2b1f4b1cac --- /dev/null +++ b/game/src/test/kotlin/content/skill/hunter/MagicBoxTest.kt @@ -0,0 +1,117 @@ +package content.skill.hunter + +import FakeRandom +import WorldTest +import content.entity.player.bank.bank +import itemOnItem +import itemOption +import objectOption +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNotNull +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.setRandom +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MagicBoxTest : WorldTest() { + @Test + fun `Catch an imp`() { + val player = createPlayer() + val start = player.tile + player.inventory.add("magic_box") + player.levels.set(Skill.Hunter, 99) + + player.itemOption("Activate", "magic_box") + tick(3) + assertTrue(GameObjects.at(start).any { it.id == "magic_box" }) + createNPC("imp", player.tile.addY(2)) + + tick(22) + + val trap = GameObjects.at(start).firstOrNull { it.id == "magic_box_caught" } + assertNotNull(trap) + + player.objectOption(trap, "Retrieve") + tick(3) + assertEquals(1, player.inventory.count("imp_in_a_box_2")) + assertEquals(0, player.inventory.count("magic_box")) + assertEquals(450.0, player.experience.get(Skill.Hunter)) + } + + @Test + fun `Can't lay without hunter level`() { + val player = createPlayer() + val start = player.tile + player.inventory.add("magic_box") + player.levels.set(Skill.Hunter, 70) + + player.itemOption("Activate", "magic_box") + tick(3) + assertFalse(GameObjects.at(start).any { it.id == "magic_box" }) + assertEquals(1, player.inventory.count("magic_box")) + } + + @Test + fun `Fail to catch`() { + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = if (until == 4) 0 else until - 1 + }) + val player = createPlayer() + val start = player.tile + player.inventory.add("magic_box") + player.levels.set(Skill.Hunter, 71) + + player.itemOption("Activate", "magic_box") + tick(3) + assertTrue(GameObjects.at(start).any { it.id == "magic_box" }) + createNPC("imp", player.tile.addY(2)) + + tick(22) + + val trap = GameObjects.at(start).firstOrNull { it.id == "magic_box_fail" } + assertNotNull(trap) + + player.objectOption(trap, "Deactivate") + tick(3) + assertEquals(1, player.inventory.count("magic_box")) + assertEquals(0.0, player.experience.get(Skill.Hunter)) + } + + @Test + fun `Imp in a box banks items with two charges`() { + val player = createPlayer() + player.inventory.add("imp_in_a_box_2") + player.inventory.add("bones", 2) + + player.itemOnItem("bones", "imp_in_a_box_2") + tick(2) + assertEquals(1, player.inventory.count("imp_in_a_box_1")) + assertEquals(0, player.inventory.count("imp_in_a_box_2")) + assertEquals(1, player.inventory.count("bones")) + assertEquals(1, player.bank.count("bones")) + + player.itemOnItem("bones", "imp_in_a_box_1") + tick(2) + assertEquals(0, player.inventory.count("imp_in_a_box_1")) + assertEquals(1, player.inventory.count("magic_box")) + assertEquals(0, player.inventory.count("bones")) + assertEquals(2, player.bank.count("bones")) + } + + @Test + fun `Imp refuses to bank another imp box`() { + val player = createPlayer() + player.inventory.add("imp_in_a_box_2") + player.inventory.add("imp_in_a_box_1") + + player.itemOnItem("imp_in_a_box_1", "imp_in_a_box_2") + tick(2) + assertEquals(1, player.inventory.count("imp_in_a_box_2")) + assertEquals(1, player.inventory.count("imp_in_a_box_1")) + assertEquals(0, player.bank.count("imp_in_a_box_1")) + } +} From e6cac58d5ae59e3dc2de96dae4238453086f9e6e Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 17:30:02 -0700 Subject: [PATCH 4/4] Add falconry kebbit hunting --- .../piscatoris_falconry_area.npcs.toml | 12 ++ .../kandarin/piscatoris/piscatoris.areas.toml | 4 + data/skill/hunter/hunter.sounds.toml | 6 + data/skill/hunter/hunter.tables.toml | 32 +++ .../content/entity/player/equip/Equipping.kt | 12 ++ .../kotlin/content/skill/hunter/Falconry.kt | 182 ++++++++++++++++++ .../content/skill/hunter/FalconryTest.kt | 119 ++++++++++++ 7 files changed, 367 insertions(+) create mode 100644 game/src/main/kotlin/content/skill/hunter/Falconry.kt create mode 100644 game/src/test/kotlin/content/skill/hunter/FalconryTest.kt diff --git a/data/area/kandarin/piscatoris/falconry_area/piscatoris_falconry_area.npcs.toml b/data/area/kandarin/piscatoris/falconry_area/piscatoris_falconry_area.npcs.toml index 6d1ad1fe4d..07d4cf3ebf 100644 --- a/data/area/kandarin/piscatoris/falconry_area/piscatoris_falconry_area.npcs.toml +++ b/data/area/kandarin/piscatoris/falconry_area/piscatoris_falconry_area.npcs.toml @@ -6,3 +6,15 @@ examine = "He's wearing a single glove; how strange." id = 5098 examine = "It seems to be on a permanent sugar rush." + +[spotted_kebbit_caught] +id = 5094 +examine = "It looks like it has caught something." + +[dark_kebbit_caught] +id = 5095 +examine = "It looks like it has caught something." + +[dashing_kebbit_caught] +id = 5096 +examine = "It looks like it has caught something." diff --git a/data/area/kandarin/piscatoris/piscatoris.areas.toml b/data/area/kandarin/piscatoris/piscatoris.areas.toml index 362f8e315f..a44b71ac56 100644 --- a/data/area/kandarin/piscatoris/piscatoris.areas.toml +++ b/data/area/kandarin/piscatoris/piscatoris.areas.toml @@ -19,3 +19,7 @@ hint = "where eagles fly." [ppiscatoris_fishing_area] x = [2340, 2349] y = [3702, 3702] + +[piscatoris_falconry_area] +x = [2363, 2395] +y = [3579, 3612] diff --git a/data/skill/hunter/hunter.sounds.toml b/data/skill/hunter/hunter.sounds.toml index 4a84be2413..9b828bb7c3 100644 --- a/data/skill/hunter/hunter.sounds.toml +++ b/data/skill/hunter/hunter.sounds.toml @@ -57,3 +57,9 @@ id = 2645 [deadfall_catch] id = 2631 + +[falcon_swoop] +id = 2634 + +[falcon_return] +id = 2633 diff --git a/data/skill/hunter/hunter.tables.toml b/data/skill/hunter/hunter.tables.toml index 499bc07f5f..9bddf53dcb 100644 --- a/data/skill/hunter/hunter.tables.toml +++ b/data/skill/hunter/hunter.tables.toml @@ -296,3 +296,35 @@ loot = ["big_bones", "tatty_graahk_fur"] level = 55 xp = 3000 loot = ["big_bones", "tatty_kyatt_fur"] + + +# --------------------------------------------------------------------------- +# Falconry — keyed by the kebbit npc id +# --------------------------------------------------------------------------- +[falconry] +level = "int" +xp = "int" # xp * 0.1 +fur = "item" +caught = "npc" +chance = "range" + +[.spotted_kebbit] +level = 43 +xp = 1040 +fur = "spotted_kebbit_fur" +caught = "spotted_kebbit_caught" +chance = [26, 310] + +[.dark_kebbit] +level = 57 +xp = 1320 +fur = "dark_kebbit_fur" +caught = "dark_kebbit_caught" +chance = [0, 253] + +[.dashing_kebbit] +level = 69 +xp = 1560 +fur = "dashing_kebbit_fur" +caught = "dashing_kebbit_caught" +chance = [0, 205] diff --git a/game/src/main/kotlin/content/entity/player/equip/Equipping.kt b/game/src/main/kotlin/content/entity/player/equip/Equipping.kt index cbe8ac596d..e06395bfb7 100644 --- a/game/src/main/kotlin/content/entity/player/equip/Equipping.kt +++ b/game/src/main/kotlin/content/entity/player/equip/Equipping.kt @@ -37,6 +37,10 @@ class Equipping : Script { message("You should leave your gas mask on while you're in West Ardougne.") return@itemOption } + if (item.id.startsWith("falconers_glove")) { + message("You should return the falcon to Matthias instead of just dropping it.") + return@itemOption + } if (item.id == "rubber_chicken" || item.id == "easter_carrot") { options.remove("Whack") } @@ -70,6 +74,14 @@ class Equipping : Script { player.message("That armour is too small for a human.") return } + if (item.id.startsWith("falconers_glove")) { + player.message("You need Matthias to provide you with a bird before you can wear that.") + return + } + if ((item.slot == EquipSlot.Weapon || item.slot == EquipSlot.Shield) && player.equipped(EquipSlot.Weapon).id.startsWith("falconers_glove")) { + player.message("You can't wield anything while you have a falcon on your arm.") + return + } if (item.id.startsWith("bedsheet") && player.tile !in Areas["port_phasmatys"]) { player.message("I'll only wear the bedsheet inside Port Phasmatys!") return diff --git a/game/src/main/kotlin/content/skill/hunter/Falconry.kt b/game/src/main/kotlin/content/skill/hunter/Falconry.kt new file mode 100644 index 0000000000..b223ebd69b --- /dev/null +++ b/game/src/main/kotlin/content/skill/hunter/Falconry.kt @@ -0,0 +1,182 @@ +package content.skill.hunter + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Sad +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.proj.shoot +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.chat.plural +import world.gregs.voidps.engine.data.definition.Rows +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.equip.equipped +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.exp.exp +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has +import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.equipment +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.inv.replace +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot + +class Falconry : Script { + init { + npcOperate("Talk-to", "matthias") { + if (hasFalcon()) { + npc("How are you finding Valor? Remember to bring him back once you're done.") + choice { + option("I'd like to hand him back, thanks.") { + equipment.transaction { + set(EquipSlot.Weapon.index, null) + } + npc("I hope you enjoyed the experience.") + } + option("I'll hang on to him a while longer.") + } + return@npcOperate + } + npc("Greetings. Can I help you at all? Perhaps you'd be interested in trying your hand at falconry?") + choice { + option("Could you tell me more about that?") { + npc("For 500 coins you can borrow one of my birds and try to catch some of the kebbits around here. I'll want the bird back when you're done, of course.") + choice { + option("Sounds good to me.") { + startFalconry() + } + option("Maybe some other time.") + } + } + option("No thanks, I'm fine.") + } + } + + npcOperate("Falconry", "matthias") { + startFalconry() + } + + npcApproach("Catch", "spotted_kebbit,dark_kebbit,dashing_kebbit") { (target) -> + approachRange(6) + catch(target) + } + + npcOperate("Retrieve", "spotted_kebbit_caught,dark_kebbit_caught,dashing_kebbit_caught") { (target) -> + retrieve(target) + } + + npcDespawn("*_kebbit_caught") { + if (lifecycle != 0) { + return@npcDespawn + } + val player = owner ?: return@npcDespawn + if (player.equipped(EquipSlot.Weapon).id == "falconers_glove") { + player.equipment.replace("falconers_glove", "falconers_glove_2") + player.message("Your falcon gives up on its catch and returns to your glove.") + } + } + + exited("piscatoris_falconry_area") { + if (hasFalcon()) { + equipment.transaction { + set(EquipSlot.Weapon.index, null) + } + message("You hand the falcon back to Matthias before leaving.") + } + } + } + + private fun Player.hasFalcon(): Boolean { + val weapon = equipped(EquipSlot.Weapon).id + return weapon == "falconers_glove" || weapon == "falconers_glove_2" + } + + private suspend fun Player.startFalconry() { + if (hasFalcon()) { + npc("You've already got one of my birds.") + return + } + if (!has(Skill.Hunter, 43, message = false)) { + npc("I'm afraid my birds are hard to handle. You'll need a Hunter level of at least 43 before I can let you use one.") + return + } + if (equipped(EquipSlot.Weapon).isNotEmpty() || equipped(EquipSlot.Shield).isNotEmpty()) { + npc("You'll need both hands free to handle the bird. Come back once you've put away what you're holding.") + return + } + if (!inventory.remove("coins", 500)) { + npc("I'm afraid it's 500 coins if you want to borrow a bird, and it looks like you don't have enough on you.") + return + } + equipment.transaction { + set(EquipSlot.Weapon.index, Item("falconers_glove_2")) + } + npc("Here you go then. Treat Valor well, and bring him back when you're done.") + } + + private suspend fun Player.catch(target: NPC) { + val row = Rows.getOrNull("falconry.${target.id}") ?: return + val weapon = equipped(EquipSlot.Weapon).id + if (weapon == "falconers_glove") { + message("Your falcon is already off chasing prey.") + return + } + if (weapon != "falconers_glove_2") { + message("You need a trained falcon to catch this kebbit.") + return + } + if (!has(Skill.Hunter, row.int("level"), message = true)) { + return + } + face(target) + sound("falcon_swoop") + shoot("gyr_falcon", target) + delay(2) + if (target.tile.distanceTo(tile) > 8) { + return + } + val success = Level.success(levels.get(Skill.Hunter), row.intRange("chance")) + if (!success) { + target.shoot("gyr_falcon", this) + sound("falcon_return", delay = 20) + message("The kebbit is too quick for your falcon.") + return + } + equipment.replace("falconers_glove_2", "falconers_glove") + target.levels.set(Skill.Constitution, 0) + NPCs.add(row.npc("caught"), target.tile, ticks = 100, owner = this) + message("Your falcon successfully swoops down on the kebbit.") + } + + private suspend fun Player.retrieve(target: NPC) { + val id = target.id.removeSuffix("_caught") + val row = Rows.getOrNull("falconry.$id") ?: return + if (target["owner", ""] != accountName) { + message("This isn't your falcon.") + return + } + val loot = listOf(row.item("fur"), "bones") + if (inventory.spaces < loot.size) { + val slots = loot.size - inventory.spaces + message("You don't have enough inventory space. You need $slots more free ${"slot".plural(slots)}.") + return + } + anim("net_catch") + delay(1) + NPCs.remove(target) + equipment.replace("falconers_glove", "falconers_glove_2") + for (item in loot) { + inventory.add(item) + } + exp(Skill.Hunter, row.int("xp") / 10.0) + message("You retrieve your falcon and its catch.") + } +} diff --git a/game/src/test/kotlin/content/skill/hunter/FalconryTest.kt b/game/src/test/kotlin/content/skill/hunter/FalconryTest.kt new file mode 100644 index 0000000000..6a78e01ed0 --- /dev/null +++ b/game/src/test/kotlin/content/skill/hunter/FalconryTest.kt @@ -0,0 +1,119 @@ +package content.skill.hunter + +import FakeRandom +import WorldTest +import npcOption +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNotNull +import org.junit.jupiter.api.assertNull +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.equip.equipped +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.setRandom +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FalconryTest : WorldTest() { + @Test + fun `Borrow a falcon from Matthias`() { + val player = createPlayer(Tile(2376, 3605)) + val matthias = createNPC("matthias", Tile(2376, 3606)) + player.inventory.add("coins", 500) + player.levels.set(Skill.Hunter, 43) + + player.npcOption(matthias, "Falconry") + tick(5) + assertEquals("falconers_glove_2", player.equipped(EquipSlot.Weapon).id) + assertEquals(0, player.inventory.count("coins")) + } + + @Test + fun `Can't borrow a falcon without the level`() { + val player = createPlayer(Tile(2376, 3605)) + val matthias = createNPC("matthias", Tile(2376, 3606)) + player.inventory.add("coins", 500) + + player.npcOption(matthias, "Falconry") + tick(5) + assertTrue(player.equipped(EquipSlot.Weapon).isEmpty()) + assertEquals(500, player.inventory.count("coins")) + } + + @Test + fun `Can't borrow a falcon without coins`() { + val player = createPlayer(Tile(2376, 3605)) + val matthias = createNPC("matthias", Tile(2376, 3606)) + player.levels.set(Skill.Hunter, 43) + + player.npcOption(matthias, "Falconry") + tick(5) + assertTrue(player.equipped(EquipSlot.Weapon).isEmpty()) + } + + @Test + fun `Catch and retrieve a kebbit`() { + val player = createPlayer(Tile(2376, 3605)) + val matthias = createNPC("matthias", Tile(2376, 3606)) + player.inventory.add("coins", 500) + player.levels.set(Skill.Hunter, 99) + player.npcOption(matthias, "Falconry") + tick(5) + assertEquals("falconers_glove_2", player.equipped(EquipSlot.Weapon).id) + + val kebbit = createNPC("spotted_kebbit", Tile(2378, 3600)) + player.npcOption(kebbit, "Catch") + tick(10) + + assertEquals("falconers_glove", player.equipped(EquipSlot.Weapon).id) + val caught = NPCs.at(kebbit.tile).firstOrNull { it.id == "spotted_kebbit_caught" } + assertNotNull(caught) + + player.npcOption(caught, "Retrieve") + tick(10) + assertEquals("falconers_glove_2", player.equipped(EquipSlot.Weapon).id) + assertEquals(1, player.inventory.count("spotted_kebbit_fur")) + assertEquals(1, player.inventory.count("bones")) + assertEquals(104.0, player.experience.get(Skill.Hunter)) + } + + @Test + fun `Fail to catch a kebbit`() { + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = if (until == 4) 0 else until - 1 + }) + val player = createPlayer(Tile(2376, 3605)) + val matthias = createNPC("matthias", Tile(2376, 3606)) + player.inventory.add("coins", 500) + player.levels.set(Skill.Hunter, 50) + player.npcOption(matthias, "Falconry") + tick(5) + + val kebbit = createNPC("spotted_kebbit", Tile(2378, 3600)) + player.npcOption(kebbit, "Catch") + tick(10) + + assertEquals("falconers_glove_2", player.equipped(EquipSlot.Weapon).id) + assertNull(NPCs.at(kebbit.tile).firstOrNull { it.id == "spotted_kebbit_caught" }) + assertEquals(0.0, player.experience.get(Skill.Hunter)) + } + + @Test + fun `Leaving the area returns the falcon`() { + val player = createPlayer(Tile(2376, 3605)) + val matthias = createNPC("matthias", Tile(2376, 3606)) + player.inventory.add("coins", 500) + player.levels.set(Skill.Hunter, 43) + player.npcOption(matthias, "Falconry") + tick(5) + assertEquals("falconers_glove_2", player.equipped(EquipSlot.Weapon).id) + + player.tele(2340, 3605) + tick(2) + assertTrue(player.equipped(EquipSlot.Weapon).isEmpty()) + } +}