From 7afddca2a401f9a73a7b00d20a34df72469a6f60 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 17:16:19 -0700 Subject: [PATCH 01/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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