From 7afddca2a401f9a73a7b00d20a34df72469a6f60 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 17:16:19 -0700 Subject: [PATCH 01/24] 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 | 41 +++- 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, 958 insertions(+), 59 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 8440c17cdd..595a004a26 100644 --- a/data/skill/hunter/hunter.objs.toml +++ b/data/skill/hunter/hunter.objs.toml @@ -170,11 +170,17 @@ id = 28930 examine = "There's something caught in it." # Box trap — triggered states -[box_trap_grey_chinchompa] +[box_trap_catching] +id = 19188 + +[box_trap_ferret] +id = 19190 + +[box_trap_chinchompa] id = 28557 examine = "It looks like there is something caught inside this trap." -[box_trap_red_chinchompa] +[box_trap_carnivorous_chinchompa] id = 28558 examine = "It looks like there is something caught inside this trap." @@ -184,11 +190,36 @@ examine = "It looks like there is something caught inside this trap." [box_trap_grenwall] id = 28906 - examine = "It looks like there is something caught inside this trap." -# 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 +examine = "Someone could get their fingers caught in that." + +[boulder_trap_sabre_toothed_kebbit] +id = 19216 +examine = "Someone could get their fingers caught in that." + +[boulder_trap_barb_tailed_kebbit] +id = 19217 +examine = "Someone could get their fingers caught in that." + +[boulder_trap_prickly_kebbit] +id = 19218 examine = "Someone could get their fingers caught in that." [pitfall_sabre_toothed_kyatt_0] 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 bd2f792a3b8492a53678f1aa203e371ab9d37ccd Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 17:55:44 -0700 Subject: [PATCH 02/24] 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 654322282e2c4b950c124641707cbc19635a3b05 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 17:21:17 -0700 Subject: [PATCH 03/24] 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 | 13 ++ data/skill/hunter/hunter.tables.toml | 15 ++ .../kotlin/content/skill/hunter/MagicBox.kt | 195 ++++++++++++++++++ .../content/skill/hunter/MagicBoxTest.kt | 117 +++++++++++ 7 files changed, 354 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 595a004a26..d4c314cf48 100644 --- a/data/skill/hunter/hunter.objs.toml +++ b/data/skill/hunter/hunter.objs.toml @@ -169,6 +169,19 @@ examine = "There's something caught in it." id = 28930 examine = "There's something caught in it." + +[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 a6fd97e070d19af068730c6d4539dad3e7ec224f Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 17:30:02 -0700 Subject: [PATCH 04/24] 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 29b8f6c952..f993d253bb 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 @@ -5,3 +5,15 @@ examine = "He's wearing a single glove; how strange." [spotted_kebbit] 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()) + } +} From b91c95888bcb36316ae762630ad620cd50b22314 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 17:40:31 -0700 Subject: [PATCH 05/24] Add polar and common kebbit tracking --- data/skill/hunter/hunter.anims.toml | 9 + data/skill/hunter/hunter.objs.toml | 103 +++++++++ data/skill/hunter/hunter.sounds.toml | 3 + data/skill/hunter/hunter.varbits.toml | 78 ++++++- data/skill/hunter/tracking.tables.toml | 181 +++++++++++++++ .../content/skill/hunter/KebbitTracking.kt | 208 ++++++++++++++++++ .../skill/hunter/KebbitTrackingTest.kt | 108 +++++++++ 7 files changed, 689 insertions(+), 1 deletion(-) create mode 100644 data/skill/hunter/tracking.tables.toml create mode 100644 game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt create mode 100644 game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt diff --git a/data/skill/hunter/hunter.anims.toml b/data/skill/hunter/hunter.anims.toml index 73492f10dd..0395cd3fbf 100644 --- a/data/skill/hunter/hunter.anims.toml +++ b/data/skill/hunter/hunter.anims.toml @@ -101,3 +101,12 @@ id = 5227 [big_cat_death] id = 5234 ticks = 2 + +[noose_catch_polar] +id = 5256 + +[noose_catch_common] +id = 5259 + +[noose_fail] +id = 5255 diff --git a/data/skill/hunter/hunter.objs.toml b/data/skill/hunter/hunter.objs.toml index d4c314cf48..044be728ef 100644 --- a/data/skill/hunter/hunter.objs.toml +++ b/data/skill/hunter/hunter.objs.toml @@ -321,3 +321,106 @@ examine = "Something has fallen into this trap." [pitfall_inverse_sabre_toothed_kyatt] id = 19236 examine = "Something has fallen into this trap." + +# Kebbit tracking +[kebbit_tracks_plant_0] +id = 19356 + +[kebbit_tracks_plant_1] +id = 19357 + +[kebbit_tracks_plant_2] +id = 19358 + +[kebbit_tracks_plant_3] +id = 19359 + +[kebbit_tracks_plant_4] +id = 19360 + +[kebbit_tracks_plant_5] +id = 19361 + +[kebbit_tracks_plant_6] +id = 19362 + +[kebbit_tracks_plant_7] +id = 19363 + +[kebbit_tracks_plant_8] +id = 19364 + +[kebbit_tracks_plant_9] +id = 19365 + +[kebbit_tracks_plant_10] +id = 19372 + +[kebbit_tracks_plant_11] +id = 19373 + +[kebbit_tracks_plant_12] +id = 19374 + +[kebbit_tracks_plant_13] +id = 19375 + +[kebbit_tracks_plant_14] +id = 19376 + +[kebbit_tracks_plant_15] +id = 19377 + +[kebbit_tracks_plant_16] +id = 19378 + +[kebbit_tracks_plant_17] +id = 19379 + +[kebbit_tracks_plant_18] +id = 19380 + +[kebbit_tunnel_0] +id = 19419 + +[kebbit_tunnel_1] +id = 19420 + +[kebbit_tunnel_2] +id = 19421 + +[kebbit_tunnel_3] +id = 19423 + +[kebbit_tunnel_4] +id = 19424 + +[kebbit_tunnel_5] +id = 19426 + +[kebbit_hollow_log_0] +id = 36688 + +[kebbit_hollow_log_1] +id = 36689 + +[kebbit_hollow_log_2] +id = 36690 + +[kebbit_bush] +id = 19428 + +[kebbit_snow_drift] +id = 19435 + +[common_kebbit_burrow] +id = 19439 + +[common_kebbit_burrow_2] +id = 19440 + +[polar_kebbit_hole] +id = 19640 + +[polar_kebbit_hole_2] +id = 19641 diff --git a/data/skill/hunter/hunter.sounds.toml b/data/skill/hunter/hunter.sounds.toml index 9b828bb7c3..2051391794 100644 --- a/data/skill/hunter/hunter.sounds.toml +++ b/data/skill/hunter/hunter.sounds.toml @@ -63,3 +63,6 @@ id = 2634 [falcon_return] id = 2633 + +[hunting_noose] +id = 2637 diff --git a/data/skill/hunter/hunter.varbits.toml b/data/skill/hunter/hunter.varbits.toml index 12ff313409..42ba65eaf8 100644 --- a/data/skill/hunter/hunter.varbits.toml +++ b/data/skill/hunter/hunter.varbits.toml @@ -85,4 +85,80 @@ values = ["empty", "spiked", "collapsed", "caught", "inverse"] [debug_traps] id = 3075 -format = "boolean" \ No newline at end of file +format = "boolean" +[common_kebbit_trail_0] +id = 2974 +format = "int" + +[common_kebbit_trail_1] +id = 2975 +format = "int" + +[common_kebbit_trail_2] +id = 2976 +format = "int" + +[common_kebbit_trail_3] +id = 2977 +format = "int" + +[common_kebbit_trail_4] +id = 2978 +format = "int" + +[common_kebbit_trail_5] +id = 2979 +format = "int" + +[common_kebbit_trail_6] +id = 2980 +format = "int" + +[common_kebbit_trail_7] +id = 2981 +format = "int" + +[common_kebbit_trail_8] +id = 2982 +format = "int" + +[common_kebbit_trail_9] +id = 2983 +format = "int" + +[polar_kebbit_trail_0] +id = 3053 +format = "int" + +[polar_kebbit_trail_1] +id = 3054 +format = "int" + +[polar_kebbit_trail_2] +id = 3055 +format = "int" + +[polar_kebbit_trail_3] +id = 3056 +format = "int" + +[polar_kebbit_trail_4] +id = 3057 +format = "int" + +[polar_kebbit_trail_5] +id = 3058 +format = "int" + +[polar_kebbit_trail_6] +id = 3059 +format = "int" + +[polar_kebbit_trail_7] +id = 3060 +format = "int" + +[polar_kebbit_trail_8] +id = 3061 +format = "int" + diff --git a/data/skill/hunter/tracking.tables.toml b/data/skill/hunter/tracking.tables.toml new file mode 100644 index 0000000000..0bd380ae75 --- /dev/null +++ b/data/skill/hunter/tracking.tables.toml @@ -0,0 +1,181 @@ +[tracking] +level = "int" +level_default = 1 +xp = "int" # xp * 0.1 +loot = "list" +catch_anim = "anim" +limit = "int" # maximum linking segments in a generated trail + +[.polar_kebbit] +xp = 300 +loot = ["raw_beast_meat", "polar_kebbit_fur", "bones"] +catch_anim = "noose_catch_polar" +limit = 3 + +[.common_kebbit] +level = 3 +xp = 360 +loot = ["common_kebbit_fur", "bones", "raw_beast_meat"] +catch_anim = "noose_catch_common" +limit = 3 + + +# --------------------------------------------------------------------------- +# Trail segments — burrow marks a starting segment for that burrow object, +# trigger is the trail object inspected to advance (defaults to end) +# --------------------------------------------------------------------------- +[trails] +kebbit = "string" +varbit = "var" +tunnel = "boolean" +tunnel_default = false +inverted = "boolean" +inverted_default = false +start = "tile" +end = "tile" +trigger = "tile" +burrow = "obj" + +[.common_0] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_0" +start = { x = 2354, y = 3595 } +end = { x = 2360, y = 3602 } +burrow = "common_kebbit_burrow" + +[.common_1] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_1" +start = { x = 2354, y = 3595 } +end = { x = 2355, y = 3601 } +burrow = "common_kebbit_burrow" + +[.common_2] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_2" +start = { x = 2354, y = 3594 } +end = { x = 2349, y = 3604 } +burrow = "common_kebbit_burrow" + +[.common_6] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_6" +inverted = true +start = { x = 2361, y = 3611 } +end = { x = 2360, y = 3602 } +burrow = "common_kebbit_burrow_2" + +[.common_7] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_7" +inverted = true +start = { x = 2360, y = 3612 } +end = { x = 2357, y = 3607 } +burrow = "common_kebbit_burrow_2" + +[.common_8] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_8" +start = { x = 2357, y = 3607 } +end = { x = 2354, y = 3609 } +trigger = { x = 2355, y = 3608 } + +[.common_9] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_9" +start = { x = 2354, y = 3609 } +end = { x = 2349, y = 3604 } +trigger = { x = 2351, y = 3608 } + +[.common_3] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_3" +start = { x = 2360, y = 3602 } +end = { x = 2355, y = 3601 } +trigger = { x = 2358, y = 3599 } + +[.common_4] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_4" +start = { x = 2355, y = 3601 } +end = { x = 2349, y = 3604 } +trigger = { x = 2352, y = 3603 } + +[.common_5] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_5" +start = { x = 2360, y = 3602 } +end = { x = 2357, y = 3607 } +trigger = { x = 2358, y = 3603 } + +[.polar_8] +kebbit = "polar_kebbit" +varbit = "polar_kebbit_trail_8" +tunnel = true +start = { x = 2712, y = 3831, level = 1 } +end = { x = 2718, y = 3832, level = 1 } +burrow = "polar_kebbit_hole" + +[.polar_7] +kebbit = "polar_kebbit" +varbit = "polar_kebbit_trail_7" +inverted = true +start = { x = 2712, y = 3831, level = 1 } +end = { x = 2716, y = 3827, level = 1 } +trigger = { x = 2713, y = 3827, level = 1 } +burrow = "polar_kebbit_hole" + +[.polar_4] +kebbit = "polar_kebbit" +varbit = "polar_kebbit_trail_4" +start = { x = 2712, y = 3831, level = 1 } +end = { x = 2708, y = 3819, level = 1 } +trigger = { x = 2708, y = 3825, level = 1 } +burrow = "polar_kebbit_hole" + +[.polar_0] +kebbit = "polar_kebbit" +varbit = "polar_kebbit_trail_0" +inverted = true +start = { x = 2718, y = 3820, level = 1 } +end = { x = 2708, y = 3819, level = 1 } +trigger = { x = 2712, y = 3815, level = 1 } +burrow = "polar_kebbit_hole_2" + +[.polar_2] +kebbit = "polar_kebbit" +varbit = "polar_kebbit_trail_2" +tunnel = true +start = { x = 2718, y = 3820, level = 1 } +end = { x = 2715, y = 3820, level = 1 } +burrow = "polar_kebbit_hole_2" + +[.polar_3] +kebbit = "polar_kebbit" +varbit = "polar_kebbit_trail_3" +tunnel = true +start = { x = 2718, y = 3820, level = 1 } +end = { x = 2721, y = 3827, level = 1 } +burrow = "polar_kebbit_hole_2" + +[.polar_5] +kebbit = "polar_kebbit" +varbit = "polar_kebbit_trail_5" +inverted = true +start = { x = 2714, y = 3821, level = 1 } +end = { x = 2716, y = 3827, level = 1 } + +[.polar_6] +kebbit = "polar_kebbit" +varbit = "polar_kebbit_trail_6" +tunnel = true +inverted = true +start = { x = 2716, y = 3827, level = 1 } +end = { x = 2718, y = 3829, level = 1 } + +[.polar_1] +kebbit = "polar_kebbit" +varbit = "polar_kebbit_trail_1" +tunnel = true +start = { x = 2708, y = 3819, level = 1 } +end = { x = 2711, y = 3819, level = 1 } diff --git a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt new file mode 100644 index 0000000000..fbf10476cc --- /dev/null +++ b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt @@ -0,0 +1,208 @@ +package content.skill.hunter + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.definition.Rows +import world.gregs.voidps.engine.data.definition.Tables +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.has +import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.engine.entity.obj.GameObject +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.random + +class KebbitTracking : Script { + + private data class Segment( + val varbit: String, + val tunnel: Boolean, + val inverted: Boolean, + val start: Tile, + val end: Tile, + val trigger: Tile, + val burrow: String?, + ) + + private val trackingTrails = mutableMapOf>() + private val trackingSteps = mutableMapOf() + + init { + objectOperate("Inspect", "common_kebbit_burrow,common_kebbit_burrow_2,polar_kebbit_hole,polar_kebbit_hole_2") { (target) -> + inspectBurrow(target) + } + + objectOperate("Inspect", "kebbit_tracks_plant_*,kebbit_tunnel_*,kebbit_hollow_log_*") { (target) -> + inspectTrail(target) + } + + objectOperate("Search", "kebbit_bush,kebbit_snow_drift") { (target) -> + inspectTrail(target) + } + + objectOperate("Attack", "kebbit_bush,kebbit_snow_drift") { (target) -> + catch(target) + } + + playerDespawn { + clearTrail(this) + } + } + + private fun kebbit(burrow: String) = if (burrow.startsWith("polar")) "polar_kebbit" else "common_kebbit" + + private fun segments(kebbit: String): List = Tables.get("trails").rows().filter { it.string("kebbit") == kebbit }.map { row -> + Segment( + varbit = row.string("varbit"), + tunnel = row.bool("tunnel"), + inverted = row.bool("inverted"), + start = row.tile("start"), + end = row.tile("end"), + trigger = row.tileOrNull("trigger") ?: row.tile("end"), + burrow = row.objOrNull("burrow"), + ) + } + + private fun tunnels(kebbit: String) = segments(kebbit).filter { it.tunnel }.flatMap { listOf(it.start, it.end) }.toSet() + + private fun linkingPool(kebbit: String): List { + val base = segments(kebbit) + val tunnels = tunnels(kebbit) + // Common kebbit starting segments only ever lead away from the burrow + val pool = if (kebbit == "common_kebbit") base.filter { it.burrow == null } else base + return pool + pool.map { inverse(it, tunnels) } + } + + private fun inverse(segment: Segment, tunnels: Set) = Segment( + varbit = segment.varbit, + tunnel = segment.end in tunnels, + inverted = !segment.inverted, + start = segment.end, + end = segment.start, + trigger = segment.trigger, + burrow = null, + ) + + private fun generate(kebbit: String, burrow: String, limit: Int): List? { + val trail = mutableListOf(segments(kebbit).filter { it.burrow == burrow }.random(random)) + val pool = linkingPool(kebbit) + var spotsLeft = random.nextInt(2, limit + 1) + var tries = spotsLeft * 3 + while (spotsLeft > 0) { + if (tries-- <= 0) { + return null + } + val previous = trail.last() + val possible = if (previous.tunnel) { + pool.filter { it.tunnel && it.start != previous.end && it.start.level == previous.end.level && it.start.distanceTo(previous.end) <= 5 } + } else { + pool.filter { it.start == previous.end } + }.filter { next -> trail.none { it.varbit == next.varbit } } + if (possible.isEmpty()) { + continue + } + val next = possible.random(random) + trail.add(next) + if (!next.tunnel) { + spotsLeft-- + } + } + return trail + } + + private fun updateTrail(player: Player) { + val trail = trackingTrails[player.accountName] ?: return + val step = trackingSteps[player.accountName] ?: 0 + for (index in 0..step) { + val segment = trail[index] + player[segment.varbit] = if (segment.inverted) 5 else 4 + } + } + + private fun clearTrail(player: Player) { + val trail = trackingTrails.remove(player.accountName) + trackingSteps.remove(player.accountName) + if (trail != null) { + for (segment in trail) { + player[segment.varbit] = 0 + } + } + } + + private fun Player.inspectBurrow(target: GameObject) { + if (trackingTrails.containsKey(accountName)) { + inspectTrail(target) + return + } + val kebbit = kebbit(target.id) + val row = Rows.get("tracking.$kebbit") + if (!has(Skill.Hunter, row.int("level"), message = true)) { + return + } + val trail = generate(kebbit, target.id, row.int("limit")) + if (trail == null) { + message("You search but find nothing of interest.") + return + } + trackingTrails[accountName] = trail + trackingSteps[accountName] = 0 + updateTrail(this) + message("You discover some tracks nearby.") + } + + private fun Player.inspectTrail(target: GameObject) { + val trail = trackingTrails[accountName] + if (trail == null) { + message("You search but find nothing.") + return + } + val step = trackingSteps[accountName] ?: 0 + val current = if (step < trail.lastIndex) trail[step + 1] else trail[step] + if (step == trail.lastIndex && current.end == target.tile) { + message("It looks like something is moving around in there.") + return + } + if (current.trigger == target.tile) { + trackingSteps[accountName] = step + 1 + updateTrail(this) + message("You discover some tracks nearby.") + return + } + message("You search but find nothing of interest.") + } + + private suspend fun Player.catch(target: GameObject) { + val trail = trackingTrails[accountName] + if (trail == null) { + message("You search but find nothing.") + return + } + if (!inventory.contains("noose_wand") && equipped(EquipSlot.Weapon).id != "noose_wand") { + message("You need a noose wand to catch the kebbit.") + return + } + val step = trackingSteps[accountName] ?: 0 + val kebbit = kebbit(trail.first().varbit) + val row = Rows.get("tracking.$kebbit") + sound("hunting_noose") + if (step != trail.lastIndex || trail[step].end != target.tile) { + anim("noose_fail") + delay(2) + message("You fail to find anything with your noose wand.") + return + } + anim(row.anim("catch_anim")) + delay(2) + for (item in row.itemList("loot")) { + inventory.add(item) + } + exp(Skill.Hunter, row.int("xp") / 10.0) + clearTrail(this) + message("You've caught a ${kebbit.replace('_', ' ')}!") + } +} diff --git a/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt b/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt new file mode 100644 index 0000000000..6c287e5a2e --- /dev/null +++ b/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt @@ -0,0 +1,108 @@ +package content.skill.hunter + +import WorldTest +import containsMessage +import objectOption +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class KebbitTrackingTest : WorldTest() { + + // With FakeRandom the common kebbit trail is always burrow -> trail_0 -> trail_3 -> trail_4, + // triggered at (2358,3599) then (2352,3603), ending at (2349,3604) + private fun startTrail(player: Player): GameObject { + val burrow = createObject("common_kebbit_burrow", Tile(2354, 3595)) + player.objectOption(burrow, "Inspect") + tick(5) + return burrow + } + + @Test + fun `Track and catch a common kebbit`() { + val player = createPlayer(Tile(2353, 3595)) + player.inventory.add("noose_wand") + player.levels.set(Skill.Hunter, 3) + startTrail(player) + assertEquals(4, player["common_kebbit_trail_0", 0]) + + val first = createObject("kebbit_tracks_plant_0", Tile(2358, 3599)) + player.objectOption(first, "Inspect") + tick(10) + assertEquals(4, player["common_kebbit_trail_3", 0]) + + val second = createObject("kebbit_tracks_plant_1", Tile(2352, 3603)) + player.objectOption(second, "Inspect") + tick(10) + assertEquals(4, player["common_kebbit_trail_4", 0]) + + val bush = createObject("kebbit_bush", Tile(2349, 3604)) + player.objectOption(bush, "Search") + tick(10) + assertTrue(player.containsMessage("something is moving around")) + + player.objectOption(bush, "Attack") + tick(5) + assertEquals(1, player.inventory.count("common_kebbit_fur")) + assertEquals(1, player.inventory.count("bones")) + assertEquals(1, player.inventory.count("raw_beast_meat")) + assertEquals(36.0, player.experience.get(Skill.Hunter)) + assertEquals(0, player["common_kebbit_trail_0", 0]) + assertEquals(0, player["common_kebbit_trail_3", 0]) + } + + @Test + fun `Wrong trail object reveals nothing`() { + val player = createPlayer(Tile(2353, 3595)) + player.levels.set(Skill.Hunter, 3) + startTrail(player) + + val wrong = createObject("kebbit_tracks_plant_2", Tile(2352, 3603)) + player.objectOption(wrong, "Inspect") + tick(15) + assertEquals(0, player["common_kebbit_trail_3", 0]) + assertEquals(0, player["common_kebbit_trail_4", 0]) + assertTrue(player.containsMessage("You search but find nothing of interest")) + } + + @Test + fun `Can't catch without a noose wand`() { + val player = createPlayer(Tile(2353, 3595)) + player.levels.set(Skill.Hunter, 3) + startTrail(player) + + val bush = createObject("kebbit_bush", Tile(2349, 3604)) + player.objectOption(bush, "Attack") + tick(15) + assertTrue(player.containsMessage("You need a noose wand")) + assertEquals(0.0, player.experience.get(Skill.Hunter)) + } + + @Test + fun `Attacking the wrong spot fails`() { + val player = createPlayer(Tile(2353, 3595)) + player.inventory.add("noose_wand") + player.levels.set(Skill.Hunter, 3) + startTrail(player) + + val bush = createObject("kebbit_bush", Tile(2349, 3604)) + player.objectOption(bush, "Attack") + tick(15) + assertEquals(0, player.inventory.count("common_kebbit_fur")) + assertEquals(0.0, player.experience.get(Skill.Hunter)) + assertEquals(4, player["common_kebbit_trail_0", 0]) + } + + @Test + fun `Can't track a common kebbit below level 3`() { + val player = createPlayer(Tile(2353, 3595)) + startTrail(player) + assertEquals(0, player["common_kebbit_trail_0", 0]) + } +} From 90097b6e4cfa4c0f0dbc725b3ae219f2e1420635 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 18:11:14 -0700 Subject: [PATCH 06/24] Add hint arrow over caught falcon --- game/src/main/kotlin/content/skill/hunter/Falconry.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/game/src/main/kotlin/content/skill/hunter/Falconry.kt b/game/src/main/kotlin/content/skill/hunter/Falconry.kt index b223ebd69b..894a153d57 100644 --- a/game/src/main/kotlin/content/skill/hunter/Falconry.kt +++ b/game/src/main/kotlin/content/skill/hunter/Falconry.kt @@ -9,6 +9,8 @@ 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.clearHint +import world.gregs.voidps.engine.client.hint import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.chat.plural import world.gregs.voidps.engine.data.definition.Rows @@ -74,10 +76,14 @@ class Falconry : Script { } npcDespawn("*_kebbit_caught") { + val player = owner ?: return@npcDespawn + val hint = get("hint", -1) + if (hint != -1) { + player.clearHint(hint) + } 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.") @@ -152,7 +158,8 @@ class Falconry : Script { } equipment.replace("falconers_glove_2", "falconers_glove") target.levels.set(Skill.Constitution, 0) - NPCs.add(row.npc("caught"), target.tile, ticks = 100, owner = this) + val caught = NPCs.add(row.npc("caught"), target.tile, ticks = 100, owner = this) + caught["hint"] = hint(caught) message("Your falcon successfully swoops down on the kebbit.") } From f0df674d4e78a6380af2e3db6743a7a78390abe3 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 18:16:52 -0700 Subject: [PATCH 07/24] Fix hint arrow removal packet including bytes the client never reads --- .../gregs/voidps/network/login/protocol/encode/HintEncoder.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/encode/HintEncoder.kt b/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/encode/HintEncoder.kt index f3382c997e..9a4c4e5846 100644 --- a/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/encode/HintEncoder.kt +++ b/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/encode/HintEncoder.kt @@ -45,6 +45,10 @@ fun Client.arrowHint( model: Int = 65535, ) = send(Protocol.HINT_ARROW) { writeByte((arrowIndex shl 5) or type) + // Removals are a single byte; the client stops reading when the type is 0 + if (type == 0) { + return@send + } writeByte(sprite) if (sprite >= 0) { if (type == 1 || type == 10) { From ffdc29856c1fe8c4aff7f0ef1907dc94f298dfb8 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 18:19:25 -0700 Subject: [PATCH 08/24] Pad hint arrow packet to the fixed size the client reads --- .../login/protocol/encode/HintEncoder.kt | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/encode/HintEncoder.kt b/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/encode/HintEncoder.kt index 9a4c4e5846..d897a80014 100644 --- a/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/encode/HintEncoder.kt +++ b/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/encode/HintEncoder.kt @@ -44,24 +44,22 @@ fun Client.arrowHint( radius: Int = 0, model: Int = 65535, ) = send(Protocol.HINT_ARROW) { + // The client always reads a fixed 12 byte body, so every form must be padded to it writeByte((arrowIndex shl 5) or type) - // Removals are a single byte; the client stops reading when the type is 0 - if (type == 0) { - return@send - } writeByte(sprite) - if (sprite >= 0) { - if (type == 1 || type == 10) { - writeShort(entityIndex) - writeInt(0) - writeShort(0) - } else if (type in 2..6) { - writeByte(level) // level - writeShort(x) // x - writeShort(y) // y - writeByte(z) // z? - writeShort(radius) - } - writeShort(model) + if (type == 1 || type == 10) { + writeShort(entityIndex) + writeInt(0) + writeShort(0) + } else if (type in 2..6) { + writeByte(level) // level + writeShort(x) // x + writeShort(y) // y + writeByte(z) // z? + writeShort(radius) + } else { + writeInt(0) + writeInt(0) } + writeShort(model) } \ No newline at end of file From 6995e0897a9368da79b0547f4478a659a456c540 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 18:45:00 -0700 Subject: [PATCH 09/24] Add imp-in-a-box Talk-to dialogue and Bank interface --- data/skill/hunter/hunter.ifaces.toml | 19 +++ .../kotlin/content/skill/hunter/MagicBox.kt | 108 ++++++++++++++++++ .../content/skill/hunter/MagicBoxTest.kt | 42 +++++++ 3 files changed, 169 insertions(+) create mode 100644 data/skill/hunter/hunter.ifaces.toml diff --git a/data/skill/hunter/hunter.ifaces.toml b/data/skill/hunter/hunter.ifaces.toml new file mode 100644 index 0000000000..c24636e261 --- /dev/null +++ b/data/skill/hunter/hunter.ifaces.toml @@ -0,0 +1,19 @@ +[imp_box] +id = 478 +type = "main_screen" + +[.title] +id = 12 + +[.text] +id = 13 + +[.inventory] +id = 14 +inventory = "inventory" +width = 7 +height = 4 +options = { Deposit = 0, Examine = 9 } + +[.close] +id = 15 diff --git a/game/src/main/kotlin/content/skill/hunter/MagicBox.kt b/game/src/main/kotlin/content/skill/hunter/MagicBox.kt index 7016eff9d3..c928a486e3 100644 --- a/game/src/main/kotlin/content/skill/hunter/MagicBox.kt +++ b/game/src/main/kotlin/content/skill/hunter/MagicBox.kt @@ -2,11 +2,19 @@ package content.skill.hunter import content.entity.effect.transform import content.entity.player.bank.BankDeposit +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player 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.client.ui.close +import world.gregs.voidps.engine.client.ui.open import world.gregs.voidps.engine.data.config.RowDefinition import world.gregs.voidps.engine.data.definition.Areas import world.gregs.voidps.engine.data.definition.Rows @@ -20,6 +28,7 @@ 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.* @@ -107,6 +116,28 @@ class MagicBox : Script { } } + itemOption("Talk-to", "imp_in_a_box_2,imp_in_a_box_1") { + impDialogue() + } + + itemOption("Bank", "imp_in_a_box_2,imp_in_a_box_1") { + open("imp_box") + } + + interfaceOpened("imp_box") { + interfaces.sendText("imp_box", "text", depositText()) + interfaceOptions.send("imp_box", "inventory") + interfaceOptions.unlockAll("imp_box", "inventory", 0 until 28) + } + + interfaceOption("Deposit", "imp_box:inventory") { (item, slot) -> + depositItem(item, slot) + } + + interfaceOption("Close", "imp_box:close") { + close("imp_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.") @@ -123,6 +154,83 @@ class MagicBox : Script { } } + private fun Player.depositText() = if (inventory.contains("imp_in_a_box_2")) { + "Select an item or stack of items to deposit.
You can deposit up to 2 items or stacks." + } else { + "Select an item or stack of items to deposit.
You can deposit 1 more item or stack." + } + + private fun Player.depositItem(item: Item, slot: Int) { + if (item.id.startsWith("imp_in_a_box") || item.id.startsWith("magic_box")) { + message("A magical force prevents you from banking this item.") + return + } + BankDeposit.deposit(this, inventory, item, item.amount, slot, check = false) + if (inventory.contains("imp_in_a_box_2")) { + inventory.replace("imp_in_a_box_2", "imp_in_a_box_1") + interfaces.sendText("imp_box", "text", depositText()) + } else if (inventory.contains("imp_in_a_box_1")) { + inventory.replace("imp_in_a_box_1", "magic_box") + close("imp_box") + message("The imp takes your items to the bank and escapes from the box.") + } else { + close("imp_box") + } + } + + private suspend fun Player.impDialogue() { + player("Hey imp, are you still there?") + npc("imp", "Of course I can hear ya, ya great big ape. You know, there's not even enuf space to swing Bob about in 'ere. How about a breather? You know, stretch me pins for a bit?") + impOptions() + } + + private suspend fun Player.impOptions() { + choice { + option("No, I'm going to keep you in there.") { + player("No, I'm going to keep you in there. I might keep you as a pet.") + npc("imp", "Pet!! Nah mate. I fink you'd find dat you'd be my pet!! We is not makin good pets.") + player("Really? Why not?") + npc("imp", "Coz...errr...") + npc("imp", "We bite! Yeah we is biting and...and...er...") + npc("imp", "We is fire risk! Yeah dat's it! We be burning down your housey and stealin' all ya shiny gems. Oh, and da beads!! Mmmm, beads.") + player("Fire risk? How does that work?") + npc("imp", "Is those wizzies. Dey don't like de imps so dey make us go BOOOM!!") + } + option("It's not that bad.") { + player("It's not that bad. You've got four big windows, charming company...er...") + npc("imp", "Yeah, we's love tiny, crampt space. It be magical. But I is a busy imp, innit? Dragons needin' ticklin', shiny relics needin' stealin', you know how it goes.") + npc("imp", "So, if you's know whas good for ya, you'd be lettin' me go, right?") + impOptions() + } + option("Don't I get three wishes?") { + player("Don't I get three wishes?") + npc("imp", "Nah, mate. Dunno what you're chirpin' about.") + player("Well, you're a magical creature aren't you? Surely I get some wishes for capturing you, or releasing you, or something?") + npc("imp", "I'm finking dat you be a bit confoosed. I is an imp, not some namby-pamby genie or some kinda fairy. Ye can tell by the horns.") + npc("imp", "Sayin' dat, I don't fancy being cooped up like one of me uncle's pigeons. Tell you what, is there anything you need deliverin' to the bank?") + npc("imp", "I may not be no cunjerer or sommink like dat, but I can get about nice an quick like. If you let me scarper, I'll take a couple of fings to the bank for ya. You game?") + bankOptions() + } + } + } + + private suspend fun Player.bankOptions() { + choice { + option("Okay, that sounds fair.") { + open("imp_box") + } + option("Surely it should be three items?") { + player("Surely it should be three items? Then it's one item per wish.") + npc("imp", "I've already told ya, I ain't no bloomin' fairy. Besides, you know wot dey say, three's a crowd innit? I don't fink I can hop about carryin' more dan 2 fings.") + bankOptions() + } + option("I've got nothing I need banking right now.") { + player("I've got nothing I need banking right now.") + npc("imp", "Great, just blinkin great, dat is. I'll just sit about countin' zombie sheep then. One...two...two and a bit...three and a bit more... I don't fink sheep 'ave dat many legs...") + } + } + } + private suspend fun Player.layTrap(floorItem: FloorItem?) { val trap = Rows.getOrNull("traps.magic_box") ?: return val level = levels.get(Skill.Hunter) diff --git a/game/src/test/kotlin/content/skill/hunter/MagicBoxTest.kt b/game/src/test/kotlin/content/skill/hunter/MagicBoxTest.kt index 2b1f4b1cac..f0c8ba7e41 100644 --- a/game/src/test/kotlin/content/skill/hunter/MagicBoxTest.kt +++ b/game/src/test/kotlin/content/skill/hunter/MagicBoxTest.kt @@ -3,13 +3,16 @@ package content.skill.hunter import FakeRandom import WorldTest import content.entity.player.bank.bank +import interfaceOption 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.client.ui.hasOpen import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.item.Item import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.inventory @@ -102,6 +105,45 @@ class MagicBoxTest : WorldTest() { assertEquals(2, player.bank.count("bones")) } + @Test + fun `Bank option opens the imp release negotiation form`() { + val player = createPlayer() + player.inventory.add("imp_in_a_box_2") + player.inventory.add("bones", 2) + + player.itemOption("Bank", "imp_in_a_box_2") + tick(2) + assertTrue(player.hasOpen("imp_box")) + + player.interfaceOption("imp_box", "inventory", "Deposit", item = Item("bones"), slot = player.inventory.indexOf("bones")) + tick(2) + assertEquals(1, player.bank.count("bones")) + assertEquals(1, player.inventory.count("imp_in_a_box_1")) + assertTrue(player.hasOpen("imp_box")) + + player.interfaceOption("imp_box", "inventory", "Deposit", item = Item("bones"), slot = player.inventory.indexOf("bones")) + tick(2) + assertEquals(2, player.bank.count("bones")) + assertEquals(1, player.inventory.count("magic_box")) + assertFalse(player.hasOpen("imp_box")) + } + + @Test + fun `Imp box can't be deposited through the form`() { + val player = createPlayer() + player.inventory.add("imp_in_a_box_2") + player.inventory.add("imp_in_a_box_1") + + player.itemOption("Bank", "imp_in_a_box_2") + tick(2) + assertTrue(player.hasOpen("imp_box")) + + player.interfaceOption("imp_box", "inventory", "Deposit", item = Item("imp_in_a_box_1"), slot = player.inventory.indexOf("imp_in_a_box_1")) + tick(2) + assertEquals(0, player.bank.count("imp_in_a_box_1")) + assertEquals(1, player.inventory.count("imp_in_a_box_2")) + } + @Test fun `Imp refuses to bank another imp box`() { val player = createPlayer() From 0d77a82db620b17c0b6f2cb8b38a6ec4af7dad23 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 21:02:31 -0700 Subject: [PATCH 10/24] Add desert devil tracking --- data/skill/hunter/hunter.anims.toml | 3 + data/skill/hunter/hunter.objs.toml | 55 ++++++++ data/skill/hunter/hunter.varbits.toml | 61 +++++++++ data/skill/hunter/tracking.tables.toml | 125 ++++++++++++++++++ .../content/skill/hunter/KebbitTracking.kt | 32 +++-- .../skill/hunter/KebbitTrackingTest.kt | 73 ++++++++++ 6 files changed, 337 insertions(+), 12 deletions(-) diff --git a/data/skill/hunter/hunter.anims.toml b/data/skill/hunter/hunter.anims.toml index 0395cd3fbf..b5b401a281 100644 --- a/data/skill/hunter/hunter.anims.toml +++ b/data/skill/hunter/hunter.anims.toml @@ -110,3 +110,6 @@ id = 5259 [noose_fail] id = 5255 + +[noose_catch_desert] +id = 5257 diff --git a/data/skill/hunter/hunter.objs.toml b/data/skill/hunter/hunter.objs.toml index 044be728ef..c2a78f6531 100644 --- a/data/skill/hunter/hunter.objs.toml +++ b/data/skill/hunter/hunter.objs.toml @@ -424,3 +424,58 @@ id = 19640 [polar_kebbit_hole_2] id = 19641 + +# Desert devil tracking +[desert_cactus_0] +id = 19388 + +[desert_cactus_1] +id = 19389 + +[desert_cactus_2] +id = 19391 + +[desert_cactus_3] +id = 19392 + +[desert_cactus_4] +id = 19394 + +[desert_cactus_5] +id = 19395 + +[desert_cactus_6] +id = 19398 + +[desert_cactus_7] +id = 19399 + +[desert_cactus_8] +id = 19401 + +[desert_rockslide_0] +id = 19390 + +[desert_rockslide_1] +id = 19393 + +[desert_rockslide_2] +id = 19396 + +[desert_rockslide_3] +id = 19397 + +[desert_rockslide_4] +id = 19400 + +[desert_rockslide_5] +id = 19402 + +[disturbed_sand] +id = 19430 + +[desert_devil_burrow] +id = 19552 + +[desert_devil_burrow_2] +id = 19553 diff --git a/data/skill/hunter/hunter.varbits.toml b/data/skill/hunter/hunter.varbits.toml index 42ba65eaf8..dfb5ffaeba 100644 --- a/data/skill/hunter/hunter.varbits.toml +++ b/data/skill/hunter/hunter.varbits.toml @@ -162,3 +162,64 @@ format = "int" id = 3061 format = "int" + +[desert_devil_trail_0] +id = 3016 +format = "int" + +[desert_devil_trail_1] +id = 3017 +format = "int" + +[desert_devil_trail_2] +id = 3018 +format = "int" + +[desert_devil_trail_3] +id = 3019 +format = "int" + +[desert_devil_trail_4] +id = 3020 +format = "int" + +[desert_devil_trail_5] +id = 3021 +format = "int" + +[desert_devil_trail_6] +id = 3022 +format = "int" + +[desert_devil_trail_7] +id = 3023 +format = "int" + +[desert_devil_trail_8] +id = 3024 +format = "int" + +[desert_devil_trail_9] +id = 3025 +format = "int" + +[desert_devil_trail_10] +id = 3026 +format = "int" + +[desert_devil_trail_11] +id = 3027 +format = "int" + +[desert_devil_trail_12] +id = 3030 +format = "int" + +[desert_devil_trail_13] +id = 3031 +format = "int" + +[desert_devil_trail_14] +id = 3032 +format = "int" + diff --git a/data/skill/hunter/tracking.tables.toml b/data/skill/hunter/tracking.tables.toml index 0bd380ae75..73eac07436 100644 --- a/data/skill/hunter/tracking.tables.toml +++ b/data/skill/hunter/tracking.tables.toml @@ -5,12 +5,19 @@ xp = "int" # xp * 0.1 loot = "list" catch_anim = "anim" limit = "int" # maximum linking segments in a generated trail +finals = "list" # tiles of the attackable objects a trail can end at [.polar_kebbit] xp = 300 loot = ["raw_beast_meat", "polar_kebbit_fur", "bones"] catch_anim = "noose_catch_polar" limit = 3 +finals = [ + { x = 2708, y = 3819, level = 1 }, + { x = 2712, y = 3831, level = 1 }, + { x = 2716, y = 3827, level = 1 }, + { x = 2718, y = 3820, level = 1 }, +] [.common_kebbit] level = 3 @@ -18,6 +25,28 @@ xp = 360 loot = ["common_kebbit_fur", "bones", "raw_beast_meat"] catch_anim = "noose_catch_common" limit = 3 +finals = [ + { x = 2349, y = 3604 }, + { x = 2354, y = 3609 }, + { x = 2355, y = 3601 }, + { x = 2357, y = 3607 }, + { x = 2360, y = 3602 }, +] + +[.desert_devil] +level = 13 +xp = 660 +loot = ["bones", "raw_beast_meat", "desert_devil_fur"] +catch_anim = "noose_catch_desert" +limit = 3 +finals = [ + { x = 3393, y = 3122 }, + { x = 3400, y = 3114 }, + { x = 3405, y = 3124 }, + { x = 3407, y = 3121 }, + { x = 3411, y = 3108 }, + { x = 3414, y = 3121 }, +] # --------------------------------------------------------------------------- @@ -179,3 +208,99 @@ varbit = "polar_kebbit_trail_1" tunnel = true start = { x = 2708, y = 3819, level = 1 } end = { x = 2711, y = 3819, level = 1 } + +[.desert_7] +kebbit = "desert_devil" +varbit = "desert_devil_trail_7" +start = { x = 3396, y = 3106 } +end = { x = 3393, y = 3122 } +burrow = "desert_devil_burrow" + +[.desert_11] +kebbit = "desert_devil" +varbit = "desert_devil_trail_11" +start = { x = 3396, y = 3106 } +end = { x = 3400, y = 3114 } +burrow = "desert_devil_burrow" + +[.desert_14] +kebbit = "desert_devil" +varbit = "desert_devil_trail_14" +start = { x = 3396, y = 3106 } +end = { x = 3411, y = 3108 } +burrow = "desert_devil_burrow" + +[.desert_0] +kebbit = "desert_devil" +varbit = "desert_devil_trail_0" +start = { x = 3402, y = 3131 } +end = { x = 3393, y = 3122 } +burrow = "desert_devil_burrow_2" + +[.desert_1] +kebbit = "desert_devil" +varbit = "desert_devil_trail_1" +start = { x = 3402, y = 3131 } +end = { x = 3405, y = 3124 } +burrow = "desert_devil_burrow_2" + +[.desert_2] +kebbit = "desert_devil" +varbit = "desert_devil_trail_2" +start = { x = 3402, y = 3131 } +end = { x = 3414, y = 3121 } +burrow = "desert_devil_burrow_2" + +[.desert_3] +kebbit = "desert_devil" +varbit = "desert_devil_trail_3" +start = { x = 3396, y = 3121 } +end = { x = 3393, y = 3122 } + +[.desert_4] +kebbit = "desert_devil" +varbit = "desert_devil_trail_4" +start = { x = 3396, y = 3121 } +end = { x = 3405, y = 3122 } + +[.desert_5] +kebbit = "desert_devil" +varbit = "desert_devil_trail_5" +start = { x = 3405, y = 3122 } +end = { x = 3409, y = 3121 } + +[.desert_6] +kebbit = "desert_devil" +varbit = "desert_devil_trail_6" +start = { x = 3409, y = 3121 } +end = { x = 3414, y = 3121 } + +[.desert_8] +kebbit = "desert_devil" +varbit = "desert_devil_trail_8" +start = { x = 3396, y = 3121 } +end = { x = 3400, y = 3114 } + +[.desert_9] +kebbit = "desert_devil" +varbit = "desert_devil_trail_9" +start = { x = 3409, y = 3121 } +end = { x = 3407, y = 3121 } + +[.desert_10] +kebbit = "desert_devil" +varbit = "desert_devil_trail_10" +start = { x = 3411, y = 3114 } +end = { x = 3411, y = 3108 } + +[.desert_12] +kebbit = "desert_devil" +varbit = "desert_devil_trail_12" +start = { x = 3399, y = 3111 } +end = { x = 3406, y = 3111 } + +[.desert_13] +kebbit = "desert_devil" +varbit = "desert_devil_trail_13" +start = { x = 3406, y = 3111 } +end = { x = 3411, y = 3108 } diff --git a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt index fbf10476cc..e617a5fc97 100644 --- a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt +++ b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt @@ -33,19 +33,19 @@ class KebbitTracking : Script { private val trackingSteps = mutableMapOf() init { - objectOperate("Inspect", "common_kebbit_burrow,common_kebbit_burrow_2,polar_kebbit_hole,polar_kebbit_hole_2") { (target) -> + objectOperate("Inspect", "common_kebbit_burrow,common_kebbit_burrow_2,polar_kebbit_hole,polar_kebbit_hole_2,desert_devil_burrow,desert_devil_burrow_2") { (target) -> inspectBurrow(target) } - objectOperate("Inspect", "kebbit_tracks_plant_*,kebbit_tunnel_*,kebbit_hollow_log_*") { (target) -> + objectOperate("Inspect", "kebbit_tracks_plant_*,kebbit_tunnel_*,kebbit_hollow_log_*,desert_cactus_*,desert_rockslide_*") { (target) -> inspectTrail(target) } - objectOperate("Search", "kebbit_bush,kebbit_snow_drift") { (target) -> + objectOperate("Search", "kebbit_bush,kebbit_snow_drift,disturbed_sand") { (target) -> inspectTrail(target) } - objectOperate("Attack", "kebbit_bush,kebbit_snow_drift") { (target) -> + objectOperate("Attack", "kebbit_bush,kebbit_snow_drift,disturbed_sand") { (target) -> catch(target) } @@ -54,7 +54,11 @@ class KebbitTracking : Script { } } - private fun kebbit(burrow: String) = if (burrow.startsWith("polar")) "polar_kebbit" else "common_kebbit" + private fun kebbit(id: String) = when { + id.startsWith("polar") -> "polar_kebbit" + id.startsWith("desert") -> "desert_devil" + else -> "common_kebbit" + } private fun segments(kebbit: String): List = Tables.get("trails").rows().filter { it.string("kebbit") == kebbit }.map { row -> Segment( @@ -73,8 +77,8 @@ class KebbitTracking : Script { private fun linkingPool(kebbit: String): List { val base = segments(kebbit) val tunnels = tunnels(kebbit) - // Common kebbit starting segments only ever lead away from the burrow - val pool = if (kebbit == "common_kebbit") base.filter { it.burrow == null } else base + // Only polar kebbit trails can route back through a burrow's starting segments + val pool = if (kebbit == "polar_kebbit") base else base.filter { it.burrow == null } return pool + pool.map { inverse(it, tunnels) } } @@ -88,12 +92,12 @@ class KebbitTracking : Script { burrow = null, ) - private fun generate(kebbit: String, burrow: String, limit: Int): List? { + private fun generate(kebbit: String, burrow: String, limit: Int, finals: List): List? { val trail = mutableListOf(segments(kebbit).filter { it.burrow == burrow }.random(random)) val pool = linkingPool(kebbit) var spotsLeft = random.nextInt(2, limit + 1) - var tries = spotsLeft * 3 - while (spotsLeft > 0) { + var tries = 20 + while (spotsLeft > 0 || trail.last().end !in finals) { if (tries-- <= 0) { return null } @@ -144,7 +148,7 @@ class KebbitTracking : Script { if (!has(Skill.Hunter, row.int("level"), message = true)) { return } - val trail = generate(kebbit, target.id, row.int("limit")) + val trail = generate(kebbit, target.id, row.int("limit"), row.tileList("finals")) if (trail == null) { message("You search but find nothing of interest.") return @@ -193,7 +197,11 @@ class KebbitTracking : Script { if (step != trail.lastIndex || trail[step].end != target.tile) { anim("noose_fail") delay(2) - message("You fail to find anything with your noose wand.") + if (target.id == "disturbed_sand" && inventory.add("old_boot")) { + message("The trail was false. You find an old boot buried in the sand.") + } else { + message("You fail to find anything with your noose wand.") + } return } anim(row.anim("catch_anim")) diff --git a/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt b/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt index 6c287e5a2e..2e81e1b950 100644 --- a/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt +++ b/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt @@ -99,6 +99,79 @@ class KebbitTrackingTest : WorldTest() { assertEquals(4, player["common_kebbit_trail_0", 0]) } + // With FakeRandom the desert trail is always burrow -> trail_7 -> inv trail_3 -> trail_4 -> trail_5 -> trail_6, + // advanced at sand (3393,3122), rockslide (3405,3122), cactus (3409,3121), ending at sand (3414,3121) + @Test + fun `Track and catch a desert devil`() { + val player = createPlayer(Tile(3395, 3106)) + player.inventory.add("noose_wand") + player.inventory.add("waterskin_4", 3) + player.levels.set(Skill.Hunter, 13) + val burrow = createObject("desert_devil_burrow", Tile(3396, 3106)) + player.objectOption(burrow, "Inspect") + tick(5) + assertEquals(4, player["desert_devil_trail_7", 0]) + + val sand = createObject("disturbed_sand", Tile(3393, 3122)) + player.objectOption(sand, "Search") + tick(20) + assertEquals(5, player["desert_devil_trail_3", 0]) + + val rockslide = createObject("desert_rockslide_1", Tile(3405, 3122)) + player.objectOption(rockslide, "Inspect") + tick(20) + assertEquals(4, player["desert_devil_trail_4", 0]) + + val cactus = createObject("desert_cactus_4", Tile(3409, 3121)) + player.objectOption(cactus, "Inspect") + tick(15) + assertEquals(4, player["desert_devil_trail_5", 0]) + + val last = createObject("disturbed_sand", Tile(3414, 3121)) + player.objectOption(last, "Search") + tick(15) + assertEquals(4, player["desert_devil_trail_6", 0]) + player.objectOption(last, "Search") + tick(5) + assertTrue(player.containsMessage("something is moving around")) + + player.objectOption(last, "Attack") + tick(5) + assertEquals(1, player.inventory.count("desert_devil_fur")) + assertEquals(1, player.inventory.count("bones")) + assertEquals(1, player.inventory.count("raw_beast_meat")) + assertEquals(66.0, player.experience.get(Skill.Hunter)) + assertEquals(0, player["desert_devil_trail_7", 0]) + } + + @Test + fun `False trail digs up an old boot`() { + val player = createPlayer(Tile(3395, 3106)) + player.inventory.add("noose_wand") + player.inventory.add("waterskin_4", 3) + player.levels.set(Skill.Hunter, 13) + val burrow = createObject("desert_devil_burrow", Tile(3396, 3106)) + player.objectOption(burrow, "Inspect") + tick(5) + + val wrong = createObject("disturbed_sand", Tile(3400, 3114)) + player.objectOption(wrong, "Attack") + tick(15) + assertEquals(1, player.inventory.count("old_boot")) + assertEquals(0.0, player.experience.get(Skill.Hunter)) + assertEquals(4, player["desert_devil_trail_7", 0]) + } + + @Test + fun `Can't track a desert devil below level 13`() { + val player = createPlayer(Tile(3395, 3106)) + player.inventory.add("waterskin_4", 3) + val burrow = createObject("desert_devil_burrow", Tile(3396, 3106)) + player.objectOption(burrow, "Inspect") + tick(5) + assertEquals(0, player["desert_devil_trail_7", 0]) + } + @Test fun `Can't track a common kebbit below level 3`() { val player = createPlayer(Tile(2353, 3595)) From 11a4cf0816e0b885691aa999131b38bd25cc98f4 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 21:10:39 -0700 Subject: [PATCH 11/24] Fix trail not advancing at the end of inverted tracking segments --- .../content/skill/hunter/KebbitTracking.kt | 2 +- .../skill/hunter/KebbitTrackingTest.kt | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt index e617a5fc97..97986b843e 100644 --- a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt +++ b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt @@ -171,7 +171,7 @@ class KebbitTracking : Script { message("It looks like something is moving around in there.") return } - if (current.trigger == target.tile) { + if (current.trigger == target.tile || current.end == target.tile) { trackingSteps[accountName] = step + 1 updateTrail(this) message("You discover some tracks nearby.") diff --git a/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt b/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt index 2e81e1b950..edd3c60961 100644 --- a/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt +++ b/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt @@ -144,6 +144,25 @@ class KebbitTrackingTest : WorldTest() { assertEquals(0, player["desert_devil_trail_7", 0]) } + // The second desert segment runs inverted (sand at 3393,3122 back to the cactus at 3396,3121), + // so following the revealed tracks to the cactus must advance the trail too + @Test + fun `Inspecting the object at the end of an inverted segment advances the trail`() { + val player = createPlayer(Tile(3395, 3106)) + player.inventory.add("waterskin_4", 3) + player.levels.set(Skill.Hunter, 13) + val burrow = createObject("desert_devil_burrow", Tile(3396, 3106)) + player.objectOption(burrow, "Inspect") + tick(5) + assertEquals(4, player["desert_devil_trail_7", 0]) + + val cactus = createObject("desert_cactus_2", Tile(3396, 3121)) + player.objectOption(cactus, "Inspect") + tick(20) + assertEquals(5, player["desert_devil_trail_3", 0]) + assertTrue(player.containsMessage("You discover some tracks nearby")) + } + @Test fun `False trail digs up an old boot`() { val player = createPlayer(Tile(3395, 3106)) From 3ee6b2e70b163703b25868e3fd9c685be4411cf8 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 21:19:17 -0700 Subject: [PATCH 12/24] Fix desert trail segments that could dead-end before disturbed sand --- data/skill/hunter/tracking.tables.toml | 8 ++++---- .../main/kotlin/content/skill/hunter/KebbitTracking.kt | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/data/skill/hunter/tracking.tables.toml b/data/skill/hunter/tracking.tables.toml index 73eac07436..eb0b440955 100644 --- a/data/skill/hunter/tracking.tables.toml +++ b/data/skill/hunter/tracking.tables.toml @@ -284,19 +284,19 @@ end = { x = 3400, y = 3114 } [.desert_9] kebbit = "desert_devil" varbit = "desert_devil_trail_9" -start = { x = 3409, y = 3121 } +start = { x = 3406, y = 3111 } end = { x = 3407, y = 3121 } [.desert_10] kebbit = "desert_devil" varbit = "desert_devil_trail_10" -start = { x = 3411, y = 3114 } -end = { x = 3411, y = 3108 } +start = { x = 3411, y = 3108 } +end = { x = 3414, y = 3121 } [.desert_12] kebbit = "desert_devil" varbit = "desert_devil_trail_12" -start = { x = 3399, y = 3111 } +start = { x = 3400, y = 3114 } end = { x = 3406, y = 3111 } [.desert_13] diff --git a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt index 97986b843e..719af7bbaf 100644 --- a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt +++ b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt @@ -108,6 +108,9 @@ class KebbitTracking : Script { pool.filter { it.start == previous.end } }.filter { next -> trail.none { it.varbit == next.varbit } } if (possible.isEmpty()) { + if (trail.last().end in finals) { + return trail + } continue } val next = possible.random(random) From 9985c8dc5543c1b0087fabfbaf48e7a8ebb8fb45 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 21:32:01 -0700 Subject: [PATCH 13/24] Add feldip weasel tracking --- data/skill/hunter/hunter.objs.toml | 55 ++++++++ data/skill/hunter/hunter.varbits.toml | 61 +++++++++ data/skill/hunter/tracking.tables.toml | 125 ++++++++++++++++++ .../content/skill/hunter/KebbitTracking.kt | 26 ++-- .../skill/hunter/KebbitTrackingTest.kt | 43 ++++++ 5 files changed, 301 insertions(+), 9 deletions(-) diff --git a/data/skill/hunter/hunter.objs.toml b/data/skill/hunter/hunter.objs.toml index c2a78f6531..834d965e7b 100644 --- a/data/skill/hunter/hunter.objs.toml +++ b/data/skill/hunter/hunter.objs.toml @@ -479,3 +479,58 @@ id = 19552 [desert_devil_burrow_2] id = 19553 + +# Feldip weasel tracking +[feldip_plant_0] +id = 19403 + +[feldip_plant_1] +id = 19404 + +[feldip_plant_2] +id = 19405 + +[feldip_plant_3] +id = 19406 + +[feldip_plant_4] +id = 19407 + +[feldip_plant_5] +id = 19408 + +[feldip_plant_6] +id = 19409 + +[feldip_plant_7] +id = 19410 + +[feldip_plant_8] +id = 19411 + +[feldip_plant_9] +id = 19412 + +[feldip_plant_10] +id = 19413 + +[feldip_plant_11] +id = 19414 + +[feldip_plant_12] +id = 19415 + +[feldip_plant_13] +id = 19416 + +[feldip_plant_14] +id = 19417 + +[weasel_bush] +id = 19427 + +[feldip_weasel_burrow] +id = 19594 + +[feldip_weasel_burrow_2] +id = 19593 diff --git a/data/skill/hunter/hunter.varbits.toml b/data/skill/hunter/hunter.varbits.toml index dfb5ffaeba..6b2a074bcb 100644 --- a/data/skill/hunter/hunter.varbits.toml +++ b/data/skill/hunter/hunter.varbits.toml @@ -223,3 +223,64 @@ format = "int" id = 3032 format = "int" + +[feldip_weasel_trail_0] +id = 3036 +format = "int" + +[feldip_weasel_trail_1] +id = 3037 +format = "int" + +[feldip_weasel_trail_2] +id = 3038 +format = "int" + +[feldip_weasel_trail_3] +id = 3039 +format = "int" + +[feldip_weasel_trail_4] +id = 3040 +format = "int" + +[feldip_weasel_trail_5] +id = 3041 +format = "int" + +[feldip_weasel_trail_6] +id = 3043 +format = "int" + +[feldip_weasel_trail_7] +id = 3044 +format = "int" + +[feldip_weasel_trail_8] +id = 3045 +format = "int" + +[feldip_weasel_trail_9] +id = 3046 +format = "int" + +[feldip_weasel_trail_10] +id = 3047 +format = "int" + +[feldip_weasel_trail_11] +id = 3048 +format = "int" + +[feldip_weasel_trail_12] +id = 3049 +format = "int" + +[feldip_weasel_trail_13] +id = 3050 +format = "int" + +[feldip_weasel_trail_14] +id = 3051 +format = "int" + diff --git a/data/skill/hunter/tracking.tables.toml b/data/skill/hunter/tracking.tables.toml index eb0b440955..5786df55f4 100644 --- a/data/skill/hunter/tracking.tables.toml +++ b/data/skill/hunter/tracking.tables.toml @@ -33,6 +33,21 @@ finals = [ { x = 2360, y = 3602 }, ] +[.feldip_weasel] +level = 7 +xp = 480 +loot = ["feldip_weasel_fur", "bones", "raw_beast_meat"] +catch_anim = "noose_catch_desert" +limit = 3 +finals = [ + { x = 2525, y = 2882 }, + { x = 2531, y = 2890 }, + { x = 2533, y = 2885 }, + { x = 2540, y = 2886 }, + { x = 2542, y = 2881 }, + { x = 2553, y = 2888 }, +] + [.desert_devil] level = 13 xp = 660 @@ -304,3 +319,113 @@ kebbit = "desert_devil" varbit = "desert_devil_trail_13" start = { x = 3406, y = 3111 } end = { x = 3411, y = 3108 } + +[.feldip_0] +kebbit = "feldip_weasel" +varbit = "feldip_weasel_trail_0" +start = { x = 2525, y = 2889 } +end = { x = 2525, y = 2882 } +burrow = "feldip_weasel_burrow" + + +[.feldip_1] +kebbit = "feldip_weasel" +varbit = "feldip_weasel_trail_1" +start = { x = 2525, y = 2889 } +end = { x = 2525, y = 2882 } +burrow = "feldip_weasel_burrow" + + +[.feldip_2] +kebbit = "feldip_weasel" +varbit = "feldip_weasel_trail_2" +start = { x = 2525, y = 2889 } +end = { x = 2531, y = 2890 } +burrow = "feldip_weasel_burrow" + + +[.feldip_12] +kebbit = "feldip_weasel" +varbit = "feldip_weasel_trail_12" +start = { x = 2554, y = 2882 } +end = { x = 2542, y = 2881 } +burrow = "feldip_weasel_burrow_2" + + +[.feldip_13] +kebbit = "feldip_weasel" +varbit = "feldip_weasel_trail_13" +start = { x = 2554, y = 2882 } +end = { x = 2553, y = 2888 } +burrow = "feldip_weasel_burrow_2" + + +[.feldip_14] +kebbit = "feldip_weasel" +varbit = "feldip_weasel_trail_14" +start = { x = 2554, y = 2882 } +end = { x = 2553, y = 2888 } +burrow = "feldip_weasel_burrow_2" + + +[.feldip_3] +kebbit = "feldip_weasel" +varbit = "feldip_weasel_trail_3" +start = { x = 2531, y = 2890 } +end = { x = 2533, y = 2885 } + + +[.feldip_4] +kebbit = "feldip_weasel" +varbit = "feldip_weasel_trail_4" +start = { x = 2525, y = 2882 } +end = { x = 2533, y = 2882 } + + +[.feldip_5] +kebbit = "feldip_weasel" +varbit = "feldip_weasel_trail_5" +start = { x = 2533, y = 2882 } +end = { x = 2533, y = 2885 } + + +[.feldip_6] +kebbit = "feldip_weasel" +varbit = "feldip_weasel_trail_6" +start = { x = 2531, y = 2890 } +end = { x = 2539, y = 2890 } + + +[.feldip_7] +kebbit = "feldip_weasel" +varbit = "feldip_weasel_trail_7" +start = { x = 2533, y = 2885 } +end = { x = 2540, y = 2886 } + + +[.feldip_8] +kebbit = "feldip_weasel" +varbit = "feldip_weasel_trail_8" +start = { x = 2542, y = 2881 } +end = { x = 2533, y = 2882 } + + +[.feldip_9] +kebbit = "feldip_weasel" +varbit = "feldip_weasel_trail_9" +start = { x = 2540, y = 2886 } +end = { x = 2542, y = 2881 } + + +[.feldip_10] +kebbit = "feldip_weasel" +varbit = "feldip_weasel_trail_10" +start = { x = 2539, y = 2890 } +end = { x = 2540, y = 2886 } + + +[.feldip_11] +kebbit = "feldip_weasel" +varbit = "feldip_weasel_trail_11" +start = { x = 2543, y = 2890 } +end = { x = 2553, y = 2888 } diff --git a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt index 719af7bbaf..75b0b7ffaa 100644 --- a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt +++ b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt @@ -33,19 +33,19 @@ class KebbitTracking : Script { private val trackingSteps = mutableMapOf() init { - objectOperate("Inspect", "common_kebbit_burrow,common_kebbit_burrow_2,polar_kebbit_hole,polar_kebbit_hole_2,desert_devil_burrow,desert_devil_burrow_2") { (target) -> + objectOperate("Inspect", "common_kebbit_burrow,common_kebbit_burrow_2,polar_kebbit_hole,polar_kebbit_hole_2,desert_devil_burrow,desert_devil_burrow_2,feldip_weasel_burrow,feldip_weasel_burrow_2") { (target) -> inspectBurrow(target) } - objectOperate("Inspect", "kebbit_tracks_plant_*,kebbit_tunnel_*,kebbit_hollow_log_*,desert_cactus_*,desert_rockslide_*") { (target) -> + objectOperate("Inspect", "kebbit_tracks_plant_*,kebbit_tunnel_*,kebbit_hollow_log_*,desert_cactus_*,desert_rockslide_*,feldip_plant_*") { (target) -> inspectTrail(target) } - objectOperate("Search", "kebbit_bush,kebbit_snow_drift,disturbed_sand") { (target) -> + objectOperate("Search", "kebbit_bush,kebbit_snow_drift,disturbed_sand,weasel_bush") { (target) -> inspectTrail(target) } - objectOperate("Attack", "kebbit_bush,kebbit_snow_drift,disturbed_sand") { (target) -> + objectOperate("Attack", "kebbit_bush,kebbit_snow_drift,disturbed_sand,weasel_bush") { (target) -> catch(target) } @@ -57,6 +57,7 @@ class KebbitTracking : Script { private fun kebbit(id: String) = when { id.startsWith("polar") -> "polar_kebbit" id.startsWith("desert") -> "desert_devil" + id.startsWith("feldip") -> "feldip_weasel" else -> "common_kebbit" } @@ -99,7 +100,7 @@ class KebbitTracking : Script { var tries = 20 while (spotsLeft > 0 || trail.last().end !in finals) { if (tries-- <= 0) { - return null + return trim(trail, finals) } val previous = trail.last() val possible = if (previous.tunnel) { @@ -108,10 +109,7 @@ class KebbitTracking : Script { pool.filter { it.start == previous.end } }.filter { next -> trail.none { it.varbit == next.varbit } } if (possible.isEmpty()) { - if (trail.last().end in finals) { - return trail - } - continue + return trim(trail, finals) } val next = possible.random(random) trail.add(next) @@ -122,6 +120,16 @@ class KebbitTracking : Script { return trail } + private fun trim(trail: MutableList, finals: List): List? { + while (trail.size > 1 && trail.last().end !in finals) { + trail.removeAt(trail.lastIndex) + } + if (trail.last().end !in finals) { + return null + } + return trail + } + private fun updateTrail(player: Player) { val trail = trackingTrails[player.accountName] ?: return val step = trackingSteps[player.accountName] ?: 0 diff --git a/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt b/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt index edd3c60961..bc9fa562f0 100644 --- a/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt +++ b/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt @@ -191,6 +191,49 @@ class KebbitTrackingTest : WorldTest() { assertEquals(0, player["desert_devil_trail_7", 0]) } + // With FakeRandom the feldip trail is always burrow -> trail_0 -> trail_4 -> trail_5, + // advanced at the plant (2533,2882), ending at the bush (2533,2885) + @Test + fun `Track and catch a feldip weasel`() { + val player = createPlayer(Tile(2524, 2889)) + player.inventory.add("noose_wand") + player.levels.set(Skill.Hunter, 7) + val burrow = createObject("feldip_weasel_burrow", Tile(2525, 2889)) + player.objectOption(burrow, "Inspect") + tick(5) + assertEquals(4, player["feldip_weasel_trail_0", 0]) + + val plant = createObject("feldip_plant_5", Tile(2533, 2882)) + player.objectOption(plant, "Inspect") + tick(15) + assertEquals(4, player["feldip_weasel_trail_4", 0]) + + val bush = createObject("weasel_bush", Tile(2533, 2885)) + player.objectOption(bush, "Search") + tick(10) + assertEquals(4, player["feldip_weasel_trail_5", 0]) + player.objectOption(bush, "Search") + tick(5) + assertTrue(player.containsMessage("something is moving around")) + + player.objectOption(bush, "Attack") + tick(5) + assertEquals(1, player.inventory.count("feldip_weasel_fur")) + assertEquals(1, player.inventory.count("bones")) + assertEquals(1, player.inventory.count("raw_beast_meat")) + assertEquals(48.0, player.experience.get(Skill.Hunter)) + assertEquals(0, player["feldip_weasel_trail_0", 0]) + } + + @Test + fun `Can't track a feldip weasel below level 7`() { + val player = createPlayer(Tile(2524, 2889)) + val burrow = createObject("feldip_weasel_burrow", Tile(2525, 2889)) + player.objectOption(burrow, "Inspect") + tick(5) + assertEquals(0, player["feldip_weasel_trail_0", 0]) + } + @Test fun `Can't track a common kebbit below level 3`() { val player = createPlayer(Tile(2353, 3595)) From e7b28601b379f2dbef8492767a59daf7c15dcc0a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 21:38:33 -0700 Subject: [PATCH 14/24] Fix tracking step advancing past the final trail segment --- .../content/skill/hunter/KebbitTracking.kt | 14 +++++--- .../skill/hunter/KebbitTrackingTest.kt | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt index 75b0b7ffaa..2b9c274911 100644 --- a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt +++ b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt @@ -176,12 +176,16 @@ class KebbitTracking : Script { message("You search but find nothing.") return } - val step = trackingSteps[accountName] ?: 0 - val current = if (step < trail.lastIndex) trail[step + 1] else trail[step] - if (step == trail.lastIndex && current.end == target.tile) { - message("It looks like something is moving around in there.") + val step = (trackingSteps[accountName] ?: 0).coerceAtMost(trail.lastIndex) + if (step == trail.lastIndex) { + if (trail[step].end == target.tile) { + message("It looks like something is moving around in there.") + } else { + message("You search but find nothing of interest.") + } return } + val current = trail[step + 1] if (current.trigger == target.tile || current.end == target.tile) { trackingSteps[accountName] = step + 1 updateTrail(this) @@ -201,7 +205,7 @@ class KebbitTracking : Script { message("You need a noose wand to catch the kebbit.") return } - val step = trackingSteps[accountName] ?: 0 + val step = (trackingSteps[accountName] ?: 0).coerceAtMost(trail.lastIndex) val kebbit = kebbit(trail.first().varbit) val row = Rows.get("tracking.$kebbit") sound("hunting_noose") diff --git a/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt b/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt index bc9fa562f0..bd68578694 100644 --- a/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt +++ b/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt @@ -144,6 +144,38 @@ class KebbitTrackingTest : WorldTest() { assertEquals(0, player["desert_devil_trail_7", 0]) } + // The common trail's final segment has a side trigger at (2352,3603) — re-inspecting it once + // the trail is fully revealed must not advance the step past the final segment + @Test + fun `Re-inspecting the final segment's trigger doesn't break the trail`() { + val player = createPlayer(Tile(2353, 3595)) + player.inventory.add("noose_wand") + player.levels.set(Skill.Hunter, 3) + startTrail(player) + + val first = createObject("kebbit_tracks_plant_0", Tile(2358, 3599)) + player.objectOption(first, "Inspect") + tick(10) + val second = createObject("kebbit_tracks_plant_1", Tile(2352, 3603)) + player.objectOption(second, "Inspect") + tick(10) + assertEquals(4, player["common_kebbit_trail_4", 0]) + + player.objectOption(second, "Inspect") + tick(10) + assertTrue(player.containsMessage("You search but find nothing of interest")) + + val bush = createObject("kebbit_bush", Tile(2349, 3604)) + player.objectOption(bush, "Search") + tick(10) + assertTrue(player.containsMessage("something is moving around")) + + player.objectOption(bush, "Attack") + tick(5) + assertEquals(1, player.inventory.count("common_kebbit_fur")) + assertEquals(36.0, player.experience.get(Skill.Hunter)) + } + // The second desert segment runs inverted (sand at 3393,3122 back to the cactus at 3396,3121), // so following the revealed tracks to the cactus must advance the trail too @Test From 6415c811aacd67ce2c67386fd90dcd43db29ae46 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 31 Aug 2026 06:12:14 -0700 Subject: [PATCH 15/24] Add razor-backed kebbit tracking and north common kebbit trails --- data/skill/hunter/hunter.objs.toml | 68 ++++++ data/skill/hunter/hunter.varbits.toml | 105 +++++++++ data/skill/hunter/tracking.tables.toml | 214 ++++++++++++++++++ .../content/skill/hunter/KebbitTracking.kt | 7 +- .../skill/hunter/KebbitTrackingTest.kt | 69 ++++++ .../world/gregs/voidps/tools/tmp/DumpPisc.kt | 52 +++++ 6 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 tools/src/main/kotlin/world/gregs/voidps/tools/tmp/DumpPisc.kt diff --git a/data/skill/hunter/hunter.objs.toml b/data/skill/hunter/hunter.objs.toml index 834d965e7b..f88c79b5c6 100644 --- a/data/skill/hunter/hunter.objs.toml +++ b/data/skill/hunter/hunter.objs.toml @@ -534,3 +534,71 @@ id = 19594 [feldip_weasel_burrow_2] id = 19593 + +# Razor-backed kebbit tracking + north common kebbit burrow +[common_kebbit_burrow_3] +id = 19438 + +[razor_backed_kebbit_burrow] +id = 19492 + +[razor_backed_kebbit_burrow_2] +id = 19493 + +[razor_backed_kebbit_burrow_3] +id = 19494 + +[razor_kebbit_bush] +id = 19429 + +[kebbit_tracks_plant_19] +id = 19339 + +[kebbit_tracks_plant_20] +id = 19340 + +[kebbit_tracks_plant_21] +id = 19341 + +[kebbit_tracks_plant_22] +id = 19342 + +[kebbit_tracks_plant_23] +id = 19343 + +[kebbit_tracks_plant_24] +id = 19344 + +[kebbit_tracks_plant_25] +id = 19345 + +[kebbit_tracks_plant_26] +id = 19346 + +[kebbit_tracks_plant_27] +id = 19347 + +[kebbit_tracks_plant_28] +id = 19348 + +[kebbit_tracks_plant_29] +id = 19349 + +[kebbit_tracks_plant_30] +id = 19350 + +[kebbit_tracks_plant_31] +id = 19351 + +[kebbit_tracks_plant_32] +id = 19352 + +[kebbit_tracks_plant_33] +id = 19353 + +[kebbit_tracks_plant_34] +id = 19354 + +[kebbit_tracks_plant_35] +id = 19355 + diff --git a/data/skill/hunter/hunter.varbits.toml b/data/skill/hunter/hunter.varbits.toml index 6b2a074bcb..d8ab194c27 100644 --- a/data/skill/hunter/hunter.varbits.toml +++ b/data/skill/hunter/hunter.varbits.toml @@ -284,3 +284,108 @@ format = "int" id = 3051 format = "int" + +[common_kebbit_trail_10] +id = 2985 +format = "int" + +[common_kebbit_trail_11] +id = 2986 +format = "int" + +[common_kebbit_trail_12] +id = 2987 +format = "int" + +[common_kebbit_trail_13] +id = 2988 +format = "int" + +[common_kebbit_trail_14] +id = 2989 +format = "int" + +[common_kebbit_trail_15] +id = 2990 +format = "int" + +[common_kebbit_trail_16] +id = 2991 +format = "int" + +[common_kebbit_trail_17] +id = 2992 +format = "int" + +[common_kebbit_trail_18] +id = 2993 +format = "int" + +[razor_backed_kebbit_trail_0] +id = 2995 +format = "int" + +[razor_backed_kebbit_trail_1] +id = 2996 +format = "int" + +[razor_backed_kebbit_trail_2] +id = 2997 +format = "int" + +[razor_backed_kebbit_trail_3] +id = 2998 +format = "int" + +[razor_backed_kebbit_trail_4] +id = 2999 +format = "int" + +[razor_backed_kebbit_trail_5] +id = 3000 +format = "int" + +[razor_backed_kebbit_trail_6] +id = 3001 +format = "int" + +[razor_backed_kebbit_trail_7] +id = 3002 +format = "int" + +[razor_backed_kebbit_trail_8] +id = 3003 +format = "int" + +[razor_backed_kebbit_trail_9] +id = 3004 +format = "int" + +[razor_backed_kebbit_trail_10] +id = 3007 +format = "int" + +[razor_backed_kebbit_trail_11] +id = 3008 +format = "int" + +[razor_backed_kebbit_trail_12] +id = 3009 +format = "int" + +[razor_backed_kebbit_trail_13] +id = 3010 +format = "int" + +[razor_backed_kebbit_trail_14] +id = 3011 +format = "int" + +[razor_backed_kebbit_trail_15] +id = 3012 +format = "int" + +[razor_backed_kebbit_trail_16] +id = 3013 +format = "int" + diff --git a/data/skill/hunter/tracking.tables.toml b/data/skill/hunter/tracking.tables.toml index 5786df55f4..1431c98be8 100644 --- a/data/skill/hunter/tracking.tables.toml +++ b/data/skill/hunter/tracking.tables.toml @@ -27,10 +27,13 @@ catch_anim = "noose_catch_common" limit = 3 finals = [ { x = 2349, y = 3604 }, + { x = 2351, y = 3619 }, { x = 2354, y = 3609 }, { x = 2355, y = 3601 }, { x = 2357, y = 3607 }, + { x = 2358, y = 3620 }, { x = 2360, y = 3602 }, + { x = 2362, y = 3615 }, ] [.feldip_weasel] @@ -48,6 +51,23 @@ finals = [ { x = 2553, y = 2888 }, ] +[.razor_backed_kebbit] +level = 49 +xp = 3480 +loot = ["long_kebbit_spike", "raw_beast_meat", "bones"] +catch_anim = "noose_catch_desert" +limit = 3 +finals = [ + { x = 2322, y = 3570 }, + { x = 2323, y = 3563 }, + { x = 2327, y = 3573 }, + { x = 2332, y = 3568 }, + { x = 2332, y = 3578 }, + { x = 2336, y = 3571 }, + { x = 2337, y = 3565 }, + { x = 2343, y = 3568 }, +] + [.desert_devil] level = 13 xp = 660 @@ -429,3 +449,197 @@ kebbit = "feldip_weasel" varbit = "feldip_weasel_trail_11" start = { x = 2543, y = 2890 } end = { x = 2553, y = 2888 } + +[.common_10] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_10" +start = { x = 2357, y = 3624 } +end = { x = 2362, y = 3615 } +burrow = "common_kebbit_burrow_3" + + +[.common_11] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_11" +start = { x = 2357, y = 3624 } +end = { x = 2351, y = 3619 } +burrow = "common_kebbit_burrow_3" + + +[.common_12] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_12" +start = { x = 2357, y = 3624 } +end = { x = 2356, y = 3620 } +burrow = "common_kebbit_burrow_3" + + +[.common_17] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_17" +start = { x = 2360, y = 3611 } +end = { x = 2362, y = 3615 } +burrow = "common_kebbit_burrow_2" + + +[.common_13] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_13" +start = { x = 2344, y = 3612 } +end = { x = 2349, y = 3604 } + + +[.common_14] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_14" +start = { x = 2354, y = 3609 } +end = { x = 2344, y = 3612 } + + +[.common_15] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_15" +start = { x = 2344, y = 3612 } +end = { x = 2351, y = 3619 } + + +[.common_16] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_16" +start = { x = 2356, y = 3620 } +end = { x = 2351, y = 3619 } + + +[.common_18] +kebbit = "common_kebbit" +varbit = "common_kebbit_trail_18" +start = { x = 2362, y = 3615 } +end = { x = 2358, y = 3620 } + + +[.razor_0] +kebbit = "razor_backed_kebbit" +varbit = "razor_backed_kebbit_trail_0" +start = { x = 2331, y = 3562 } +end = { x = 2323, y = 3563 } +burrow = "razor_backed_kebbit_burrow" + + +[.razor_1] +kebbit = "razor_backed_kebbit" +varbit = "razor_backed_kebbit_trail_1" +start = { x = 2331, y = 3562 } +end = { x = 2332, y = 3568 } +burrow = "razor_backed_kebbit_burrow" + + +[.razor_2] +kebbit = "razor_backed_kebbit" +varbit = "razor_backed_kebbit_trail_2" +start = { x = 2331, y = 3562 } +end = { x = 2337, y = 3565 } +burrow = "razor_backed_kebbit_burrow" + + +[.razor_9] +kebbit = "razor_backed_kebbit" +varbit = "razor_backed_kebbit_trail_9" +start = { x = 2322, y = 3576 } +end = { x = 2322, y = 3570 } +burrow = "razor_backed_kebbit_burrow_2" + + +[.razor_12] +kebbit = "razor_backed_kebbit" +varbit = "razor_backed_kebbit_trail_12" +start = { x = 2322, y = 3576 } +end = { x = 2327, y = 3573 } +burrow = "razor_backed_kebbit_burrow_2" + + +[.razor_13] +kebbit = "razor_backed_kebbit" +varbit = "razor_backed_kebbit_trail_13" +start = { x = 2322, y = 3576 } +end = { x = 2332, y = 3578 } +burrow = "razor_backed_kebbit_burrow_2" + + +[.razor_8] +kebbit = "razor_backed_kebbit" +varbit = "razor_backed_kebbit_trail_8" +start = { x = 2341, y = 3577 } +end = { x = 2343, y = 3568 } +burrow = "razor_backed_kebbit_burrow_3" + + +[.razor_15] +kebbit = "razor_backed_kebbit" +varbit = "razor_backed_kebbit_trail_15" +start = { x = 2341, y = 3577 } +end = { x = 2336, y = 3571 } +burrow = "razor_backed_kebbit_burrow_3" + + +[.razor_16] +kebbit = "razor_backed_kebbit" +varbit = "razor_backed_kebbit_trail_16" +start = { x = 2341, y = 3577 } +end = { x = 2332, y = 3578 } +burrow = "razor_backed_kebbit_burrow_3" + + +[.razor_3] +kebbit = "razor_backed_kebbit" +varbit = "razor_backed_kebbit_trail_3" +start = { x = 2337, y = 3565 } +end = { x = 2343, y = 3568 } + + +[.razor_4] +kebbit = "razor_backed_kebbit" +varbit = "razor_backed_kebbit_trail_4" +start = { x = 2322, y = 3570 } +end = { x = 2323, y = 3563 } + + +[.razor_5] +kebbit = "razor_backed_kebbit" +varbit = "razor_backed_kebbit_trail_5" +start = { x = 2323, y = 3563 } +end = { x = 2332, y = 3568 } + + +[.razor_6] +kebbit = "razor_backed_kebbit" +varbit = "razor_backed_kebbit_trail_6" +start = { x = 2337, y = 3565 } +end = { x = 2336, y = 3571 } + + +[.razor_7] +kebbit = "razor_backed_kebbit" +varbit = "razor_backed_kebbit_trail_7" +start = { x = 2343, y = 3568 } +end = { x = 2336, y = 3571 } + + +[.razor_10] +kebbit = "razor_backed_kebbit" +varbit = "razor_backed_kebbit_trail_10" +start = { x = 2327, y = 3573 } +end = { x = 2322, y = 3570 } + + +[.razor_11] +kebbit = "razor_backed_kebbit" +varbit = "razor_backed_kebbit_trail_11" +start = { x = 2327, y = 3573 } +end = { x = 2332, y = 3568 } + + +[.razor_14] +kebbit = "razor_backed_kebbit" +varbit = "razor_backed_kebbit_trail_14" +start = { x = 2332, y = 3578 } +end = { x = 2336, y = 3571 } diff --git a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt index 2b9c274911..7aa91d7129 100644 --- a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt +++ b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt @@ -33,7 +33,7 @@ class KebbitTracking : Script { private val trackingSteps = mutableMapOf() init { - objectOperate("Inspect", "common_kebbit_burrow,common_kebbit_burrow_2,polar_kebbit_hole,polar_kebbit_hole_2,desert_devil_burrow,desert_devil_burrow_2,feldip_weasel_burrow,feldip_weasel_burrow_2") { (target) -> + objectOperate("Inspect", "common_kebbit_burrow,common_kebbit_burrow_2,common_kebbit_burrow_3,polar_kebbit_hole,polar_kebbit_hole_2,desert_devil_burrow,desert_devil_burrow_2,feldip_weasel_burrow,feldip_weasel_burrow_2,razor_backed_kebbit_burrow,razor_backed_kebbit_burrow_2,razor_backed_kebbit_burrow_3") { (target) -> inspectBurrow(target) } @@ -41,11 +41,11 @@ class KebbitTracking : Script { inspectTrail(target) } - objectOperate("Search", "kebbit_bush,kebbit_snow_drift,disturbed_sand,weasel_bush") { (target) -> + objectOperate("Search", "kebbit_bush,kebbit_snow_drift,disturbed_sand,weasel_bush,razor_kebbit_bush") { (target) -> inspectTrail(target) } - objectOperate("Attack", "kebbit_bush,kebbit_snow_drift,disturbed_sand,weasel_bush") { (target) -> + objectOperate("Attack", "kebbit_bush,kebbit_snow_drift,disturbed_sand,weasel_bush,razor_kebbit_bush") { (target) -> catch(target) } @@ -58,6 +58,7 @@ class KebbitTracking : Script { id.startsWith("polar") -> "polar_kebbit" id.startsWith("desert") -> "desert_devil" id.startsWith("feldip") -> "feldip_weasel" + id.startsWith("razor") -> "razor_backed_kebbit" else -> "common_kebbit" } diff --git a/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt b/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt index bd68578694..3c357b8ddf 100644 --- a/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt +++ b/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt @@ -266,6 +266,75 @@ class KebbitTrackingTest : WorldTest() { assertEquals(0, player["feldip_weasel_trail_0", 0]) } + // With FakeRandom the razor trail is always burrow -> trail_0 -> trail_5 -> inverse trail_11, + // advanced at bushes (2323,3563) then (2332,3568), ending at the bush (2327,3573) + @Test + fun `Track and catch a razor-backed kebbit`() { + val player = createPlayer(Tile(2330, 3562)) + player.inventory.add("noose_wand") + player.levels.set(Skill.Hunter, 49) + val burrow = createObject("razor_backed_kebbit_burrow", Tile(2331, 3562)) + player.objectOption(burrow, "Inspect") + tick(5) + assertEquals(4, player["razor_backed_kebbit_trail_0", 0]) + + val first = createObject("razor_kebbit_bush", Tile(2332, 3568)) + player.objectOption(first, "Search") + tick(15) + assertEquals(4, player["razor_backed_kebbit_trail_5", 0]) + + val last = createObject("razor_kebbit_bush", Tile(2327, 3573)) + player.objectOption(last, "Search") + tick(15) + assertEquals(5, player["razor_backed_kebbit_trail_11", 0]) + player.objectOption(last, "Search") + tick(5) + assertTrue(player.containsMessage("something is moving around")) + + player.objectOption(last, "Attack") + tick(5) + assertEquals(1, player.inventory.count("long_kebbit_spike")) + assertEquals(1, player.inventory.count("raw_beast_meat")) + assertEquals(1, player.inventory.count("bones")) + assertEquals(348.0, player.experience.get(Skill.Hunter)) + assertEquals(0, player["razor_backed_kebbit_trail_0", 0]) + } + + // With FakeRandom the north burrow trail is burrow -> trail_10 -> trail_18, trimmed short + // because nothing links onward from the bush at (2358,3620) + @Test + fun `Track a common kebbit from the north burrow`() { + val player = createPlayer(Tile(2356, 3624)) + player.inventory.add("noose_wand") + player.levels.set(Skill.Hunter, 3) + val burrow = createObject("common_kebbit_burrow_3", Tile(2357, 3624)) + player.objectOption(burrow, "Inspect") + tick(5) + assertEquals(4, player["common_kebbit_trail_10", 0]) + + val last = createObject("kebbit_bush", Tile(2358, 3620)) + player.objectOption(last, "Search") + tick(15) + assertEquals(4, player["common_kebbit_trail_18", 0]) + player.objectOption(last, "Search") + tick(5) + assertTrue(player.containsMessage("something is moving around")) + + player.objectOption(last, "Attack") + tick(5) + assertEquals(1, player.inventory.count("common_kebbit_fur")) + assertEquals(36.0, player.experience.get(Skill.Hunter)) + } + + @Test + fun `Can't track a razor-backed kebbit below level 49`() { + val player = createPlayer(Tile(2330, 3562)) + val burrow = createObject("razor_backed_kebbit_burrow", Tile(2331, 3562)) + player.objectOption(burrow, "Inspect") + tick(5) + assertEquals(0, player["razor_backed_kebbit_trail_0", 0]) + } + @Test fun `Can't track a common kebbit below level 3`() { val player = createPlayer(Tile(2353, 3595)) diff --git a/tools/src/main/kotlin/world/gregs/voidps/tools/tmp/DumpPisc.kt b/tools/src/main/kotlin/world/gregs/voidps/tools/tmp/DumpPisc.kt new file mode 100644 index 0000000000..dd6654bc9a --- /dev/null +++ b/tools/src/main/kotlin/world/gregs/voidps/tools/tmp/DumpPisc.kt @@ -0,0 +1,52 @@ +package world.gregs.voidps.tools.tmp + +import world.gregs.voidps.cache.CacheDelegate +import world.gregs.voidps.cache.definition.decoder.ObjectDecoder +import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.tools.cache.Xteas +import world.gregs.voidps.tools.map.MapDecoder + +object DumpPisc { + + @JvmStatic + fun main(args: Array) { + Settings.load() + val cache = CacheDelegate(Settings["storage.cache.path"]) + val objects = ObjectDecoder(member = true, lowDetail = false).load(cache) + val decals = mutableMapOf() + for (def in objects) { + if (def.varbit in 2984..3015) { + decals[def.id] = def.varbit + } + } + val interact = mutableSetOf() + for (def in objects) { + val options = def.options ?: continue + if (options.any { it == "Inspect" || it == "Search" || it == "Attack" } && def.name != "null" && def.id in 19000..19999) { + interact.add(def.id) + } + } + val decoder = MapDecoder(Xteas()) + decoder.modified = false + val maps = decoder.load(cache) + val regions = mutableSetOf() + for (rx in 35..37) { + for (ry in 54..57) { + regions.add((rx shl 8) or ry) + } + } + for (definition in maps) { + if (definition.id !in regions) { + continue + } + val regionX = (definition.id shr 8) shl 6 + val regionY = (definition.id and 0xff) shl 6 + for (obj in definition.objects) { + if (obj.id in decals || obj.id in interact) { + val def = objects.getOrNull(obj.id) + println("map obj=${obj.id} '${def?.name}' varbit=${decals[obj.id] ?: -1} x=${regionX + obj.x} y=${regionY + obj.y} level=${obj.level}") + } + } + } + } +} From bc32aaa42376931b86663a5e0c8ec65cfb2c8f50 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 31 Aug 2026 08:08:17 -0700 Subject: [PATCH 16/24] Remove temporary Piscatoris cache dump tool --- .../world/gregs/voidps/tools/tmp/DumpPisc.kt | 52 ------------------- 1 file changed, 52 deletions(-) delete mode 100644 tools/src/main/kotlin/world/gregs/voidps/tools/tmp/DumpPisc.kt diff --git a/tools/src/main/kotlin/world/gregs/voidps/tools/tmp/DumpPisc.kt b/tools/src/main/kotlin/world/gregs/voidps/tools/tmp/DumpPisc.kt deleted file mode 100644 index dd6654bc9a..0000000000 --- a/tools/src/main/kotlin/world/gregs/voidps/tools/tmp/DumpPisc.kt +++ /dev/null @@ -1,52 +0,0 @@ -package world.gregs.voidps.tools.tmp - -import world.gregs.voidps.cache.CacheDelegate -import world.gregs.voidps.cache.definition.decoder.ObjectDecoder -import world.gregs.voidps.engine.data.Settings -import world.gregs.voidps.tools.cache.Xteas -import world.gregs.voidps.tools.map.MapDecoder - -object DumpPisc { - - @JvmStatic - fun main(args: Array) { - Settings.load() - val cache = CacheDelegate(Settings["storage.cache.path"]) - val objects = ObjectDecoder(member = true, lowDetail = false).load(cache) - val decals = mutableMapOf() - for (def in objects) { - if (def.varbit in 2984..3015) { - decals[def.id] = def.varbit - } - } - val interact = mutableSetOf() - for (def in objects) { - val options = def.options ?: continue - if (options.any { it == "Inspect" || it == "Search" || it == "Attack" } && def.name != "null" && def.id in 19000..19999) { - interact.add(def.id) - } - } - val decoder = MapDecoder(Xteas()) - decoder.modified = false - val maps = decoder.load(cache) - val regions = mutableSetOf() - for (rx in 35..37) { - for (ry in 54..57) { - regions.add((rx shl 8) or ry) - } - } - for (definition in maps) { - if (definition.id !in regions) { - continue - } - val regionX = (definition.id shr 8) shl 6 - val regionY = (definition.id and 0xff) shl 6 - for (obj in definition.objects) { - if (obj.id in decals || obj.id in interact) { - val def = objects.getOrNull(obj.id) - println("map obj=${obj.id} '${def?.name}' varbit=${decals[obj.id] ?: -1} x=${regionX + obj.x} y=${regionY + obj.y} level=${obj.level}") - } - } - } - } -} From a6878bd170bf8872eb72c399a1fb61de4361c98a Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 31 Aug 2026 11:11:31 -0700 Subject: [PATCH 17/24] Fix trap cleanup on logout, stacked loot space checks and tracking edge cases --- .../kotlin/content/skill/hunter/BirdSnare.kt | 8 ++- .../kotlin/content/skill/hunter/BoxTrap.kt | 40 ++++++++------ .../content/skill/hunter/DeadfallTrap.kt | 13 ++++- .../content/skill/hunter/KebbitTracking.kt | 16 +++--- .../kotlin/content/skill/hunter/MagicBox.kt | 12 ++++- .../kotlin/content/skill/hunter/NetTrap.kt | 11 +++- .../content/skill/hunter/BoxTrapTest.kt | 53 +++++++++++++++++++ .../skill/hunter/KebbitTrackingTest.kt | 46 ++++++++++++++++ 8 files changed, 170 insertions(+), 29 deletions(-) diff --git a/game/src/main/kotlin/content/skill/hunter/BirdSnare.kt b/game/src/main/kotlin/content/skill/hunter/BirdSnare.kt index b8e1d212ac..fe1d71679d 100644 --- a/game/src/main/kotlin/content/skill/hunter/BirdSnare.kt +++ b/game/src/main/kotlin/content/skill/hunter/BirdSnare.kt @@ -107,10 +107,14 @@ class BirdSnare : Script { } npcDespawn("hunting_ojibway_trap_npc") { - val player = owner ?: return@npcDespawn val trap = GameObjects.getLayer(tile, ObjectLayer.GROUND) ?: return@npcDespawn - player.dec("trap_count") GameObjects.remove(trap) + val player = owner + if (player == null) { + FloorItems.add(trap.tile, "bird_snare") + return@npcDespawn + } + player.dec("trap_count") val drop = if (lifecycle == 0) { player.message("The bird snare that you laid has fallen over.") true diff --git a/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt b/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt index 70a9d2080c..a9e044284f 100644 --- a/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt +++ b/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt @@ -31,6 +31,7 @@ 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.transact.operation.AddItem.add class BoxTrap : Script { init { @@ -106,16 +107,25 @@ class BoxTrap : Script { return@huntNPC } target.levels.set(Skill.Constitution, 0) + clear("bait") 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 bait: String? = get("bait") + val player = owner + if (player == null) { + FloorItems.add(trap.tile, "box_trap") + if (bait != null) { + FloorItems.add(trap.tile, bait) + } + return@npcDespawn + } + player.dec("trap_count") val drop = if (lifecycle == 0) { player.message("The box trap that you laid has fallen over.") true @@ -124,7 +134,6 @@ class BoxTrap : Script { } if (drop) { player.drop(trap.tile, "box_trap") - val bait: String? = get("bait") if (bait != null) { player.drop(trap.tile, bait) } @@ -172,6 +181,10 @@ class BoxTrap : Script { anim("lay_trap") sound("lay_box_trap") delay(3) + if (GameObjects.getLayer(tile, ObjectLayer.GROUND) != null) { + message("You can't lay a trap here.", ChatType.Filter) + return + } if (floorItem != null) { FloorItems.remove(floorItem) } else { @@ -195,24 +208,21 @@ class BoxTrap : Script { if (loot.isEmpty() && 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) + val added = inventory.transaction { + for (item in items + loot) { + add(item) + } } + if (!added) { + message("You don't have enough inventory space.") + return + } + collapse(npc, target) 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) } diff --git a/game/src/main/kotlin/content/skill/hunter/DeadfallTrap.kt b/game/src/main/kotlin/content/skill/hunter/DeadfallTrap.kt index c6e4c493bd..5cf08215bf 100644 --- a/game/src/main/kotlin/content/skill/hunter/DeadfallTrap.kt +++ b/game/src/main/kotlin/content/skill/hunter/DeadfallTrap.kt @@ -22,6 +22,7 @@ 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.FloorItems import world.gregs.voidps.engine.entity.obj.* import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.inventory @@ -92,14 +93,18 @@ class DeadfallTrap : Script { } 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 } + GameObjects.remove(trap) + val player = owner + if (player == null) { + FloorItems.add(trap.tile, "logs") + 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 @@ -143,6 +148,10 @@ class DeadfallTrap : Script { anim("lay_trap") sound("set_deadfall") delay(3) + if (NPCs.findOrNull(target.tile, "hunting_deadfall_trap_npc") != null) { + message("You can't lay a trap here.", ChatType.Filter) + return + } inventory.remove("logs") inc("trap_count") inc("deadfall_count") diff --git a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt index 7aa91d7129..cfdf7b127c 100644 --- a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt +++ b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt @@ -13,6 +13,7 @@ import world.gregs.voidps.engine.entity.character.sound import world.gregs.voidps.engine.entity.obj.GameObject import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.transact.operation.AddItem.add import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot import world.gregs.voidps.type.Tile import world.gregs.voidps.type.random @@ -151,15 +152,12 @@ class KebbitTracking : Script { } private fun Player.inspectBurrow(target: GameObject) { - if (trackingTrails.containsKey(accountName)) { - inspectTrail(target) - return - } val kebbit = kebbit(target.id) val row = Rows.get("tracking.$kebbit") if (!has(Skill.Hunter, row.int("level"), message = true)) { return } + clearTrail(this) val trail = generate(kebbit, target.id, row.int("limit"), row.tileList("finals")) if (trail == null) { message("You search but find nothing of interest.") @@ -222,8 +220,14 @@ class KebbitTracking : Script { } anim(row.anim("catch_anim")) delay(2) - for (item in row.itemList("loot")) { - inventory.add(item) + val added = inventory.transaction { + for (item in row.itemList("loot")) { + add(item) + } + } + if (!added) { + message("You don't have enough inventory space.") + return } exp(Skill.Hunter, row.int("xp") / 10.0) clearTrail(this) diff --git a/game/src/main/kotlin/content/skill/hunter/MagicBox.kt b/game/src/main/kotlin/content/skill/hunter/MagicBox.kt index c928a486e3..5a99ecbe9f 100644 --- a/game/src/main/kotlin/content/skill/hunter/MagicBox.kt +++ b/game/src/main/kotlin/content/skill/hunter/MagicBox.kt @@ -101,10 +101,14 @@ class MagicBox : Script { } 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 player = owner + if (player == null) { + FloorItems.add(trap.tile, "magic_box") + return@npcDespawn + } + player.dec("trap_count") val drop = if (lifecycle == 0) { player.message("The magic box that you activated has stopped working.") true @@ -252,6 +256,10 @@ class MagicBox : Script { anim("lay_trap") sound("lay_box_trap") delay(3) + if (GameObjects.getLayer(tile, ObjectLayer.GROUND) != null) { + message("You can't lay a trap here.", ChatType.Filter) + return + } if (floorItem != null) { FloorItems.remove(floorItem) } else { diff --git a/game/src/main/kotlin/content/skill/hunter/NetTrap.kt b/game/src/main/kotlin/content/skill/hunter/NetTrap.kt index 46e6f33320..6bcbacb74b 100644 --- a/game/src/main/kotlin/content/skill/hunter/NetTrap.kt +++ b/game/src/main/kotlin/content/skill/hunter/NetTrap.kt @@ -24,6 +24,7 @@ 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.FloorItems import world.gregs.voidps.engine.entity.obj.* import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.inventory @@ -126,11 +127,17 @@ class NetTrap : Script { npcDespawn("hunting_sapling_trap_npc") { val trap = GameObjects.getLayer(tile.add(direction.inverse()), ObjectLayer.GROUND) ?: return@npcDespawn - val player = owner ?: return@npcDespawn - player.dec("trap_count") val net = GameObjects.findOrNull(trap.tile.add(trap.direction()), "net") net?.remove() GameObjects.remove(trap) + val player = owner + if (player == null) { + for (item in listOf("rope", "small_fishing_net")) { + FloorItems.add(tile, item) + } + return@npcDespawn + } + player.dec("trap_count") if (lifecycle == 0 || trap.id.endsWith("_net_failed")) { player.message("The net trap that you set has collapsed.") } diff --git a/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt b/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt index 185aeadfdd..df4b23200c 100644 --- a/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt +++ b/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt @@ -14,6 +14,7 @@ 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.item.floor.FloorItems import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.inventory @@ -82,6 +83,58 @@ class BoxTrapTest : WorldTest() { } } + @Test + fun `Grenwall spikes stack when checking with few free slots`() { + val player = createPlayer() + val start = player.tile + player.inventory.add("box_trap") + player.inventory.add("raw_pawya_meat") + 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("raw_pawya_meat")) + tick(2) + player.inventory.add("bones", 26) + assertEquals(2, player.inventory.spaces) + createNPC("grenwall", player.tile.addY(2)) + + tick(22) + + val trap = GameObjects.at(start).firstOrNull { it.id == "box_trap_grenwall" } + assertNotNull(trap) + player.objectOption(trap, "Check") + tick(3) + assertEquals(18, player.inventory.count("grenwall_spikes")) + assertEquals(1, player.inventory.count("box_trap")) + } + + @Test + fun `Bait is not returned when a caught trap collapses`() { + val player = createPlayer() + val start = player.tile + player.inventory.add("box_trap") + player.inventory.add("papaya_fruit") + 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("papaya_fruit")) + tick(2) + createNPC("pawya", player.tile.addY(2)) + + tick(22) + assertNotNull(GameObjects.at(start).firstOrNull { it.id == "box_trap_pawya" }) + + tick(110) + assertEquals(1, FloorItems.at(start).count { it.id == "box_trap" }) + assertEquals(0, FloorItems.at(start).count { it.id == "papaya_fruit" }) + } + @Test fun `Grenwall ignores unbaited trap`() { val player = createPlayer() diff --git a/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt b/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt index 3c357b8ddf..0a6027bb2d 100644 --- a/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt +++ b/game/src/test/kotlin/content/skill/hunter/KebbitTrackingTest.kt @@ -9,6 +9,7 @@ import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.obj.GameObject 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.type.Tile import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -335,6 +336,51 @@ class KebbitTrackingTest : WorldTest() { assertEquals(0, player["razor_backed_kebbit_trail_0", 0]) } + @Test + fun `Full inventory keeps the trail and gives no loot`() { + val player = createPlayer(Tile(2353, 3595)) + player.inventory.add("noose_wand") + player.inventory.add("bones", 27) + player.levels.set(Skill.Hunter, 3) + startTrail(player) + + val first = createObject("kebbit_tracks_plant_0", Tile(2358, 3599)) + player.objectOption(first, "Inspect") + tick(10) + val second = createObject("kebbit_tracks_plant_1", Tile(2352, 3603)) + player.objectOption(second, "Inspect") + tick(10) + val bush = createObject("kebbit_bush", Tile(2349, 3604)) + player.objectOption(bush, "Attack") + tick(10) + assertTrue(player.containsMessage("You don't have enough inventory space")) + assertEquals(0.0, player.experience.get(Skill.Hunter)) + assertEquals(4, player["common_kebbit_trail_0", 0]) + + player.inventory.remove("bones", 27) + player.objectOption(bush, "Attack") + tick(5) + assertEquals(1, player.inventory.count("common_kebbit_fur")) + assertEquals(36.0, player.experience.get(Skill.Hunter)) + } + + @Test + fun `Inspecting a burrow mid-trail starts a fresh trail`() { + val player = createPlayer(Tile(2353, 3595)) + player.levels.set(Skill.Hunter, 3) + val burrow = startTrail(player) + + val first = createObject("kebbit_tracks_plant_0", Tile(2358, 3599)) + player.objectOption(first, "Inspect") + tick(10) + assertEquals(4, player["common_kebbit_trail_3", 0]) + + player.objectOption(burrow, "Inspect") + tick(10) + assertEquals(4, player["common_kebbit_trail_0", 0]) + assertEquals(0, player["common_kebbit_trail_3", 0]) + } + @Test fun `Can't track a common kebbit below level 3`() { val player = createPlayer(Tile(2353, 3595)) From 08921feeb9592d0b3151f01e1e3dbd04d02c54e6 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 31 Aug 2026 11:47:11 -0700 Subject: [PATCH 18/24] Fix trap race conditions and consolidate shared trap lifecycle into Traps --- .../kotlin/content/skill/hunter/BirdSnare.kt | 79 +++---------------- .../kotlin/content/skill/hunter/BoxTrap.kt | 71 +++-------------- .../content/skill/hunter/DeadfallTrap.kt | 16 +++- .../content/skill/hunter/KebbitTracking.kt | 10 ++- .../kotlin/content/skill/hunter/MagicBox.kt | 71 ++++------------- .../kotlin/content/skill/hunter/NetTrap.kt | 15 ++-- .../main/kotlin/content/skill/hunter/Traps.kt | 78 ++++++++++++++++++ 7 files changed, 144 insertions(+), 196 deletions(-) diff --git a/game/src/main/kotlin/content/skill/hunter/BirdSnare.kt b/game/src/main/kotlin/content/skill/hunter/BirdSnare.kt index fe1d71679d..7bc05fac98 100644 --- a/game/src/main/kotlin/content/skill/hunter/BirdSnare.kt +++ b/game/src/main/kotlin/content/skill/hunter/BirdSnare.kt @@ -8,7 +8,6 @@ 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 @@ -24,7 +23,6 @@ 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 @@ -64,7 +62,7 @@ class BirdSnare : Script { itemOnObjectOperate("*", "bird_snare") { when { it.item.id == "unlit_torch" -> message("I should light the torch before using it to smoke the trap.") - it.item.id == "torch_lit" -> smoke(it.target) + it.item.id == "torch_lit" -> Traps.smoke(this, "bird_snare", it.target.tile) it.item.def.contains(Params.HEALS) -> message("There isn't really anywhere to put any bait on this trap.") else -> noInterest() } @@ -80,12 +78,15 @@ class BirdSnare : Script { if (!player.has(Skill.Hunter, creature.int("level"))) { return@huntNPC } - if (tile.distanceTo(target.tile) > 2) { + if (tile.distanceTo(target.tile) > 2 || target["caught", false]) { return@huntNPC } transform("${id}_off") - var chance = Traps.chance(this, creature) + val chance = Traps.chance(this, creature) val success = Level.success(player.levels.get(Skill.Hunter), chance) + if (success) { + target["caught"] = true + } target.walkToDelay(tile) target.walkOverDelay(tile) target.face(Direction.SOUTH) @@ -107,73 +108,12 @@ class BirdSnare : Script { } npcDespawn("hunting_ojibway_trap_npc") { - val trap = GameObjects.getLayer(tile, ObjectLayer.GROUND) ?: return@npcDespawn - GameObjects.remove(trap) - val player = owner - if (player == null) { - FloorItems.add(trap.tile, "bird_snare") - return@npcDespawn - } - player.dec("trap_count") - val drop = if (lifecycle == 0) { - player.message("The bird snare that you laid has fallen over.") - true - } else { - player["logged_out", false] - } - if (drop) { - player.drop(trap.tile, "bird_snare") - } + Traps.despawn(this, "bird_snare", "The bird snare that you laid has fallen over.") } } - private fun Player.smoke(target: GameObject) { - val id = Tables.npc("traps.${target.id}.npc") - val npc = NPCs.find(target.tile, id) - if (npc["owner", ""] != accountName) { - message("This isn't your trap.") - return - } - if (npc["smoked", false]) { - message("You've already smoked this trap.") // TODO proper message - return - } - anim("lay_trap_small") - areaSound("hunting_smoke2", tile = target.tile, radius = 5) - npc["smoked"] = true - message("You use the smoke from the torch to remove your scent from the trap.", type = ChatType.Filter) - } - private suspend fun Player.layTrap(floorItem: FloorItem?) { - val trap = Rows.getOrNull("traps.bird_snare") ?: 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("set_noose") - delay(3) - if (floorItem != null) { - FloorItems.remove(floorItem) - } else { - inventory.remove("bird_snare") - } - inc("trap_count") - NPCs.add("hunting_ojibway_trap_npc", tile, ticks = 100, owner = this) - val obj = GameObjects.add("bird_snare", tile) - stepAway(obj) + Traps.lay(this, "bird_snare", "set_noose", floorItem) } private suspend fun Player.dismantleTrap(target: GameObject, creature: RowDefinition?) { @@ -192,6 +132,9 @@ class BirdSnare : Script { anim("take_trap") sound("trap_dismantle", delay = 25) delay(2) + if (GameObjects.getLayer(target.tile, ObjectLayer.GROUND)?.id != target.id) { + return + } collapse(npc, target, drop = false) inventory.add("bird_snare") message("You dismantle the trap.", ChatType.Filter) diff --git a/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt b/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt index a9e044284f..db21c74f75 100644 --- a/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt +++ b/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt @@ -1,15 +1,12 @@ 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 @@ -26,7 +23,6 @@ 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 @@ -89,12 +85,15 @@ class BoxTrap : Script { return@huntNPC } } - if (tile.distanceTo(target.tile) > 2) { + if (tile.distanceTo(target.tile) > 2 || target["caught", false]) { return@huntNPC } transform("${id}_off") val chance = Traps.chance(this, creature) val success = Level.success(player.levels.get(Skill.Hunter), chance) + if (success) { + target["caught"] = true + } target.walkToDelay(tile) target.walkOverDelay(tile) despawn(100) @@ -114,30 +113,7 @@ class BoxTrap : Script { } npcDespawn("hunting_box_trap_npc") { - val trap = GameObjects.getLayer(tile, ObjectLayer.GROUND) ?: return@npcDespawn - GameObjects.remove(trap) - val bait: String? = get("bait") - val player = owner - if (player == null) { - FloorItems.add(trap.tile, "box_trap") - if (bait != null) { - FloorItems.add(trap.tile, bait) - } - return@npcDespawn - } - player.dec("trap_count") - 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") - if (bait != null) { - player.drop(trap.tile, bait) - } - } + Traps.despawn(this, "box_trap", "The box trap that you laid has fallen over.") } } @@ -161,39 +137,7 @@ class BoxTrap : Script { } 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 (GameObjects.getLayer(tile, ObjectLayer.GROUND) != null) { - message("You can't lay a trap here.", ChatType.Filter) - return - } - 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) + Traps.lay(this, "box_trap", "lay_box_trap", floorItem) } private suspend fun Player.dismantleTrap(target: GameObject, creature: RowDefinition?) { @@ -211,6 +155,9 @@ class BoxTrap : Script { anim("take_trap") sound("trap_dismantle", delay = 25) delay(2) + if (GameObjects.getLayer(target.tile, ObjectLayer.GROUND)?.id != target.id) { + return + } val added = inventory.transaction { for (item in items + loot) { add(item) diff --git a/game/src/main/kotlin/content/skill/hunter/DeadfallTrap.kt b/game/src/main/kotlin/content/skill/hunter/DeadfallTrap.kt index 5cf08215bf..0e39b93b23 100644 --- a/game/src/main/kotlin/content/skill/hunter/DeadfallTrap.kt +++ b/game/src/main/kotlin/content/skill/hunter/DeadfallTrap.kt @@ -66,12 +66,15 @@ class DeadfallTrap : Script { if (!player.has(Skill.Hunter, creature.int("level"))) { return@huntNPC } - if (tile.distanceTo(target.tile) > 3) { + if (tile.distanceTo(target.tile) > 3 || target["caught", false]) { return@huntNPC } transform("${id}_off") val chance = Traps.chance(this, creature) val success = Level.success(player.levels.get(Skill.Hunter), chance) + if (success) { + target["caught"] = true + } target.walkToDelay(tile) despawn(100) val trap = GameObjects.getLayer(tile, ObjectLayer.GROUND) ?: return@huntNPC @@ -100,7 +103,7 @@ class DeadfallTrap : Script { GameObjects.remove(trap) val player = owner if (player == null) { - FloorItems.add(trap.tile, "logs") + FloorItems.add(trap.tile, "logs", disappearTicks = 200) return@npcDespawn } player.dec("trap_count") @@ -148,11 +151,13 @@ class DeadfallTrap : Script { anim("lay_trap") sound("set_deadfall") delay(3) - if (NPCs.findOrNull(target.tile, "hunting_deadfall_trap_npc") != null) { + if (GameObjects.getLayer(target.tile, ObjectLayer.GROUND)?.id != "boulder_trap") { message("You can't lay a trap here.", ChatType.Filter) return } - inventory.remove("logs") + if (!inventory.remove("logs")) { + return + } inc("trap_count") inc("deadfall_count") NPCs.add("hunting_deadfall_trap_npc", target.tile, ticks = 100, owner = this) @@ -177,6 +182,9 @@ class DeadfallTrap : Script { anim("take_trap") sound("take_branches", delay = 25) delay(2) + if (GameObjects.getLayer(target.tile, ObjectLayer.GROUND)?.id != target.id) { + return + } collapse(this, npc, target) for (item in items) { inventory.add(item) diff --git a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt index cfdf7b127c..c3afb9a8df 100644 --- a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt +++ b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt @@ -32,6 +32,8 @@ class KebbitTracking : Script { private val trackingTrails = mutableMapOf>() private val trackingSteps = mutableMapOf() + private val segmentCache = mutableMapOf>() + private val poolCache = mutableMapOf>() init { objectOperate("Inspect", "common_kebbit_burrow,common_kebbit_burrow_2,common_kebbit_burrow_3,polar_kebbit_hole,polar_kebbit_hole_2,desert_devil_burrow,desert_devil_burrow_2,feldip_weasel_burrow,feldip_weasel_burrow_2,razor_backed_kebbit_burrow,razor_backed_kebbit_burrow_2,razor_backed_kebbit_burrow_3") { (target) -> @@ -63,7 +65,9 @@ class KebbitTracking : Script { else -> "common_kebbit" } - private fun segments(kebbit: String): List = Tables.get("trails").rows().filter { it.string("kebbit") == kebbit }.map { row -> + private fun segments(kebbit: String): List = segmentCache.getOrPut(kebbit) { load(kebbit) } + + private fun load(kebbit: String): List = Tables.get("trails").rows().filter { it.string("kebbit") == kebbit }.map { row -> Segment( varbit = row.string("varbit"), tunnel = row.bool("tunnel"), @@ -77,12 +81,12 @@ class KebbitTracking : Script { private fun tunnels(kebbit: String) = segments(kebbit).filter { it.tunnel }.flatMap { listOf(it.start, it.end) }.toSet() - private fun linkingPool(kebbit: String): List { + private fun linkingPool(kebbit: String): List = poolCache.getOrPut(kebbit) { val base = segments(kebbit) val tunnels = tunnels(kebbit) // Only polar kebbit trails can route back through a burrow's starting segments val pool = if (kebbit == "polar_kebbit") base else base.filter { it.burrow == null } - return pool + pool.map { inverse(it, tunnels) } + pool + pool.map { inverse(it, tunnels) } } private fun inverse(segment: Segment, tunnels: Set) = Segment( diff --git a/game/src/main/kotlin/content/skill/hunter/MagicBox.kt b/game/src/main/kotlin/content/skill/hunter/MagicBox.kt index 5a99ecbe9f..6734cec1df 100644 --- a/game/src/main/kotlin/content/skill/hunter/MagicBox.kt +++ b/game/src/main/kotlin/content/skill/hunter/MagicBox.kt @@ -8,7 +8,6 @@ import content.entity.player.dialogue.Quiz import content.entity.player.dialogue.type.choice import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player -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 @@ -16,7 +15,6 @@ import world.gregs.voidps.engine.client.ui.chat.plural import world.gregs.voidps.engine.client.ui.close import world.gregs.voidps.engine.client.ui.open 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 @@ -30,7 +28,6 @@ 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 @@ -76,12 +73,15 @@ class MagicBox : Script { if (!player.has(Skill.Hunter, creature.int("level"))) { return@huntNPC } - if (tile.distanceTo(target.tile) > 2) { + if (tile.distanceTo(target.tile) > 2 || target["caught", false]) { return@huntNPC } transform("${id}_off") val chance = Traps.chance(this, creature) val success = Level.success(player.levels.get(Skill.Hunter), chance) + if (success) { + target["caught"] = true + } target.walkToDelay(tile) target.walkOverDelay(tile) despawn(100) @@ -101,23 +101,7 @@ class MagicBox : Script { } npcDespawn("hunting_imptrap_npc") { - val trap = GameObjects.getLayer(tile, ObjectLayer.GROUND) ?: return@npcDespawn - GameObjects.remove(trap) - val player = owner - if (player == null) { - FloorItems.add(trap.tile, "magic_box") - return@npcDespawn - } - player.dec("trap_count") - 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") - } + Traps.despawn(this, "magic_box", "The magic box that you activated has stopped working.") } itemOption("Talk-to", "imp_in_a_box_2,imp_in_a_box_1") { @@ -147,7 +131,11 @@ class MagicBox : Script { message("The imp refuses to take that to your bank.") return@itemOnItem } + val before = inventory.count(item.id) BankDeposit.deposit(this, inventory, item, 1, check = false) + if (inventory.count(item.id) >= before) { + return@itemOnItem + } 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.") @@ -169,7 +157,11 @@ class MagicBox : Script { message("A magical force prevents you from banking this item.") return } + val before = inventory.count(item.id) BankDeposit.deposit(this, inventory, item, item.amount, slot, check = false) + if (inventory.count(item.id) >= before) { + return + } if (inventory.contains("imp_in_a_box_2")) { inventory.replace("imp_in_a_box_2", "imp_in_a_box_1") interfaces.sendText("imp_box", "text", depositText()) @@ -236,39 +228,7 @@ class MagicBox : Script { } 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 (GameObjects.getLayer(tile, ObjectLayer.GROUND) != null) { - message("You can't lay a trap here.", ChatType.Filter) - return - } - 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) + Traps.lay(this, "magic_box", "lay_box_trap", floorItem) } private suspend fun Player.dismantleTrap(target: GameObject, creature: RowDefinition?) { @@ -288,6 +248,9 @@ class MagicBox : Script { anim("take_trap") sound("trap_dismantle", delay = 25) delay(2) + if (GameObjects.getLayer(target.tile, ObjectLayer.GROUND)?.id != target.id) { + return + } collapse(npc, target) for (item in items) { inventory.add(item) diff --git a/game/src/main/kotlin/content/skill/hunter/NetTrap.kt b/game/src/main/kotlin/content/skill/hunter/NetTrap.kt index 6bcbacb74b..81201c6ed0 100644 --- a/game/src/main/kotlin/content/skill/hunter/NetTrap.kt +++ b/game/src/main/kotlin/content/skill/hunter/NetTrap.kt @@ -96,12 +96,15 @@ class NetTrap : Script { if (!player.has(Skill.Hunter, creature.int("level"))) { return@huntNPC } - if (tile.distanceTo(target.tile) > 2) { + if (tile.distanceTo(target.tile) > 2 || target["caught", false]) { return@huntNPC } transform("${id}_off") - var chance = Traps.chance(this, creature) + val chance = Traps.chance(this, creature) val success = Level.success(player.levels.get(Skill.Hunter), chance) + if (success) { + target["caught"] = true + } val trapId = creature.string("trap") target.walkToDelay(tile) target.delay(1) @@ -130,10 +133,12 @@ class NetTrap : Script { val net = GameObjects.findOrNull(trap.tile.add(trap.direction()), "net") net?.remove() GameObjects.remove(trap) + val bait: String? = get("bait") + val items = if (bait != null) listOf("rope", "small_fishing_net", bait) else listOf("rope", "small_fishing_net") val player = owner if (player == null) { - for (item in listOf("rope", "small_fishing_net")) { - FloorItems.add(tile, item) + for (item in items) { + FloorItems.add(tile, item, disappearTicks = 200) } return@npcDespawn } @@ -142,7 +147,7 @@ class NetTrap : Script { player.message("The net trap that you set has collapsed.") } if (lifecycle == 0 || trap.id.endsWith("_net_failed") || player["logged_out", false]) { - for (item in listOf("rope", "small_fishing_net")) { + for (item in items) { player.drop(tile, item) } } diff --git a/game/src/main/kotlin/content/skill/hunter/Traps.kt b/game/src/main/kotlin/content/skill/hunter/Traps.kt index 36e882e6c4..563399d752 100644 --- a/game/src/main/kotlin/content/skill/hunter/Traps.kt +++ b/game/src/main/kotlin/content/skill/hunter/Traps.kt @@ -1,19 +1,97 @@ package content.skill.hunter +import content.entity.player.inv.item.drop import net.pearx.kasechange.toLowerSpaceCase 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.chat.ChatType +import world.gregs.voidps.engine.entity.character.player.skill.Skill +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.GameObjects +import world.gregs.voidps.engine.entity.obj.ObjectLayer +import world.gregs.voidps.engine.entity.obj.stepAway +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove import world.gregs.voidps.type.Tile object Traps { fun max(level: Int, max: Int) = (1 + level / 20).coerceAtMost(max) + suspend fun lay(player: Player, trapId: String, sound: String, floorItem: FloorItem?) { + val trap = Rows.getOrNull("traps.$trapId") ?: return + val level = player.levels.get(Skill.Hunter) + if (!player.has(Skill.Hunter, trap.int("level"), message = true)) { + return + } + if (Areas.get(player.tile.zone).any { it.tags.contains("bank") } || GameObjects.getLayer(player.tile, ObjectLayer.GROUND) != null) { + player.message("You can't lay a trap here.", ChatType.Filter) + return + } + val max = max(level, trap.int("max")) + if (player.get("trap_count", 0) >= max) { + player.message("You may setup only $max ${"trap".plural(max)} at a time at your Hunter level.") + return + } + player.arriveDelay() + player.message("You begin setting up ${if (max == 1) "the" else "a"} trap.", ChatType.Filter) + player.anim("lay_trap") + player.sound(sound) + player.delay(3) + if (GameObjects.getLayer(player.tile, ObjectLayer.GROUND) != null) { + player.message("You can't lay a trap here.", ChatType.Filter) + return + } + if (floorItem != null) { + if (!FloorItems.remove(floorItem)) { + return + } + } else if (!player.inventory.remove(trapId)) { + return + } + player.inc("trap_count") + NPCs.add(Tables.npc("traps.$trapId.npc"), player.tile, ticks = 100, owner = player) + val obj = GameObjects.add(trapId, player.tile) + player.stepAway(obj) + } + + fun despawn(npc: NPC, trapItem: String, collapseMessage: String) { + val trap = GameObjects.getLayer(npc.tile, ObjectLayer.GROUND) ?: return + GameObjects.remove(trap) + val bait: String? = npc["bait"] + val player = npc.owner + if (player == null) { + FloorItems.add(trap.tile, trapItem, disappearTicks = 200) + if (bait != null) { + FloorItems.add(trap.tile, bait, disappearTicks = 200) + } + return + } + player.dec("trap_count") + val drop = if (npc.lifecycle == 0) { + player.message(collapseMessage) + true + } else { + player["logged_out", false] + } + if (drop) { + player.drop(trap.tile, trapItem) + if (bait != null) { + player.drop(trap.tile, bait) + } + } + } + fun chance(npc: NPC, creature: RowDefinition): IntRange { val chance = creature.intRange("chance") var add = 0 From c1693ee218d66d3408d878e80616b55cc6405b20 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 3 Sep 2026 06:38:47 -0700 Subject: [PATCH 19/24] Fix review feedback on hunter traps PR - Torch item id was torch_lit (doesn't exist) instead of lit_torch, so smoking any trap silently fell through to noInterest() - Ground traps had default collision, blocking NPCs from walking onto them (chinchompas couldn't approach a box trap) - Deadfall dismantle played the snare pickup anim instead of the boulder lay anim, and setting a boulder trap incorrectly stepped the player away from an object they were never standing on - Falconry catch kept walking the player toward the kebbit after the falcon was already thrown; leaving the area also left an unclaimed caught kebbit on the ground - Falconry area's north boundary cut through Matthias's house, so players lost their falcon mid-room; extended it to the house's actual extent - Inspecting an unrelated burrow reset any trail already in progress; now only the trail's own burrow can restart it --- data/area/kandarin/piscatoris/piscatoris.areas.toml | 2 +- game/src/main/kotlin/content/skill/hunter/BirdSnare.kt | 2 +- game/src/main/kotlin/content/skill/hunter/BoxTrap.kt | 2 +- game/src/main/kotlin/content/skill/hunter/DeadfallTrap.kt | 5 ++--- game/src/main/kotlin/content/skill/hunter/Falconry.kt | 6 +++++- game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt | 5 +++++ game/src/main/kotlin/content/skill/hunter/NetTrap.kt | 2 +- game/src/main/kotlin/content/skill/hunter/Traps.kt | 2 +- 8 files changed, 17 insertions(+), 9 deletions(-) diff --git a/data/area/kandarin/piscatoris/piscatoris.areas.toml b/data/area/kandarin/piscatoris/piscatoris.areas.toml index a44b71ac56..14a063fcb4 100644 --- a/data/area/kandarin/piscatoris/piscatoris.areas.toml +++ b/data/area/kandarin/piscatoris/piscatoris.areas.toml @@ -22,4 +22,4 @@ y = [3702, 3702] [piscatoris_falconry_area] x = [2363, 2395] -y = [3579, 3612] +y = [3579, 3620] diff --git a/game/src/main/kotlin/content/skill/hunter/BirdSnare.kt b/game/src/main/kotlin/content/skill/hunter/BirdSnare.kt index 7bc05fac98..d628b52ffe 100644 --- a/game/src/main/kotlin/content/skill/hunter/BirdSnare.kt +++ b/game/src/main/kotlin/content/skill/hunter/BirdSnare.kt @@ -62,7 +62,7 @@ class BirdSnare : Script { itemOnObjectOperate("*", "bird_snare") { 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, "bird_snare", it.target.tile) + it.item.id == "lit_torch" -> Traps.smoke(this, "bird_snare", it.target.tile) it.item.def.contains(Params.HEALS) -> message("There isn't really anywhere to put any bait on this trap.") else -> noInterest() } diff --git a/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt b/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt index db21c74f75..cc1c131191 100644 --- a/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt +++ b/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt @@ -58,7 +58,7 @@ class BoxTrap : Script { 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 == "lit_torch" -> 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() diff --git a/game/src/main/kotlin/content/skill/hunter/DeadfallTrap.kt b/game/src/main/kotlin/content/skill/hunter/DeadfallTrap.kt index 0e39b93b23..226af92a3e 100644 --- a/game/src/main/kotlin/content/skill/hunter/DeadfallTrap.kt +++ b/game/src/main/kotlin/content/skill/hunter/DeadfallTrap.kt @@ -50,7 +50,7 @@ class DeadfallTrap : Script { 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.id == "lit_torch" -> 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() } @@ -162,7 +162,6 @@ class DeadfallTrap : Script { 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?) { @@ -179,7 +178,7 @@ class DeadfallTrap : Script { message("You don't have enough inventory space. You need $slots more free ${"slot".plural(slots)}.") return } - anim("take_trap") + anim("lay_trap") sound("take_branches", delay = 25) delay(2) if (GameObjects.getLayer(target.tile, ObjectLayer.GROUND)?.id != target.id) { diff --git a/game/src/main/kotlin/content/skill/hunter/Falconry.kt b/game/src/main/kotlin/content/skill/hunter/Falconry.kt index 894a153d57..7113533a04 100644 --- a/game/src/main/kotlin/content/skill/hunter/Falconry.kt +++ b/game/src/main/kotlin/content/skill/hunter/Falconry.kt @@ -67,7 +67,7 @@ class Falconry : Script { } npcApproach("Catch", "spotted_kebbit,dark_kebbit,dashing_kebbit") { (target) -> - approachRange(6) + approachRange(6, update = false) catch(target) } @@ -97,6 +97,10 @@ class Falconry : Script { } message("You hand the falcon back to Matthias before leaving.") } + val caught = NPCs.firstOrNull { it.id.endsWith("_kebbit_caught") && it.owner == this } + if (caught != null) { + NPCs.remove(caught) + } } } diff --git a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt index c3afb9a8df..05b8d98f26 100644 --- a/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt +++ b/game/src/main/kotlin/content/skill/hunter/KebbitTracking.kt @@ -161,6 +161,11 @@ class KebbitTracking : Script { if (!has(Skill.Hunter, row.int("level"), message = true)) { return } + val existing = trackingTrails[accountName] + if (existing != null && existing.first().burrow != target.id) { + message("You are already following a different set of tracks.") + return + } clearTrail(this) val trail = generate(kebbit, target.id, row.int("limit"), row.tileList("finals")) if (trail == null) { diff --git a/game/src/main/kotlin/content/skill/hunter/NetTrap.kt b/game/src/main/kotlin/content/skill/hunter/NetTrap.kt index 81201c6ed0..ca51f4947e 100644 --- a/game/src/main/kotlin/content/skill/hunter/NetTrap.kt +++ b/game/src/main/kotlin/content/skill/hunter/NetTrap.kt @@ -79,7 +79,7 @@ class NetTrap : Script { } when { item.id == "unlit_torch" -> message("I should light the torch before using it to smoke the trap.") - item.id == "torch_lit" -> Traps.smoke(this, trap.id.removeSuffix("_setup"), trap.tile.add(target.direction())) + item.id == "lit_torch" -> Traps.smoke(this, trap.id.removeSuffix("_setup"), trap.tile.add(target.direction())) item.id.endsWith("_tar") -> bait(item, trap) item.def.contains(Params.HEALS) -> message("I don't think I'd catch much using that as bait.") else -> noInterest() diff --git a/game/src/main/kotlin/content/skill/hunter/Traps.kt b/game/src/main/kotlin/content/skill/hunter/Traps.kt index 563399d752..07735729b2 100644 --- a/game/src/main/kotlin/content/skill/hunter/Traps.kt +++ b/game/src/main/kotlin/content/skill/hunter/Traps.kt @@ -61,7 +61,7 @@ object Traps { } player.inc("trap_count") NPCs.add(Tables.npc("traps.$trapId.npc"), player.tile, ticks = 100, owner = player) - val obj = GameObjects.add(trapId, player.tile) + val obj = GameObjects.add(trapId, player.tile, collision = false) player.stepAway(obj) } From 59d761a23edbdd8d47c1da3ac17774a80eabeb84 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Thu, 3 Sep 2026 07:46:48 -0700 Subject: [PATCH 20/24] Update Falconry to the merged hint arrow API main replaced the per-index hint(npc)/clearHint(id) pair with markHint(character)/clearHinted(), so the falcon catch hint needed updating to keep compiling after merging upstream/main. --- game/src/main/kotlin/content/skill/hunter/Falconry.kt | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/game/src/main/kotlin/content/skill/hunter/Falconry.kt b/game/src/main/kotlin/content/skill/hunter/Falconry.kt index 7113533a04..3576ec4b79 100644 --- a/game/src/main/kotlin/content/skill/hunter/Falconry.kt +++ b/game/src/main/kotlin/content/skill/hunter/Falconry.kt @@ -9,8 +9,8 @@ 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.clearHint -import world.gregs.voidps.engine.client.hint +import world.gregs.voidps.engine.client.clearHinted +import world.gregs.voidps.engine.client.markHint import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.chat.plural import world.gregs.voidps.engine.data.definition.Rows @@ -76,11 +76,8 @@ class Falconry : Script { } npcDespawn("*_kebbit_caught") { + clearHinted() val player = owner ?: return@npcDespawn - val hint = get("hint", -1) - if (hint != -1) { - player.clearHint(hint) - } if (lifecycle != 0) { return@npcDespawn } @@ -163,7 +160,7 @@ class Falconry : Script { equipment.replace("falconers_glove_2", "falconers_glove") target.levels.set(Skill.Constitution, 0) val caught = NPCs.add(row.npc("caught"), target.tile, ticks = 100, owner = this) - caught["hint"] = hint(caught) + markHint(caught) message("Your falcon successfully swoops down on the kebbit.") } From 441e2470aed7307f6c9f58625a82fd60287238fc Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 7 Sep 2026 14:19:25 -0700 Subject: [PATCH 21/24] Address review feedback on box trap luring, falconry and imp-in-a-box --- data/entity/npc/hunt_modes.toml | 2 +- data/skill/hunter/hunter.npcs.toml | 2 +- .../kotlin/content/skill/hunter/BoxTrap.kt | 10 ++- .../kotlin/content/skill/hunter/Falconry.kt | 4 +- .../kotlin/content/skill/hunter/MagicBox.kt | 66 ++++++++++++------- .../content/skill/hunter/BoxTrapTest.kt | 12 ++++ 6 files changed, 69 insertions(+), 27 deletions(-) diff --git a/data/entity/npc/hunt_modes.toml b/data/entity/npc/hunt_modes.toml index 882883829d..d8287617c2 100644 --- a/data/entity/npc/hunt_modes.toml +++ b/data/entity/npc/hunt_modes.toml @@ -161,7 +161,7 @@ find_keep_hunting = true [box_trap] type = "npc" id = "chinchompa,carnivorous_chinchompa,ferret,pawya,grenwall" -check_visual = "line_of_sight" +check_visual = "line_of_walk" check_not_busy = true find_keep_hunting = true diff --git a/data/skill/hunter/hunter.npcs.toml b/data/skill/hunter/hunter.npcs.toml index cb34749f82..eeab5ee758 100644 --- a/data/skill/hunter/hunter.npcs.toml +++ b/data/skill/hunter/hunter.npcs.toml @@ -9,7 +9,7 @@ id = 5071 [hunting_box_trap_npc] id = 5077 hunt_mode = "box_trap" -hunt_range = 2 +hunt_range = 8 [hunting_box_trap_npc_off] id = 5078 diff --git a/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt b/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt index cc1c131191..507865809d 100644 --- a/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt +++ b/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt @@ -85,7 +85,7 @@ class BoxTrap : Script { return@huntNPC } } - if (tile.distanceTo(target.tile) > 2 || target["caught", false]) { + if (tile.distanceTo(target.tile) > 8 || target["caught", false]) { return@huntNPC } transform("${id}_off") @@ -112,6 +112,14 @@ class BoxTrap : Script { areaSound("box_trap_catch", tile) } + npcDeath("chinchompa,carnivorous_chinchompa") { + if (contains("caught")) { + return@npcDeath + } + gfx("chinchompa_impact") + areaSound("chinchompa_explode", tile) + } + npcDespawn("hunting_box_trap_npc") { Traps.despawn(this, "box_trap", "The box trap that you laid has fallen over.") } diff --git a/game/src/main/kotlin/content/skill/hunter/Falconry.kt b/game/src/main/kotlin/content/skill/hunter/Falconry.kt index 3576ec4b79..9bd43959c3 100644 --- a/game/src/main/kotlin/content/skill/hunter/Falconry.kt +++ b/game/src/main/kotlin/content/skill/hunter/Falconry.kt @@ -145,9 +145,11 @@ class Falconry : Script { } face(target) sound("falcon_swoop") + equipment.replace("falconers_glove_2", "falconers_glove") shoot("gyr_falcon", target) delay(2) if (target.tile.distanceTo(tile) > 8) { + equipment.replace("falconers_glove", "falconers_glove_2") return } val success = Level.success(levels.get(Skill.Hunter), row.intRange("chance")) @@ -155,9 +157,9 @@ class Falconry : Script { target.shoot("gyr_falcon", this) sound("falcon_return", delay = 20) message("The kebbit is too quick for your falcon.") + equipment.replace("falconers_glove", "falconers_glove_2") return } - equipment.replace("falconers_glove_2", "falconers_glove") target.levels.set(Skill.Constitution, 0) val caught = NPCs.add(row.npc("caught"), target.tile, ticks = 100, owner = this) markHint(caught) diff --git a/game/src/main/kotlin/content/skill/hunter/MagicBox.kt b/game/src/main/kotlin/content/skill/hunter/MagicBox.kt index 6734cec1df..2fbf7f534f 100644 --- a/game/src/main/kotlin/content/skill/hunter/MagicBox.kt +++ b/game/src/main/kotlin/content/skill/hunter/MagicBox.kt @@ -1,5 +1,6 @@ package content.skill.hunter +import content.area.wilderness.wildernessLevel import content.entity.effect.transform import content.entity.player.bank.BankDeposit import content.entity.player.dialogue.Happy @@ -40,7 +41,7 @@ class MagicBox : Script { layTrap(null) } - floorItemOperate("Lay") { (item) -> + floorItemOperate("Activate") { (item) -> if (item.id == "magic_box") { layTrap(item) } @@ -109,6 +110,9 @@ class MagicBox : Script { } itemOption("Bank", "imp_in_a_box_2,imp_in_a_box_1") { + if (!canImpBank()) { + return@itemOption + } open("imp_box") } @@ -126,22 +130,22 @@ class MagicBox : Script { close("imp_box") } - itemOnItem("*", "imp_in_a_box_2,imp_in_a_box_1") { item, box -> + itemOnItem("*", "imp_in_a_box_2,imp_in_a_box_1") { item, _ -> 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 } + if (!canImpBank()) { + return@itemOnItem + } val before = inventory.count(item.id) BankDeposit.deposit(this, inventory, item, 1, check = false) if (inventory.count(item.id) >= before) { return@itemOnItem } - 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.") + when (useCharge()) { + 1 -> message("The imp takes the item to your bank.") + 0 -> message("The imp takes the item to your bank and escapes from the box.") } } } @@ -162,16 +166,32 @@ class MagicBox : Script { if (inventory.count(item.id) >= before) { return } - if (inventory.contains("imp_in_a_box_2")) { - inventory.replace("imp_in_a_box_2", "imp_in_a_box_1") - interfaces.sendText("imp_box", "text", depositText()) - } else if (inventory.contains("imp_in_a_box_1")) { - inventory.replace("imp_in_a_box_1", "magic_box") - close("imp_box") - message("The imp takes your items to the bank and escapes from the box.") - } else { - close("imp_box") + when (useCharge()) { + 1 -> interfaces.sendText("imp_box", "text", depositText()) + 0 -> { + close("imp_box") + message("The imp takes your items to the bank and escapes from the box.") + } + else -> close("imp_box") + } + } + + private fun Player.useCharge(): Int { + if (inventory.replace("imp_in_a_box_2", "imp_in_a_box_1")) { + return 1 + } + if (inventory.replace("imp_in_a_box_1", "magic_box")) { + return 0 + } + return -1 + } + + private fun Player.canImpBank(): Boolean { + if (wildernessLevel > 30) { + message("The imp refuses to visit your bank from this deep in the wilderness.") + return false } + return true } private suspend fun Player.impDialogue() { @@ -182,7 +202,7 @@ class MagicBox : Script { private suspend fun Player.impOptions() { choice { - option("No, I'm going to keep you in there.") { + option("No, I'm going to keep you in there.") { player("No, I'm going to keep you in there. I might keep you as a pet.") npc("imp", "Pet!! Nah mate. I fink you'd find dat you'd be my pet!! We is not makin good pets.") player("Really? Why not?") @@ -192,14 +212,13 @@ class MagicBox : Script { player("Fire risk? How does that work?") npc("imp", "Is those wizzies. Dey don't like de imps so dey make us go BOOOM!!") } - option("It's not that bad.") { + option("It's not that bad.") { player("It's not that bad. You've got four big windows, charming company...er...") npc("imp", "Yeah, we's love tiny, crampt space. It be magical. But I is a busy imp, innit? Dragons needin' ticklin', shiny relics needin' stealin', you know how it goes.") npc("imp", "So, if you's know whas good for ya, you'd be lettin' me go, right?") impOptions() } option("Don't I get three wishes?") { - player("Don't I get three wishes?") npc("imp", "Nah, mate. Dunno what you're chirpin' about.") player("Well, you're a magical creature aren't you? Surely I get some wishes for capturing you, or releasing you, or something?") npc("imp", "I'm finking dat you be a bit confoosed. I is an imp, not some namby-pamby genie or some kinda fairy. Ye can tell by the horns.") @@ -213,15 +232,16 @@ class MagicBox : Script { private suspend fun Player.bankOptions() { choice { option("Okay, that sounds fair.") { - open("imp_box") + if (canImpBank()) { + open("imp_box") + } } - option("Surely it should be three items?") { + option("Surely it should be three items?") { player("Surely it should be three items? Then it's one item per wish.") npc("imp", "I've already told ya, I ain't no bloomin' fairy. Besides, you know wot dey say, three's a crowd innit? I don't fink I can hop about carryin' more dan 2 fings.") bankOptions() } option("I've got nothing I need banking right now.") { - player("I've got nothing I need banking right now.") npc("imp", "Great, just blinkin great, dat is. I'll just sit about countin' zombie sheep then. One...two...two and a bit...three and a bit more... I don't fink sheep 'ave dat many legs...") } } diff --git a/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt b/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt index df4b23200c..76d53f4d3c 100644 --- a/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt +++ b/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt @@ -135,6 +135,18 @@ class BoxTrapTest : WorldTest() { assertEquals(0, FloorItems.at(start).count { it.id == "papaya_fruit" }) } + @Test + fun `Chinchompa explodes when killed`() { + val player = createPlayer() + val chinchompa = createNPC("chinchompa", player.tile.addY(1)) + tick(2) + + chinchompa.levels.set(Skill.Constitution, 0) + assertEquals(2739, chinchompa.visuals.secondaryGraphic.id) + tick(10) + assertTrue(FloorItems.at(chinchompa.tile).none { it.id != "" }) + } + @Test fun `Grenwall ignores unbaited trap`() { val player = createPlayer() From 16f68d7c8a56300637ca6baaa1608a45abb747c8 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 9 Sep 2026 06:06:05 -0700 Subject: [PATCH 22/24] Lure box trap creatures without line of sight and use captured falconry projectile data --- data/skill/hunter/hunter.anims.toml | 3 +++ data/skill/hunter/hunting.gfx.toml | 14 +++++++++++- .../kotlin/content/skill/hunter/BoxTrap.kt | 7 ++++++ .../kotlin/content/skill/hunter/Falconry.kt | 7 ++++-- .../content/skill/hunter/BoxTrapTest.kt | 22 +++++++++++++++++++ 5 files changed, 50 insertions(+), 3 deletions(-) diff --git a/data/skill/hunter/hunter.anims.toml b/data/skill/hunter/hunter.anims.toml index b5b401a281..d58965ec2d 100644 --- a/data/skill/hunter/hunter.anims.toml +++ b/data/skill/hunter/hunter.anims.toml @@ -113,3 +113,6 @@ id = 5255 [noose_catch_desert] id = 5257 + +[falconer_attack] +id = 5162 diff --git a/data/skill/hunter/hunting.gfx.toml b/data/skill/hunter/hunting.gfx.toml index bb57527f82..f9f393d5fd 100644 --- a/data/skill/hunter/hunting.gfx.toml +++ b/data/skill/hunter/hunting.gfx.toml @@ -1,5 +1,17 @@ [gyr_falcon] -id = 927 +id = 922 +delay = 24 +curve = 10 +multiplier = 8 +height = 110 +end_height = -35 + +[falcon_lift] +id = 918 +height = 110 + +[falcon_impale] +id = 923 [chocolate_kebbit] id = 930 diff --git a/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt b/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt index 507865809d..ae9b5c7c8b 100644 --- a/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt +++ b/game/src/main/kotlin/content/skill/hunter/BoxTrap.kt @@ -1,5 +1,6 @@ package content.skill.hunter +import content.entity.effect.clearTransform import content.entity.effect.transform import content.quest.questCompleted import net.pearx.kasechange.toLowerSpaceCase @@ -96,6 +97,12 @@ class BoxTrap : Script { } target.walkToDelay(tile) target.walkOverDelay(tile) + if (target.tile != tile) { + // Couldn't reach the trap; re-arm it and free the creature + target.clear("caught") + clearTransform() + return@huntNPC + } despawn(100) val trap = GameObjects.getLayer(tile, ObjectLayer.GROUND) ?: return@huntNPC val catching = trap.replace("box_trap_catching") diff --git a/game/src/main/kotlin/content/skill/hunter/Falconry.kt b/game/src/main/kotlin/content/skill/hunter/Falconry.kt index 9bd43959c3..11bab9b4ef 100644 --- a/game/src/main/kotlin/content/skill/hunter/Falconry.kt +++ b/game/src/main/kotlin/content/skill/hunter/Falconry.kt @@ -146,7 +146,9 @@ class Falconry : Script { face(target) sound("falcon_swoop") equipment.replace("falconers_glove_2", "falconers_glove") - shoot("gyr_falcon", target) + gfx("falcon_lift") + anim("falconer_attack") + val flight = shoot("gyr_falcon", target) delay(2) if (target.tile.distanceTo(tile) > 8) { equipment.replace("falconers_glove", "falconers_glove_2") @@ -154,12 +156,13 @@ class Falconry : Script { } val success = Level.success(levels.get(Skill.Hunter), row.intRange("chance")) if (!success) { - target.shoot("gyr_falcon", this) + target.shoot("gyr_falcon", this, height = 10, endHeight = 70, curve = 0) sound("falcon_return", delay = 20) message("The kebbit is too quick for your falcon.") equipment.replace("falconers_glove", "falconers_glove_2") return } + target.gfx("falcon_impale", delay = flight) target.levels.set(Skill.Constitution, 0) val caught = NPCs.add(row.npc("caught"), target.tile, ticks = 100, owner = this) markHint(caught) diff --git a/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt b/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt index 76d53f4d3c..4abbb2d5c2 100644 --- a/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt +++ b/game/src/test/kotlin/content/skill/hunter/BoxTrapTest.kt @@ -18,6 +18,7 @@ import world.gregs.voidps.engine.entity.item.floor.FloorItems 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 @@ -135,6 +136,27 @@ class BoxTrapTest : WorldTest() { assertEquals(0, FloorItems.at(start).count { it.id == "papaya_fruit" }) } + @Test + fun `Wandering pawya is lured to a baited trap without line of sight`() { + world.gregs.voidps.engine.data.Settings.load(mapOf("world.npcs.randomWalk" to "true")) + val player = createPlayer(Tile(2245, 3190)) + player.inventory.add("box_trap") + player.inventory.add("papaya_fruit") + player.levels.set(Skill.Hunter, 99) + val start = player.tile + + 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("papaya_fruit")) + tick(2) + createNPC("pawya", Tile(2245, 3192)) + + tickIf(limit = 90) { GameObjects.at(start).none { it.id == "box_trap_pawya" } } + assertNotNull(GameObjects.at(start).firstOrNull { it.id == "box_trap_pawya" }) + } + @Test fun `Chinchompa explodes when killed`() { val player = createPlayer() From 8d8719fcceb8b4d6a9602ac3cc66fff1df9e2293 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 9 Sep 2026 06:17:54 -0700 Subject: [PATCH 23/24] Lower falcon flight heights to match other projectiles --- data/skill/hunter/hunting.gfx.toml | 4 ++-- game/src/main/kotlin/content/skill/hunter/Falconry.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/data/skill/hunter/hunting.gfx.toml b/data/skill/hunter/hunting.gfx.toml index f9f393d5fd..8c4422f3ad 100644 --- a/data/skill/hunter/hunting.gfx.toml +++ b/data/skill/hunter/hunting.gfx.toml @@ -3,12 +3,12 @@ id = 922 delay = 24 curve = 10 multiplier = 8 -height = 110 +height = 5 end_height = -35 [falcon_lift] id = 918 -height = 110 +height = 5 [falcon_impale] id = 923 diff --git a/game/src/main/kotlin/content/skill/hunter/Falconry.kt b/game/src/main/kotlin/content/skill/hunter/Falconry.kt index 11bab9b4ef..e88f448a7c 100644 --- a/game/src/main/kotlin/content/skill/hunter/Falconry.kt +++ b/game/src/main/kotlin/content/skill/hunter/Falconry.kt @@ -156,7 +156,7 @@ class Falconry : Script { } val success = Level.success(levels.get(Skill.Hunter), row.intRange("chance")) if (!success) { - target.shoot("gyr_falcon", this, height = 10, endHeight = 70, curve = 0) + target.shoot("gyr_falcon", this, height = 5, endHeight = 45, curve = 0) sound("falcon_return", delay = 20) message("The kebbit is too quick for your falcon.") equipment.replace("falconers_glove", "falconers_glove_2") From 30ad701020a77774ed68c7f4f4d9fba4a9103317 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 9 Sep 2026 06:23:44 -0700 Subject: [PATCH 24/24] Sync caught falcon spawn with projectile arrival --- game/src/main/kotlin/content/skill/hunter/Falconry.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/game/src/main/kotlin/content/skill/hunter/Falconry.kt b/game/src/main/kotlin/content/skill/hunter/Falconry.kt index e88f448a7c..007b146706 100644 --- a/game/src/main/kotlin/content/skill/hunter/Falconry.kt +++ b/game/src/main/kotlin/content/skill/hunter/Falconry.kt @@ -149,7 +149,9 @@ class Falconry : Script { gfx("falcon_lift") anim("falconer_attack") val flight = shoot("gyr_falcon", target) - delay(2) + // Flight time is in 30ms client ticks; resume one game tick before impact so + // the caught falcon npc's queued spawn appears as the projectile lands + delay((flight / 20 - 1).coerceAtLeast(1)) if (target.tile.distanceTo(tile) > 8) { equipment.replace("falconers_glove", "falconers_glove_2") return @@ -162,7 +164,7 @@ class Falconry : Script { equipment.replace("falconers_glove", "falconers_glove_2") return } - target.gfx("falcon_impale", delay = flight) + target.gfx("falcon_impale") target.levels.set(Skill.Constitution, 0) val caught = NPCs.add(row.npc("caught"), target.tile, ticks = 100, owner = this) markHint(caught)