From f28e766f9bbaa722d5ac855a0e8e7314dd5323f0 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 15:31:32 -0700 Subject: [PATCH 01/15] Add Isafdar navigation obstacles and Lletya content Isafdar: - Dense forest passages (56 Agility for the two Elf Camp entrances), lining up with the passage centre before squeezing through - Tripwire and sticks traps with agility roll, fail damage and messages - Leaf traps: jump over, fall into the pit below on failure or when walking onto the trap tile, climb out via the protruding rocks - Log balances (45 Agility) at the hunting grounds and Arandar, interruptible while approaching, walked at walk speed while crossing - Elven Overpass cliffside rocks (59/68/85 Agility) - Arandar heavy gates, Underground Pass door pass-through and Poison Waste cave entrances, exits and sewer ladders Lletya: - Teleport crystal Activate with charge degradation - Eluned recharges teleport crystals (750 coins, decreasing to 150) - Islwyn sells and attunes crystal bows and shields (900k, decreasing) - Shopkeeper and resident dialogues - Elf musician locked facing south Fixes: - New crystal bow no longer dropped at the target as ammo when fired - objectApproach supports arrive = false to skip waiting for the pending route, matching objectOperate --- data/area/kandarin/arandar/arandar.objs.toml | 11 + .../underground_pass.objs.toml | 3 + data/area/tirannwn/isafdar/isafdar.anims.toml | 20 ++ data/area/tirannwn/isafdar/isafdar.areas.toml | 16 ++ data/area/tirannwn/isafdar/isafdar.objs.toml | 69 +++++ data/area/tirannwn/lletya/lletya.areas.toml | 3 + data/area/tirannwn/lletya/lletya.vars.toml | 11 + .../poison_waste/poison_waste.objs.toml | 35 +++ data/entity/npc/humanoid/musicians.npcs.toml | 1 + .../voidps/engine/entity/Approachable.kt | 7 +- .../mode/interact/PlayerOnObjectInteract.kt | 2 +- .../area/kandarin/arandar/ArandarGate.kt | 16 ++ .../area/tirannwn/isafdar/DenseForest.kt | 46 ++++ .../content/area/tirannwn/isafdar/Eluned.kt | 54 ++++ .../area/tirannwn/isafdar/ElvenOverpass.kt | 32 +++ .../area/tirannwn/isafdar/IsafdarTraps.kt | 50 ++++ .../content/area/tirannwn/isafdar/Islwyn.kt | 100 +++++++ .../area/tirannwn/isafdar/LeafTraps.kt | 79 ++++++ .../isafdar/UndergroundPassEntrance.kt | 24 ++ .../area/tirannwn/lletya/LletyaResidents.kt | 80 ++++++ .../area/tirannwn/lletya/LletyaShopkeepers.kt | 47 ++++ .../tirannwn/lletya/LletyaTreeEntrance.kt | 25 ++ .../area/tirannwn/lletya/TeleportCrystal.kt | 32 +++ .../poison_waste/PoisonWasteDungeon.kt | 45 ++++ .../skill/agility/shortcut/LogBalance.kt | 47 ++++ .../kotlin/content/skill/ranged/ammo/Ammo.kt | 2 +- .../content/area/tirannwn/IsafdarTest.kt | 250 ++++++++++++++++++ .../content/area/tirannwn/LletyaTest.kt | 130 +++++++++ .../skill/ranged/ammo/CrystalBowTest.kt | 30 +++ 29 files changed, 1264 insertions(+), 3 deletions(-) create mode 100644 data/area/kandarin/arandar/arandar.objs.toml create mode 100644 data/area/kandarin/ardougne/underground_pass/underground_pass.objs.toml create mode 100644 data/area/tirannwn/isafdar/isafdar.anims.toml create mode 100644 data/area/tirannwn/isafdar/isafdar.objs.toml create mode 100644 data/area/tirannwn/lletya/lletya.areas.toml create mode 100644 data/area/tirannwn/lletya/lletya.vars.toml create mode 100644 data/area/tirannwn/poison_waste/poison_waste.objs.toml create mode 100644 game/src/main/kotlin/content/area/kandarin/arandar/ArandarGate.kt create mode 100644 game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt create mode 100644 game/src/main/kotlin/content/area/tirannwn/isafdar/Eluned.kt create mode 100644 game/src/main/kotlin/content/area/tirannwn/isafdar/ElvenOverpass.kt create mode 100644 game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt create mode 100644 game/src/main/kotlin/content/area/tirannwn/isafdar/Islwyn.kt create mode 100644 game/src/main/kotlin/content/area/tirannwn/isafdar/LeafTraps.kt create mode 100644 game/src/main/kotlin/content/area/tirannwn/isafdar/UndergroundPassEntrance.kt create mode 100644 game/src/main/kotlin/content/area/tirannwn/lletya/LletyaResidents.kt create mode 100644 game/src/main/kotlin/content/area/tirannwn/lletya/LletyaShopkeepers.kt create mode 100644 game/src/main/kotlin/content/area/tirannwn/lletya/LletyaTreeEntrance.kt create mode 100644 game/src/main/kotlin/content/area/tirannwn/lletya/TeleportCrystal.kt create mode 100644 game/src/main/kotlin/content/area/tirannwn/poison_waste/PoisonWasteDungeon.kt create mode 100644 game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt create mode 100644 game/src/test/kotlin/content/area/tirannwn/LletyaTest.kt create mode 100644 game/src/test/kotlin/content/skill/ranged/ammo/CrystalBowTest.kt diff --git a/data/area/kandarin/arandar/arandar.objs.toml b/data/area/kandarin/arandar/arandar.objs.toml new file mode 100644 index 0000000000..89ca492810 --- /dev/null +++ b/data/area/kandarin/arandar/arandar.objs.toml @@ -0,0 +1,11 @@ +[arandar_heavy_gate] +id = 3944 +examine = "A heavy wooden gate." + +[arandar_heavy_gate_2] +id = 3945 +examine = "A heavy wooden gate." + +[arandar_log_balance] +id = 3933 +examine = "A slippery looking log." diff --git a/data/area/kandarin/ardougne/underground_pass/underground_pass.objs.toml b/data/area/kandarin/ardougne/underground_pass/underground_pass.objs.toml new file mode 100644 index 0000000000..62e2c393aa --- /dev/null +++ b/data/area/kandarin/ardougne/underground_pass/underground_pass.objs.toml @@ -0,0 +1,3 @@ +[underground_pass_cave_entrance] +id = 36000 +examine = "A dark cave entrance." diff --git a/data/area/tirannwn/isafdar/isafdar.anims.toml b/data/area/tirannwn/isafdar/isafdar.anims.toml new file mode 100644 index 0000000000..e014eeef81 --- /dev/null +++ b/data/area/tirannwn/isafdar/isafdar.anims.toml @@ -0,0 +1,20 @@ +[tripwire_step_over] +id = 1236 + +[sticks_trap_pass] +id = 819 + +[leaf_trap_jump] +id = 1115 + +[dense_forest_climb] +id = 839 + +[dense_forest_squeeze] +id = 1237 + +[elven_overpass_climb_up] +id = 2050 + +[elven_overpass_climb_down] +id = 2049 diff --git a/data/area/tirannwn/isafdar/isafdar.areas.toml b/data/area/tirannwn/isafdar/isafdar.areas.toml index 3e934dd2d1..9f12b1bb45 100644 --- a/data/area/tirannwn/isafdar/isafdar.areas.toml +++ b/data/area/tirannwn/isafdar/isafdar.areas.toml @@ -9,3 +9,19 @@ y = [3243, 3249] [isafdar_hunter_fishing_area] x = [2264, 2274] y = [3248, 3258] + +[isafdar_leaf_trap_1] +x = [2209] +y = [3203] + +[isafdar_leaf_trap_2] +x = [2267] +y = [3203] + +[isafdar_leaf_trap_3] +x = [2274] +y = [3174] + +[isafdar_leaf_trap_4] +x = [2277] +y = [3262] diff --git a/data/area/tirannwn/isafdar/isafdar.objs.toml b/data/area/tirannwn/isafdar/isafdar.objs.toml new file mode 100644 index 0000000000..f4d3284eb0 --- /dev/null +++ b/data/area/tirannwn/isafdar/isafdar.objs.toml @@ -0,0 +1,69 @@ +[tripwire] +id = 3921 +examine = "I wonder who set this here." + +[isafdar_sticks_trap] +id = 3922 +examine = "A suspicious looking bundle of sticks." + +[isafdar_leaves] +id = 3923 +examine = "Are those leaves hiding something?" + +[isafdar_leaves_2] +id = 3924 +examine = "Are those leaves hiding something?" + +[isafdar_leaves_3] +id = 3925 +examine = "Are those leaves hiding something?" + +[isafdar_protruding_rocks] +id = 3927 +examine = "Perhaps I could climb up these." + +[isafdar_log_balance] +id = 3931 +examine = "A slippery looking log." + +[isafdar_log_balance_2] +id = 3932 +examine = "A slippery looking log." + +[dense_forest] +id = 3937 +examine = "Very dense forest." + +[dense_forest_2] +id = 3938 +examine = "Very dense forest." + +[dense_forest_3] +id = 3939 +examine = "Very dense forest." + +[dense_forest_hard] +id = 3998 +level = 56 +examine = "Very dense forest." + +[dense_forest_hard_2] +id = 3999 +level = 56 +examine = "Very dense forest." + +[lletya_tree_entrance] +id = 8742 +examine = "An especially dense patch of trees." + +[elven_overpass_rocks_up] +id = 9296 +examine = "A rocky cliff face." + +[elven_overpass_rocks_down] +id = 9297 +examine = "A rocky cliff face." + +[isafdar_cave_entrance] +id = 4006 +examine = "A dark cave entrance." diff --git a/data/area/tirannwn/lletya/lletya.areas.toml b/data/area/tirannwn/lletya/lletya.areas.toml new file mode 100644 index 0000000000..a2d423a1f7 --- /dev/null +++ b/data/area/tirannwn/lletya/lletya.areas.toml @@ -0,0 +1,3 @@ +[lletya_teleport] +x = [2328, 2330] +y = [3171, 3173] diff --git a/data/area/tirannwn/lletya/lletya.vars.toml b/data/area/tirannwn/lletya/lletya.vars.toml new file mode 100644 index 0000000000..269aed2803 --- /dev/null +++ b/data/area/tirannwn/lletya/lletya.vars.toml @@ -0,0 +1,11 @@ +[teleport_crystal_recharges] +format = "int" +default = 0 +persist = true +transmit = false + +[crystal_seed_attunements] +format = "int" +default = 0 +persist = true +transmit = false diff --git a/data/area/tirannwn/poison_waste/poison_waste.objs.toml b/data/area/tirannwn/poison_waste/poison_waste.objs.toml new file mode 100644 index 0000000000..025aab6728 --- /dev/null +++ b/data/area/tirannwn/poison_waste/poison_waste.objs.toml @@ -0,0 +1,35 @@ +[poison_waste_cave_entrance] +id = 26684 +examine = "A murky cave entrance." + +[poison_waste_cave_entrance_2] +id = 26685 +examine = "A murky cave entrance." + +[poison_waste_cave_entrance_3] +id = 26686 +examine = "A murky cave entrance." + +[poison_waste_cave_exit] +id = 26571 +examine = "A way out of the cave." + +[poison_waste_cave_exit_2] +id = 26572 +examine = "A way out of the cave." + +[poison_waste_cave_exit_3] +id = 26573 +examine = "A way out of the cave." + +[poison_waste_cave_exit_4] +id = 26574 +examine = "A way out of the cave." + +[poison_waste_sewer_ladder_up] +id = 26518 +examine = "A ladder leading up." + +[poison_waste_sewer_ladder_down] +id = 26519 +examine = "A ladder leading down." diff --git a/data/entity/npc/humanoid/musicians.npcs.toml b/data/entity/npc/humanoid/musicians.npcs.toml index b9b81a5029..85d40561ad 100644 --- a/data/entity/npc/humanoid/musicians.npcs.toml +++ b/data/entity/npc/humanoid/musicians.npcs.toml @@ -169,5 +169,6 @@ examine = "He looks lovelorn." [musician_isafdar] id = 8723 song = 704 +interacts = false categories = ["elf"] examine = "She's beautiful." \ No newline at end of file diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/Approachable.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/Approachable.kt index c4b9218b7f..a1a75730cf 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/Approachable.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/Approachable.kt @@ -31,9 +31,12 @@ interface Approachable { } } - fun objectApproach(option: String, obj: String = "*", handler: suspend Player.(PlayerOnObjectInteract) -> Unit) { + fun objectApproach(option: String, obj: String = "*", arrive: Boolean = true, handler: suspend Player.(PlayerOnObjectInteract) -> Unit) { Script.checkLoading() Wildcards.find(obj, Wildcard.Object) { id -> + if (!arrive) { + noDelays.add("$option:$id") + } playerObject.getOrPut("$option:$id") { mutableListOf() }.add(handler) } } @@ -143,6 +146,7 @@ interface Approachable { } companion object : AutoCloseable { + val noDelays = mutableSetOf() val playerPlayer = Object2ObjectOpenHashMap Unit>>(2) val onPlayer = Object2ObjectOpenHashMap Unit>>(10) @@ -166,6 +170,7 @@ interface Approachable { var npcMark: (Player.(NPC) -> Unit)? = null override fun close() { + noDelays.clear() playerPlayer.clear() onPlayer.clear() playerNpc.clear() diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/interact/PlayerOnObjectInteract.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/interact/PlayerOnObjectInteract.kt index fa2e86db03..ccc5e1e194 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/interact/PlayerOnObjectInteract.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/interact/PlayerOnObjectInteract.kt @@ -22,7 +22,7 @@ data class PlayerOnObjectInteract( } override fun approach() { - invoke(emptySet(), Approachable.playerObject) + invoke(Approachable.noDelays, Approachable.playerObject) } private fun invoke(noDelays: Set, map: Map Unit>>) { diff --git a/game/src/main/kotlin/content/area/kandarin/arandar/ArandarGate.kt b/game/src/main/kotlin/content/area/kandarin/arandar/ArandarGate.kt new file mode 100644 index 0000000000..c22cd20368 --- /dev/null +++ b/game/src/main/kotlin/content/area/kandarin/arandar/ArandarGate.kt @@ -0,0 +1,16 @@ +package content.area.kandarin.arandar + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.type.Tile + +class ArandarGate : Script { + + init { + objectOperate("Enter", "arandar_heavy_gate,arandar_heavy_gate_2") { (target) -> + val dest = Tile(target.tile.x, if (tile.y > target.tile.y) target.tile.y - 1 else target.tile.y + 1, tile.level) + delay() + tele(dest) + } + } +} diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt new file mode 100644 index 0000000000..e9865fd4ab --- /dev/null +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt @@ -0,0 +1,46 @@ +package content.area.tirannwn.isafdar + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.chat.obstacle +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.type.Direction +import world.gregs.voidps.type.Tile + +class DenseForest : Script { + + init { + objectOperate("Enter", "dense_forest,dense_forest_2,dense_forest_3,dense_forest_hard,dense_forest_hard_2") { (target) -> + val level = target.def["level", 0] + if (level > 0 && !has(Skill.Agility, level)) { + obstacle(level) + return@objectOperate + } + // Rotation determines the passage's axis; squeeze through to the far side + val sideways = target.rotation == 1 || target.rotation == 3 + val direction = if (sideways) { + if (tile.x < target.tile.x) Direction.EAST else Direction.WEST + } else { + if (tile.y < target.tile.y) Direction.NORTH else Direction.SOUTH + } + if (target.id == "dense_forest" || target.id == "dense_forest_2") { + // Line up on the tile just before the passage's footprint before squeezing through + val start = if (sideways) { + val x = if (direction == Direction.EAST) target.tile.x - 1 else target.tile.x + target.width + val centre = tile.y.coerceIn(target.tile.y + (target.height - 1) / 2, target.tile.y + target.height / 2) + Tile(x, centre, tile.level) + } else { + val y = if (direction == Direction.NORTH) target.tile.y - 1 else target.tile.y + target.height + val centre = tile.x.coerceIn(target.tile.x + (target.width - 1) / 2, target.tile.x + target.width / 2) + Tile(centre, y, tile.level) + } + walkOverDelay(start) + } + val dest = tile.add(direction.delta.x * 3, direction.delta.y * 3) + face(direction) + delay() + anim(if (target.id == "dense_forest") "dense_forest_climb" else "dense_forest_squeeze") + exactMoveDelay(dest, delay = 90, direction = direction) + } + } +} diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/Eluned.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/Eluned.kt new file mode 100644 index 0000000000..7f386e8521 --- /dev/null +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/Eluned.kt @@ -0,0 +1,54 @@ +package content.area.tirannwn.isafdar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +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.player.dialogue.type.statement +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove + +class Eluned : Script { + + init { + npcOperate("Talk-to", "eluned,eluned_2,eluned_3,eluned_lletya") { + npc("Hello there, it's a lovely day for a walk in the woods. So what can I help you with?") + if (!inventory.contains("crystal_teleport_seed_uncharged")) { + player("I'm just looking around.") + return@npcOperate + } + player("I am looking to recharge teleportation crystals.") + recharge() + } + + itemOnNPCOperate("crystal_teleport_seed_uncharged", "eluned,eluned_2,eluned_3,eluned_lletya") { + recharge() + } + } + + suspend fun Player.recharge() { + val price = rechargePrice() + npc("Very well. I'll recharge your teleportation crystal for $price gold. What do you say?") + choice { + option("Recharge a crystal.") { + if (!inventory.contains("coins", price)) { + player("Actually, I don't have enough coins.") + return@option + } + if (inventory.remove("coins", price) && inventory.remove("crystal_teleport_seed_uncharged")) { + inventory.add("crystal_teleport_seed_4") + inc("teleport_crystal_recharges") + statement("Eluned recharges your elven teleportation crystal for $price gold.") + } + } + option("Nevermind, I really must be going.") + } + } + + fun Player.rechargePrice(): Int = (750 - 150 * get("teleport_crystal_recharges", 0)).coerceAtLeast(150) +} diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/ElvenOverpass.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/ElvenOverpass.kt new file mode 100644 index 0000000000..592e758e42 --- /dev/null +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/ElvenOverpass.kt @@ -0,0 +1,32 @@ +package content.area.tirannwn.isafdar + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.chat.noInterest +import world.gregs.voidps.engine.entity.character.player.chat.obstacle +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.type.Tile + +class ElvenOverpass : Script { + + init { + objectOperate("Climb", "elven_overpass_rocks_up,elven_overpass_rocks_down") { (target) -> + val up = target.id == "elven_overpass_rocks_up" + val (level, dest) = when (target.tile) { + Tile(2333, 3252) -> 85 to Tile(2338, 3253) + Tile(2338, 3282) -> 68 to Tile(2338, 3286) + Tile(2346, 3299) -> 59 to Tile(2344, 3294) + Tile(2337, 3253) -> 85 to Tile(2332, 3252) + Tile(2338, 3285) -> 68 to Tile(2338, 3281) + Tile(2344, 3295) -> 59 to Tile(2346, 3300) + else -> return@objectOperate noInterest() + } + if (!has(Skill.Agility, level)) { + obstacle(level) + return@objectOperate + } + anim(if (up) "elven_overpass_climb_up" else "elven_overpass_climb_down") + exactMoveDelay(dest, delay = 90) + } + } +} diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt new file mode 100644 index 0000000000..9be92d28c2 --- /dev/null +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt @@ -0,0 +1,50 @@ +package content.area.tirannwn.isafdar + +import content.entity.combat.hit.damage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +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 +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +class IsafdarTraps : Script { + + init { + objectOperate("Step-over", "tripwire") { (target) -> + val northSouth = target.rotation == 0 || target.rotation == 2 + crossTrap(target, northSouth, "tripwire_step_over", "You successfully step over the tripwire.", "You snag the trip wire as you step over it.") + } + + objectOperate("Pass", "isafdar_sticks_trap") { (target) -> + val northSouth = target.rotation == 1 || target.rotation == 3 + crossTrap(target, northSouth, "sticks_trap_pass", "You manage to skillfully pass the trap.", "You set off the trap as you pass.") + } + } + + suspend fun Player.crossTrap(target: GameObject, northSouth: Boolean, anim: String, success: String, failure: String) { + // Rotation determines the trap's axis; cross to the far side relative to the player + val direction: Direction + val dest = if (northSouth) { + direction = if (tile.y > target.tile.y) Direction.SOUTH else Direction.NORTH + Tile(target.tile.x, if (tile.y > target.tile.y) target.tile.y - 1 else target.tile.y + 2, tile.level) + } else { + direction = if (tile.x > target.tile.x) Direction.WEST else Direction.EAST + Tile(if (tile.x > target.tile.x) target.tile.x - 1 else target.tile.x + 2, target.tile.y, tile.level) + } + face(direction) + delay() + anim(anim) + exactMoveDelay(dest, delay = 60, direction = direction) + if (Level.success(levels.get(Skill.Agility), 128..250)) { // TODO unknown rate + message(success, ChatType.Filter) + } else { + message(failure) + damage(random.nextInt(20, 41)) // TODO unknown damage + } + } +} diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/Islwyn.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/Islwyn.kt new file mode 100644 index 0000000000..b6010fc877 --- /dev/null +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/Islwyn.kt @@ -0,0 +1,100 @@ +package content.area.tirannwn.isafdar + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +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.player.dialogue.type.statement +import content.quest.questCompleted +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove + +class Islwyn : Script { + + init { + npcOperate("Talk-to", "islwyn") { + if (!questCompleted("roving_elves")) { + npc("Hello there, it's a lovely day for a walk in the woods. So what can I help you with?") + player("I'm just looking around.") + return@npcOperate + } + npc("Welcome back to the land of the elves, friend! Do you need your seeds charged into equipment?") + choice { + option("I need to buy a new piece of equipment.") { + npc("Ah, very well. I will sell you a new bow or shield for 900,000 coins.") + choice { + option("I'd like to buy a new bow.") { + buy("new_crystal_bow", "crystal bow") + } + option("I'd like to buy a new shield.") { + buy("new_crystal_shield", "crystal shield") + } + option("Oh well... never mind then.") + } + } + option("I need to recharge my seeds into equipment.") { + recharge() + } + } + } + + itemOnNPCOperate("crystal_seed", "islwyn") { + if (!questCompleted("roving_elves")) { + npc("That's a curious crystal you have there, friend.") + return@itemOnNPCOperate + } + recharge() + } + } + + suspend fun Player.buy(item: String, name: String) { + if (!inventory.remove("coins", NEW_PRICE)) { + player("Sorry but I don't have that much.") + npc("Well sorry, but I can't let it go for anything less.") + return + } + inventory.add(item) + statement("You hand over ${String.format("%,d", NEW_PRICE)} coins and get a $name in return.") + npc("Good hunting.") + player("Thanks... goodbye.") + } + + suspend fun Player.recharge() { + if (!inventory.contains("crystal_seed")) { + statement("You don't have any seeds to recharge.") + return + } + val price = (900_000 - 180_000 * get("crystal_seed_attunements", 0)).coerceAtLeast(180_000) + npc("Certainly, that will cost you ${String.format("%,d", price)} coins.") + choice { + option("Recharge my seed into a bow, please.") { + attune("crystal_bow_full", price) + } + option("Recharge my seed into a shield, please.") { + attune("crystal_shield_full", price) + } + option("That's too much for me.") + } + } + + suspend fun Player.attune(item: String, price: Int) { + if (!inventory.contains("coins", price)) { + statement("You don't have enough coins, you need ${String.format("%,d", price)}.") + return + } + if (inventory.remove("coins", price) && inventory.remove("crystal_seed")) { + inventory.add(item) + inc("crystal_seed_attunements") + npc("Good hunting.") + } + } + + companion object { + private const val NEW_PRICE = 900_000 + } +} diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/LeafTraps.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/LeafTraps.kt new file mode 100644 index 0000000000..7e2b971dc1 --- /dev/null +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/LeafTraps.kt @@ -0,0 +1,79 @@ +package content.area.tirannwn.isafdar + +import content.entity.combat.hit.directHit +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.definition.AreaDefinition +import world.gregs.voidps.engine.entity.character.move.tele +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.chat.noInterest +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.queue.strongQueue +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +class LeafTraps : Script { + + init { + objectOperate("Jump", "isafdar_leaves,isafdar_leaves_2,isafdar_leaves_3") { (target) -> + val site = sites.firstOrNull { target.tile.within(it.centre, 3) } ?: return@objectOperate noInterest() + // West, centre and south pits are jumped north-south; the north pit east-west + val direction: Direction + val dest = if (site.centre.y == 3262) { + direction = if (tile.x > site.centre.x) Direction.WEST else Direction.EAST + Tile(if (tile.x > site.centre.x) site.centre.x - 2 else site.centre.x + 2, site.centre.y) + } else { + direction = if (tile.y > site.centre.y) Direction.SOUTH else Direction.NORTH + Tile(site.centre.x, if (tile.y > site.centre.y) site.centre.y - 2 else site.centre.y + 2) + } + face(direction) + delay() + if (Level.success(levels.get(Skill.Agility), 128..250)) { // TODO unknown rate + anim("leaf_trap_jump") + exactMoveDelay(dest, delay = 60, direction = direction) + message("You safely jump across.", ChatType.Filter) + } else { + fall(this, site) + } + } + + objectOperate("Climb", "isafdar_protruding_rocks") { (target) -> + val site = sites.firstOrNull { target.tile.within(it.pit, 2) } ?: return@objectOperate noInterest() + tele(site.exit) + message("You climb out of the pit.", ChatType.Filter) + } + + entered("isafdar_leaf_trap_1", ::stumble) + entered("isafdar_leaf_trap_2", ::stumble) + entered("isafdar_leaf_trap_3", ::stumble) + entered("isafdar_leaf_trap_4", ::stumble) + } + + fun stumble(player: Player, definition: AreaDefinition) { + val site = sites.firstOrNull { player.tile.within(it.centre, 3) } ?: return + player.strongQueue("leaf_trap_fall", 1) { + fall(player, site) + } + } + + private data class Site(val centre: Tile, val pit: Tile, val exit: Tile) + + companion object { + // TODO which pit belongs to the west, centre and north traps is unverified + private val sites = listOf( + Site(centre = Tile(2209, 3203), pit = Tile(2313, 9656), exit = Tile(2209, 3201)), + Site(centre = Tile(2267, 3203), pit = Tile(2354, 9656), exit = Tile(2267, 3201)), + Site(centre = Tile(2274, 3174), pit = Tile(2336, 9656), exit = Tile(2274, 3172)), + Site(centre = Tile(2277, 3262), pit = Tile(2354, 9643), exit = Tile(2275, 3262)), + ) + + private fun fall(player: Player, site: Site) { + player.message("You fall through and onto some spikes.") + player.tele(site.pit) + player.directHit(random.nextInt(20, 41)) // TODO unknown damage + } + } +} diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/UndergroundPassEntrance.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/UndergroundPassEntrance.kt new file mode 100644 index 0000000000..c024a8f92f --- /dev/null +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/UndergroundPassEntrance.kt @@ -0,0 +1,24 @@ +package content.area.tirannwn.isafdar + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.type.Tile + +class UndergroundPassEntrance : Script { + + init { + // Pass-through until the Underground Pass dungeon is implemented + objectOperate("Enter", "isafdar_cave_entrance") { + message("You enter the cave and follow the passage through the mountain...") + delay(2) + tele(Tile(2438, 3315)) + } + + objectOperate("Enter", "underground_pass_cave_entrance") { + message("You enter the cave and follow the passage through the mountain...") + delay(2) + tele(Tile(2312, 3217)) + } + } +} diff --git a/game/src/main/kotlin/content/area/tirannwn/lletya/LletyaResidents.kt b/game/src/main/kotlin/content/area/tirannwn/lletya/LletyaResidents.kt new file mode 100644 index 0000000000..0c7413781f --- /dev/null +++ b/game/src/main/kotlin/content/area/tirannwn/lletya/LletyaResidents.kt @@ -0,0 +1,80 @@ +package content.area.tirannwn.lletya + +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.male + +class LletyaResidents : Script { + + init { + npcOperate("Talk-to", "goreu") { + player("Hello.") + npc("Good day, can I help you?") + player("No thanks I'm just watching the world go by.") + npc("Well I can think of no better place to do it, it is beautiful here is it not?") + player("Indeed it is.") + } + + npcOperate("Talk-to", "arvel") { + npc("Good day traveller. You are far from home, what brings you here?") + player("I am a wandering ${if (male) "hero" else "heroine"}. I come here in search of adventure.") + npc("Sounds ghastly, I just want to live in peace.") + player("Unfortunately without people like me, peace doesn't last for long.") + npc("True, but then again most adventurers cause as much trouble as they put right.") + player("You've got a point there... Hmm...") + npc("Well, good day traveller. And always do the right thing.") + } + + npcOperate("Talk-to", "mawrth") { + npc("Those children are nothing but trouble - if I did not watch them all the time, Seren knows what trouble they would get in to!") + player("They look old enough to look after themselves.") + npc("They are only 34 and 38, far too young to be left unsupervised.") + player("Only!?! How old does that make you?") + npc("Has no one told you it is rude to ask a lady her age?") + player("Sorry, I wasn't thinking. Anyway... I'd better stop distracting you.") + npc("Okay, See you some other time.") + } + + npcOperate("Talk-to", "kelyn") { + player("Hello.") + npc("Huh... Oh sorry, you made me jump. I was miles away, day dreaming.") + player("About what may I ask?") + npc("I was thinking about the crystal spires of Prifddinas.") + player("It must be beautiful, I've only seen the city walls.") + npc("I have never seen it, all I know are the stories. I hope that changes one day.") + } + + npcOperate("Talk-to", "eoin") { + player("Hello.") + npc("Sorry, I cannot stop or Iona will catch me, we are playing tag!") + } + + npcOperate("Talk-to", "iona") { + player("Hello.") + npc("I can never catch Eoin, he is just too fast, I am always 'It'.") + } + + npcOperate("Talk-to", "ysgawyn") { + player("Hello.") + npc("Greetings, human. Not many of your kind find their way to Lletya.") + player("It's quite the hidden village.") + npc("And it must remain so. The safety of everyone here depends on it.") + } + + npcOperate("Talk-to", "gwir") { + player("Hello.") + npc("Good day to you. I am recording the history of our people, there is much to set down.") + player("Anything I can help with?") + npc("Unless you can remember what happened a few thousand years ago, I fear not.") + } + + npcOperate("Talk-to", "arianwyn_lletya,arianwyn_lletya_2,arianwyn_lletya_3") { + statement("He doesn't seem interested in talking to you.") + } + } +} diff --git a/game/src/main/kotlin/content/area/tirannwn/lletya/LletyaShopkeepers.kt b/game/src/main/kotlin/content/area/tirannwn/lletya/LletyaShopkeepers.kt new file mode 100644 index 0000000000..55b2201b57 --- /dev/null +++ b/game/src/main/kotlin/content/area/tirannwn/lletya/LletyaShopkeepers.kt @@ -0,0 +1,47 @@ +package content.area.tirannwn.lletya + +import content.entity.npc.shop.openShop +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 world.gregs.voidps.engine.Script + +class LletyaShopkeepers : Script { + + init { + npcOperate("Talk-to", "eudav,gethin") { (target) -> + npc("Can I help you at all?") + choice { + option("Yes please. What are you selling?") { + openShop(target.def["shop"]) + } + option("No thanks.") + } + } + + npcOperate("Talk-to", "oronwen") { (target) -> + npc("Hello, can I help?") + choice { + option("Yes please. What are you selling?") { + openShop(target.def["shop"]) + } + option("No thanks.") + } + } + + npcOperate("Talk-to", "dalldav") { (target) -> + npc("Can I help you at all?") + choice { + option("Yes please. What are you selling?") { + openShop(target.def["shop"]) + } + option("Why do you sell this stuff? The Crystal Bow is so much better.") { + npc("We keep all these old toys to train our children with, but if people will part with coins for them, then they are theirs!") + } + option("No thanks.") + } + } + } +} diff --git a/game/src/main/kotlin/content/area/tirannwn/lletya/LletyaTreeEntrance.kt b/game/src/main/kotlin/content/area/tirannwn/lletya/LletyaTreeEntrance.kt new file mode 100644 index 0000000000..955ba8a748 --- /dev/null +++ b/game/src/main/kotlin/content/area/tirannwn/lletya/LletyaTreeEntrance.kt @@ -0,0 +1,25 @@ +package content.area.tirannwn.lletya + +import content.entity.player.dialogue.type.statement +import content.quest.questCompleted +import world.gregs.voidps.engine.Script +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Tile + +class LletyaTreeEntrance : Script { + + init { + objectOperate("Pass", "lletya_tree_entrance") { (target) -> + if (!questCompleted("roving_elves")) { + statement("The trees are too dense for you to find a way through.") + return@objectOperate + } + val direction = if (tile.x < target.tile.x) Direction.EAST else Direction.WEST + val dest = Tile(target.tile.x + direction.delta.x * 2, target.tile.y, tile.level) + face(direction) + delay() + anim("dense_forest_squeeze") + exactMoveDelay(dest, delay = 90, direction = direction) + } + } +} diff --git a/game/src/main/kotlin/content/area/tirannwn/lletya/TeleportCrystal.kt b/game/src/main/kotlin/content/area/tirannwn/lletya/TeleportCrystal.kt new file mode 100644 index 0000000000..4a0961b525 --- /dev/null +++ b/game/src/main/kotlin/content/area/tirannwn/lletya/TeleportCrystal.kt @@ -0,0 +1,32 @@ +package content.area.tirannwn.lletya + +import content.entity.player.dialogue.type.choice +import content.skill.magic.jewellery.itemTeleport +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.definition.Areas + +class TeleportCrystal : Script { + + init { + itemOption("Activate", "crystal_teleport_seed_#") { + if (contains("delay")) { + return@itemOption + } + val item = it.item.id + choice("Select an Option") { + option("Teleport to Lletya.") { + if (itemTeleport(this, it.inventory, it.slot, Areas["lletya_teleport"], "jewellery")) { + if (item == "crystal_teleport_seed_1") { + message("Your teleportation crystal has degraded to a tiny elf crystal.") + message("Eluned can re-enchant it.") + } else { + message("Your teleportation crystal has degraded from use.") + } + } + } + option("Cancel.") + } + } + } +} diff --git a/game/src/main/kotlin/content/area/tirannwn/poison_waste/PoisonWasteDungeon.kt b/game/src/main/kotlin/content/area/tirannwn/poison_waste/PoisonWasteDungeon.kt new file mode 100644 index 0000000000..b80aa159b2 --- /dev/null +++ b/game/src/main/kotlin/content/area/tirannwn/poison_waste/PoisonWasteDungeon.kt @@ -0,0 +1,45 @@ +package content.area.tirannwn.poison_waste + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.equals + +class PoisonWasteDungeon : Script { + + init { + objectOperate("Enter", "poison_waste_cave_entrance,poison_waste_cave_entrance_2,poison_waste_cave_entrance_3") { + message("You enter the murky cave...") + delay(2) + tele(Tile(1989, 4174)) + } + + objectOperate("Exit", "poison_waste_cave_exit,poison_waste_cave_exit_2,poison_waste_cave_exit_3,poison_waste_cave_exit_4") { + delay(2) + tele(Tile(2321, 3100)) + } + + objectOperate("Climb-up", "poison_waste_sewer_ladder_up") { (target) -> + anim("climb_up") + delay(2) + val dest = if (target.tile.equals(2041, 4189) || target.tile.equals(2041, 4172)) { + target.tile.add(1, 0, 1) + } else { + target.tile.add(0, 1, 1) + } + tele(dest) + } + + objectOperate("Climb-down", "poison_waste_sewer_ladder_down") { (target) -> + anim("climb_down") + delay(2) + val dest = if (target.tile.equals(2041, 4189) || target.tile.equals(2041, 4172)) { + target.tile.add(-1, 0, -1) + } else { + target.tile.add(0, -1, -1) + } + tele(dest) + } + } +} diff --git a/game/src/main/kotlin/content/skill/agility/shortcut/LogBalance.kt b/game/src/main/kotlin/content/skill/agility/shortcut/LogBalance.kt index 5ef428a62d..e2ef3d0f7e 100644 --- a/game/src/main/kotlin/content/skill/agility/shortcut/LogBalance.kt +++ b/game/src/main/kotlin/content/skill/agility/shortcut/LogBalance.kt @@ -4,6 +4,8 @@ import content.entity.combat.hit.damage import content.entity.gfx.areaGfx import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.move.running +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.clearRenderEmote import world.gregs.voidps.engine.entity.character.player.renderEmote @@ -12,7 +14,9 @@ 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.GameObject import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Tile import world.gregs.voidps.type.equals import world.gregs.voidps.type.random @@ -37,6 +41,16 @@ class LogBalance : Script { message("... and make it safely to the other side.", ChatType.Filter) } + objectApproach("Cross", "isafdar_log_balance,isafdar_log_balance_2,arandar_log_balance", arrive = false) { (target) -> + approachRange(10) + steps.clear() + crossLog(target) + } + + objectOperate("Cross", "isafdar_log_balance,isafdar_log_balance_2,arandar_log_balance") { (target) -> + crossLog(target) + } + objectOperate("Walk-across", "ardougne_log_balance_east,ardougne_log_balance_west") { (target) -> if (!has(Skill.Agility, 33)) { message("You need an Agility level of 33 to negotiate this obstacle.") @@ -76,4 +90,37 @@ class LogBalance : Script { } } } + + suspend fun Player.crossLog(target: GameObject) { + if (!has(Skill.Agility, 45)) { + message("You need an Agility level of 45 to negotiate this obstacle.") + return + } + val (start, end) = when (target.id) { + "isafdar_log_balance" -> Tile(2202, 3237) to Tile(2196, 3237) + "isafdar_log_balance_2" -> Tile(2258, 3250) to Tile(2264, 3250) + else -> Tile(2290, 3232) to Tile(2290, 3239) + } + val from = if (tile.distanceTo(start) <= tile.distanceTo(end)) start else end + val to = if (from == start) end else start + // Approach the log at normal speed; walking away cancels + walkTo(from) + var count = 0 + while (tile != from && count++ < 50) { + pause(1) + } + if (tile != from) { + return + } + val wasRunning = running + running = false + message("You walk carefully across the slippery log...", ChatType.Filter) + delay() + renderEmote("rope_balance") + walkOverDelay(to) + clearRenderEmote() + running = wasRunning + exp(Skill.Agility, 7.5) + message("... and make it safely to the other side.", ChatType.Filter) + } } diff --git a/game/src/main/kotlin/content/skill/ranged/ammo/Ammo.kt b/game/src/main/kotlin/content/skill/ranged/ammo/Ammo.kt index 950918286e..daa6e1d902 100644 --- a/game/src/main/kotlin/content/skill/ranged/ammo/Ammo.kt +++ b/game/src/main/kotlin/content/skill/ranged/ammo/Ammo.kt @@ -68,7 +68,7 @@ class Ammo(val ammoDefinitions: AmmoDefinitions, val weaponStyles: WeaponStyleDe when { weapon.id.startsWith("zaryte_bow") -> player.ammo = "zaryte_arrow" weapon.id.endsWith("sling") -> player.ammo = "sling_rock" - weapon.id.startsWith("crystal_bow") -> player.ammo = "special_arrow" + weapon.id.startsWith("crystal_bow") || weapon.id == "new_crystal_bow" -> player.ammo = "special_arrow" else -> player.ammo = weapon.id } return true diff --git a/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt b/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt new file mode 100644 index 0000000000..dbc96fc5db --- /dev/null +++ b/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt @@ -0,0 +1,250 @@ +package content.area.tirannwn + +import FakeRandom +import WorldTest +import containsMessage +import objectOption +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import walk +import world.gregs.voidps.engine.entity.character.move.running +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.setRandom + +class IsafdarTest : WorldTest() { + + @Test + fun `Squeeze through dense forest`() { + val player = createPlayer(Tile(2188, 3167)) + player.levels.set(Skill.Agility, 56) + val forest = GameObjects.find(Tile(2187, 3169), "dense_forest_hard") + + player.objectOption(forest, "Enter") + tick(8) + + assertEquals(Tile(2188, 3171), player.tile) + } + + @Test + fun `Entering dense forest from an offset tile lines up with the passage first`() { + val player = createPlayer(Tile(2230, 3150)) + val forest = GameObjects.find(Tile(2232, 3148), "dense_forest") + + player.objectOption(forest, "Enter") + tick(12) + + assertEquals(Tile(2234, 3149), player.tile) + } + + @Test + fun `Entering dense forest from the east walks around its footprint first`() { + val player = createPlayer(Tile(2274, 3193)) + val forest = GameObjects.find(Tile(2272, 3191), "dense_forest") + + player.objectOption(forest, "Enter") + tick(12) + + assertEquals(Tile(2271, 3192), player.tile) + } + + @Test + fun `Can't enter dense forest without agility level`() { + val player = createPlayer(Tile(2188, 3167)) + player.levels.set(Skill.Agility, 55) + val forest = GameObjects.find(Tile(2187, 3169), "dense_forest_hard") + + player.objectOption(forest, "Enter") + tick(4) + + assertTrue(player.containsMessage("You need an Agility level of 56")) + assertEquals(Tile(2188, 3168), player.tile) + } + + @Test + fun `Cross isafdar log balance`() { + val player = createPlayer(Tile(2202, 3237)) + player.levels.set(Skill.Agility, 45) + val log = GameObjects.find(Tile(2201, 3237), "isafdar_log_balance") + + player.objectOption(log, "Cross") + tick(12) + + assertEquals(Tile(2196, 3237), player.tile) + assertEquals(7.5, player.experience.get(Skill.Agility)) + } + + @Test + fun `Crossing the log never paths south around it`() { + val near = GameObjects.find(Tile(2259, 3250), "isafdar_log_balance_2") + val far = GameObjects.find(Tile(2263, 3250), "isafdar_log_balance_2") + var count = 0 + for ((start, log) in listOf( + Tile(2255, 3250) to near, + Tile(2255, 3250) to far, + Tile(2258, 3250) to near, + Tile(2258, 3251) to near, + Tile(2256, 3251) to near, + Tile(2250, 3250) to near, + Tile(2252, 3252) to near, + )) { + val player = createPlayer(start, "log_crosser_${count++}") + player.running = true + player.levels.set(Skill.Agility, 45) + tick(2) + + player.objectOption(log, "Cross") + repeat(30) { + tick() + assertTrue(player.tile.y >= 3250, "player from $start stepped south of the log at ${player.tile}") + } + + assertEquals(Tile(2264, 3250), player.tile) + assertTrue(player.running, "run setting not restored after crossing from $start") + } + } + + @Test + fun `Walking away while approaching the log cancels the crossing`() { + val player = createPlayer(Tile(2250, 3250)) + player.running = true + player.levels.set(Skill.Agility, 45) + val log = GameObjects.find(Tile(2259, 3250), "isafdar_log_balance_2") + + player.objectOption(log, "Cross") + tick(2) + player.walk(Tile(2250, 3252)) + tick(20) + + assertEquals(Tile(2250, 3252), player.tile) + assertEquals(0.0, player.experience.get(Skill.Agility)) + } + + @Test + fun `Can't cross log balance without agility level`() { + val player = createPlayer(Tile(2202, 3237)) + player.levels.set(Skill.Agility, 44) + val log = GameObjects.find(Tile(2201, 3237), "isafdar_log_balance") + + player.objectOption(log, "Cross") + tick(4) + + assertTrue(player.containsMessage("You need an Agility level of 45")) + } + + @Test + fun `Step over tripwire`() { + val player = createPlayer(Tile(2215, 3153)) + player.levels.set(Skill.Agility, 1) + val wire = GameObjects.find(Tile(2215, 3154), "tripwire") + + player.objectOption(wire, "Step-over") + tick(8) + + assertEquals(Tile(2215, 3156), player.tile) + assertTrue(player.containsMessage("You successfully step over the tripwire.")) + } + + @Test + fun `Failing to step over tripwire deals damage`() { + val player = createPlayer(Tile(2215, 3153)) + player.levels.set(Skill.Agility, 1) + val wire = GameObjects.find(Tile(2215, 3154), "tripwire") + setRandom(object : FakeRandom() { + override fun nextBits(bitCount: Int): Int = Int.MAX_VALUE + }) + + player.objectOption(wire, "Step-over") + tick(8) + + assertEquals(Tile(2215, 3156), player.tile) + assertTrue(player.containsMessage("You snag the trip wire as you step over it.")) + assertTrue(player.levels.get(Skill.Constitution) < player.levels.getMax(Skill.Constitution)) + } + + @Test + fun `Pass sticks trap`() { + val player = createPlayer(Tile(2236, 3180)) + val sticks = GameObjects.find(Tile(2235, 3181), "isafdar_sticks_trap") + + player.objectOption(sticks, "Pass") + tick(8) + + assertTrue(player.containsMessage("You manage to skillfully pass the trap.")) + } + + @Test + fun `Jump over leaf trap`() { + val player = createPlayer(Tile(2274, 3172)) + player.levels.set(Skill.Agility, 99) + val leaves = GameObjects.find(Tile(2274, 3174), "isafdar_leaves_2") + + player.objectOption(leaves, "Jump") + tick(8) + + assertEquals(Tile(2274, 3176), player.tile) + assertTrue(player.containsMessage("You safely jump across.")) + } + + @Test + fun `Walking onto a leaf trap drops the player into the pit`() { + val player = createPlayer(Tile(2274, 3171)) + + player.walk(Tile(2274, 3174)) + tickIf { player.tile.y < 9000 } + tick(2) + + assertEquals(Tile(2336, 9656), player.tile) + assertTrue(player.containsMessage("You fall through and onto some spikes.")) + assertTrue(player.levels.get(Skill.Constitution) < player.levels.getMax(Skill.Constitution)) + } + + @Test + fun `Climb out of a leaf trap pit`() { + val player = createPlayer(Tile(2336, 9656)) + val rocks = GameObjects.find(Tile(2336, 9656), "isafdar_protruding_rocks") + + player.objectOption(rocks, "Climb") + tick(4) + + assertEquals(Tile(2274, 3172), player.tile) + } + + @Test + fun `Climb elven overpass rocks`() { + val player = createPlayer(Tile(2346, 3300)) + player.levels.set(Skill.Agility, 59) + val rocks = GameObjects.find(Tile(2346, 3299), "elven_overpass_rocks_up") + + player.objectOption(rocks, "Climb") + tick(8) + + assertEquals(Tile(2344, 3294), player.tile) + } + + @Test + fun `Can't climb elven overpass without agility level`() { + val player = createPlayer(Tile(2346, 3300)) + player.levels.set(Skill.Agility, 58) + val rocks = GameObjects.find(Tile(2346, 3299), "elven_overpass_rocks_up") + + player.objectOption(rocks, "Climb") + tick(4) + + assertTrue(player.containsMessage("You need an Agility level of 59")) + } + + @Test + fun `Pass through the lletya tree entrance after roving elves`() { + val player = createPlayer(Tile(2304, 3191)) + player["roving_elves"] = "completed" + val tree = GameObjects.find(Tile(2305, 3191), "lletya_tree_entrance") + + player.objectOption(tree, "Pass") + tick(8) + + assertEquals(Tile(2307, 3191), player.tile) + } +} diff --git a/game/src/test/kotlin/content/area/tirannwn/LletyaTest.kt b/game/src/test/kotlin/content/area/tirannwn/LletyaTest.kt new file mode 100644 index 0000000000..99a6d0cab3 --- /dev/null +++ b/game/src/test/kotlin/content/area/tirannwn/LletyaTest.kt @@ -0,0 +1,130 @@ +package content.area.tirannwn + +import WorldTest +import containsMessage +import dialogueContinue +import dialogueOption +import itemOption +import npcOption +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.client.ui.hasOpen +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory + +class LletyaTest : WorldTest() { + + @Test + fun `Activate teleport crystal to teleport to lletya`() { + val player = createPlayer(emptyTile) + player.inventory.add("crystal_teleport_seed_4") + + player.itemOption("Activate", "crystal_teleport_seed_4") + tick() + player.dialogueOption(1) // "Teleport to Lletya." + tickIf { player.tile.x !in 2328..2330 } + tick(2) + + assertTrue(player.tile.x in 2328..2330 && player.tile.y in 3171..3173) + assertEquals(1, player.inventory.count("crystal_teleport_seed_3")) + assertTrue(player.containsMessage("Your teleportation crystal has degraded from use.")) + } + + @Test + fun `Eluned recharges uncharged teleport crystals with a sliding price`() { + val player = createPlayer(emptyTile) + val eluned = createNPC("eluned", emptyTile.addX(1)) + player.inventory.add("crystal_teleport_seed_uncharged") + player.inventory.add("coins", 750) + + player.npcOption(eluned, "Talk-to") + tick(2) + player.dialogueContinue(3) // greeting, request, price quote + player.dialogueOption(1) // "Recharge a crystal." + player.dialogueContinue() // repeated player line + tick(2) + + assertEquals(1, player.inventory.count("crystal_teleport_seed_4")) + assertEquals(0, player.inventory.count("coins")) + assertEquals(1, player["teleport_crystal_recharges", 0]) + } + + @Test + fun `Eluned recharge is cheaper after the first`() { + val player = createPlayer(emptyTile) + val eluned = createNPC("eluned", emptyTile.addX(1)) + player["teleport_crystal_recharges"] = 1 + player.inventory.add("crystal_teleport_seed_uncharged") + player.inventory.add("coins", 600) + + player.npcOption(eluned, "Talk-to") + tick(2) + player.dialogueContinue(3) + player.dialogueOption(1) + player.dialogueContinue() + tick(2) + + assertEquals(1, player.inventory.count("crystal_teleport_seed_4")) + assertEquals(0, player.inventory.count("coins")) + assertEquals(2, player["teleport_crystal_recharges", 0]) + } + + @Test + fun `Islwyn attunes a crystal seed into a bow`() { + val player = createPlayer(emptyTile) + player["roving_elves"] = "completed" + val islwyn = createNPC("islwyn", emptyTile.addX(1)) + player.inventory.add("crystal_seed") + player.inventory.add("coins", 900_000) + + player.npcOption(islwyn, "Talk-to") + tick(2) + player.dialogueContinue() // greeting + player.dialogueOption(2) // "I need to recharge my seeds into equipment." + player.dialogueContinue(2) // repeated player line, price quote + player.dialogueOption(1) // "Recharge my seed into a bow, please." + player.dialogueContinue() // repeated player line + tick(2) + + assertEquals(1, player.inventory.count("crystal_bow_full")) + assertEquals(0, player.inventory.count("crystal_seed")) + assertEquals(0, player.inventory.count("coins")) + assertEquals(1, player["crystal_seed_attunements", 0]) + } + + @Test + fun `Islwyn won't attune a seed without enough coins`() { + val player = createPlayer(emptyTile) + player["roving_elves"] = "completed" + val islwyn = createNPC("islwyn", emptyTile.addX(1)) + player.inventory.add("crystal_seed") + + player.npcOption(islwyn, "Talk-to") + tick(2) + player.dialogueContinue() + player.dialogueOption(2) + player.dialogueContinue(2) + player.dialogueOption(1) + player.dialogueContinue() + tick(2) + + assertEquals(1, player.inventory.count("crystal_seed")) + assertEquals(0, player.inventory.count("crystal_bow_full")) + } + + @Test + fun `Lletya shopkeeper dialogue opens the shop`() { + val player = createPlayer(emptyTile) + val eudav = createNPC("eudav", emptyTile.addX(1)) + + player.npcOption(eudav, "Talk-to") + tick(2) + player.dialogueContinue() // "Can I help you at all?" + player.dialogueOption(1) // "Yes please. What are you selling?" + player.dialogueContinue() // repeated player line + tick(2) + + assertTrue(player.hasOpen("shop")) + } +} diff --git a/game/src/test/kotlin/content/skill/ranged/ammo/CrystalBowTest.kt b/game/src/test/kotlin/content/skill/ranged/ammo/CrystalBowTest.kt new file mode 100644 index 0000000000..ee4de0a862 --- /dev/null +++ b/game/src/test/kotlin/content/skill/ranged/ammo/CrystalBowTest.kt @@ -0,0 +1,30 @@ +package content.skill.ranged.ammo + +import WorldTest +import npcOption +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.inv.equipment +import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot + +class CrystalBowTest : WorldTest() { + + @Test + fun `Crystal bow doesn't drop itself as ammo`() { + val player = createPlayer(emptyTile) + val npc = createNPC("giant_rat", emptyTile.addY(4)) + npc.huntMode = "" + player.levels.set(Skill.Ranged, 70) + player.equipment.set(EquipSlot.Weapon.index, "new_crystal_bow") + + player.npcOption(npc, "Attack") + tick(10) + + // The new bow degrades into crystal_bow_full on its first shot but stays equipped + assertEquals("crystal_bow_full", player.equipment[EquipSlot.Weapon.index].id) + assertNull(FloorItems.firstOrNull(npc.tile, "new_crystal_bow")) + } +} From bf7c86f39cc274c056857fdc82689783d223b213 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Fri, 4 Sep 2026 21:23:14 -0700 Subject: [PATCH 02/15] Fix Isafdar trap animations and movement to match original behaviour - Dense forest: correct animations per passage type (839 climb-over, 749 double squeeze, 1237 side squeeze) with sounds, 94cs move with 30cs start delay to stop the end-of-move slide, and a collision-aware line-up walk so entering from the side can no longer clip through - Tripwire: step-over is now a lined-up single-tile exact move over the wire; failing fires the flanking arrow launcher animations and projectiles - Sticks trap: crossing walks along the trap at walk speed; failing springs the trap and flings the player off in the direction fixed by the trap's rotation, and walking onto the trap without interacting now sets it off - Leaf traps: trigger areas cover the full 3x3 leaf patches so walking (or cutting diagonally) over any leaf tile drops the player into the pit with the stumble and getup animations; jumps line up with the trap first and use the proper timings; centre pit tile corrected - Log balance: no longer toggles the run setting while crossing (run orb stays lit) and plays the balance sound each step --- data/area/tirannwn/isafdar/isafdar.anims.toml | 19 ++- data/area/tirannwn/isafdar/isafdar.areas.toml | 28 ++-- data/area/tirannwn/isafdar/isafdar.objs.toml | 3 + .../area/tirannwn/isafdar/isafdar.sounds.toml | 17 +++ .../area/tirannwn/isafdar/DenseForest.kt | 47 ++++-- .../area/tirannwn/isafdar/IsafdarTraps.kt | 134 +++++++++++++++--- .../area/tirannwn/isafdar/LeafTraps.kt | 56 ++++++-- .../skill/agility/shortcut/LogBalance.kt | 11 +- .../content/area/tirannwn/IsafdarTest.kt | 35 ++++- 9 files changed, 281 insertions(+), 69 deletions(-) create mode 100644 data/area/tirannwn/isafdar/isafdar.sounds.toml diff --git a/data/area/tirannwn/isafdar/isafdar.anims.toml b/data/area/tirannwn/isafdar/isafdar.anims.toml index e014eeef81..2e11845b7e 100644 --- a/data/area/tirannwn/isafdar/isafdar.anims.toml +++ b/data/area/tirannwn/isafdar/isafdar.anims.toml @@ -1,20 +1,35 @@ [tripwire_step_over] id = 1236 -[sticks_trap_pass] -id = 819 +[trap_stumble_back] +id = 846 + +[leaf_trap_stumble] +id = 764 [leaf_trap_jump] id = 1115 +[pit_getup] +id = 534 + [dense_forest_climb] id = 839 [dense_forest_squeeze] id = 1237 +[dense_forest_double_squeeze] +id = 749 + [elven_overpass_climb_up] id = 2050 [elven_overpass_climb_down] id = 2049 + +[woodspring] +id = 1219 + +[arrowtrip] +id = 1213 diff --git a/data/area/tirannwn/isafdar/isafdar.areas.toml b/data/area/tirannwn/isafdar/isafdar.areas.toml index 9f12b1bb45..42abd4f995 100644 --- a/data/area/tirannwn/isafdar/isafdar.areas.toml +++ b/data/area/tirannwn/isafdar/isafdar.areas.toml @@ -11,17 +11,29 @@ x = [2264, 2274] y = [3248, 3258] [isafdar_leaf_trap_1] -x = [2209] -y = [3203] +x = [2208, 2210] +y = [3202, 3204] [isafdar_leaf_trap_2] -x = [2267] -y = [3203] +x = [2266, 2268] +y = [3202, 3204] [isafdar_leaf_trap_3] -x = [2274] -y = [3174] +x = [2273, 2275] +y = [3173, 3175] [isafdar_leaf_trap_4] -x = [2277] -y = [3262] +x = [2276, 2278] +y = [3261, 3263] + +[isafdar_sticks_trap_1] +x = [2200, 2202] +y = [3169] + +[isafdar_sticks_trap_2] +x = [2235, 2237] +y = [3181] + +[isafdar_sticks_trap_3] +x = [2275, 2277] +y = [3163] diff --git a/data/area/tirannwn/isafdar/isafdar.objs.toml b/data/area/tirannwn/isafdar/isafdar.objs.toml index f4d3284eb0..21a47942db 100644 --- a/data/area/tirannwn/isafdar/isafdar.objs.toml +++ b/data/area/tirannwn/isafdar/isafdar.objs.toml @@ -67,3 +67,6 @@ examine = "A rocky cliff face." [isafdar_cave_entrance] id = 4006 examine = "A dark cave entrance." + +[isafdar_arrow_trap] +id = 3920 diff --git a/data/area/tirannwn/isafdar/isafdar.sounds.toml b/data/area/tirannwn/isafdar/isafdar.sounds.toml new file mode 100644 index 0000000000..f53f7a34d5 --- /dev/null +++ b/data/area/tirannwn/isafdar/isafdar.sounds.toml @@ -0,0 +1,17 @@ +[forest_doublesqueeze] +id = 1348 + +[forest_lowwall] +id = 1358 + +[tripwire_with_arrows] +id = 1360 + +[forest_sidesqueeze] +id = 1361 + +[springtrap] +id = 1368 + +[log_balance] +id = 2470 diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt index e9865fd4ab..4c5ef900a8 100644 --- a/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt @@ -4,6 +4,7 @@ import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.entity.character.player.chat.obstacle 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.type.Direction import world.gregs.voidps.type.Tile @@ -23,24 +24,42 @@ class DenseForest : Script { } else { if (tile.y < target.tile.y) Direction.NORTH else Direction.SOUTH } - if (target.id == "dense_forest" || target.id == "dense_forest_2") { - // Line up on the tile just before the passage's footprint before squeezing through - val start = if (sideways) { - val x = if (direction == Direction.EAST) target.tile.x - 1 else target.tile.x + target.width - val centre = tile.y.coerceIn(target.tile.y + (target.height - 1) / 2, target.tile.y + target.height / 2) - Tile(x, centre, tile.level) - } else { - val y = if (direction == Direction.NORTH) target.tile.y - 1 else target.tile.y + target.height - val centre = tile.x.coerceIn(target.tile.x + (target.width - 1) / 2, target.tile.x + target.width / 2) - Tile(centre, y, tile.level) + // Line up on the centre lane just outside the passage before squeezing through + val start = if (sideways) { + val x = if (direction == Direction.EAST) target.tile.x - 1 else target.tile.x + target.width + val centre = tile.y.coerceIn(target.tile.y + (target.height - 1) / 2, target.tile.y + target.height / 2) + Tile(x, centre, tile.level) + } else { + val y = if (direction == Direction.NORTH) target.tile.y - 1 else target.tile.y + target.height + val centre = tile.x.coerceIn(target.tile.x + (target.width - 1) / 2, target.tile.x + target.width / 2) + Tile(centre, y, tile.level) + } + if (tile != start) { + walkToDelay(start, forceWalk = true) + if (tile != start) { + return@objectOperate } - walkOverDelay(start) } val dest = tile.add(direction.delta.x * 3, direction.delta.y * 3) face(direction) - delay() - anim(if (target.id == "dense_forest") "dense_forest_climb" else "dense_forest_squeeze") - exactMoveDelay(dest, delay = 90, direction = direction) + when (target.id) { + "dense_forest", "dense_forest_hard_2" -> { + anim("dense_forest_climb", delay = 30) + sound("forest_lowwall", delay = 30) + exactMoveDelay(dest, delay = 94, direction = direction, startDelay = 30) + } + "dense_forest_2", "dense_forest_hard" -> { + anim("dense_forest_double_squeeze", delay = 30) + sound("forest_doublesqueeze", delay = 30) + exactMoveDelay(dest, delay = 94, direction = direction, startDelay = 30) + clearAnim() + } + else -> { + anim("dense_forest_squeeze", delay = 30) + sound("forest_sidesqueeze", delay = 40) + exactMoveDelay(dest, delay = 94, direction = direction, startDelay = 30) + } + } } } } diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt index 9be92d28c2..24d19fd1cc 100644 --- a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt @@ -1,13 +1,17 @@ package content.area.tirannwn.isafdar import content.entity.combat.hit.damage +import content.entity.proj.shoot import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.definition.AreaDefinition 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 +import world.gregs.voidps.engine.entity.character.sound import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.queue.strongQueue import world.gregs.voidps.type.Direction import world.gregs.voidps.type.Tile import world.gregs.voidps.type.random @@ -16,35 +20,121 @@ class IsafdarTraps : Script { init { objectOperate("Step-over", "tripwire") { (target) -> - val northSouth = target.rotation == 0 || target.rotation == 2 - crossTrap(target, northSouth, "tripwire_step_over", "You successfully step over the tripwire.", "You snag the trip wire as you step over it.") + val (entry, exit, out, direction) = crossing(target) + this["crossing_trap"] = true + walkOverDelay(entry) + anim("tripwire_step_over") + exactMoveDelay(exit, delay = 30, direction = direction) + if (Level.success(levels.get(Skill.Agility), 128..250)) { // TODO unknown rate + message("You successfully step over the tripwire.") + } else { + message("You snag the trip wire as you step over it.") + arrowVolley(target) + damage(random.nextInt(20, 41)) // TODO unknown damage + } + walkOverDelay(out) + clear("crossing_trap") } objectOperate("Pass", "isafdar_sticks_trap") { (target) -> - val northSouth = target.rotation == 1 || target.rotation == 3 - crossTrap(target, northSouth, "sticks_trap_pass", "You manage to skillfully pass the trap.", "You set off the trap as you pass.") + val (entry, exit, _, direction) = crossing(target) + this["crossing_trap"] = true + walkToDelay(entry.add(direction.inverse()), forceWalk = true) + walkOverDelay(entry) + if (Level.success(levels.get(Skill.Agility), 128..250)) { // TODO unknown rate + walkOverDelay(exit) + message("You manage to skillfully pass the trap.") + } else { + springTrap(target) + } + clear("crossing_trap") + } + + entered("isafdar_sticks_trap_1", ::sticks) + entered("isafdar_sticks_trap_2", ::sticks) + entered("isafdar_sticks_trap_3", ::sticks) + } + + fun sticks(player: Player, definition: AreaDefinition) { + if (player.contains("crossing_trap")) { + return + } + val trap = findTrap(definition, "isafdar_sticks_trap") ?: return + player.strongQueue("sticks_trap") { + steps.clear() + springTrap(trap) } } - suspend fun Player.crossTrap(target: GameObject, northSouth: Boolean, anim: String, success: String, failure: String) { - // Rotation determines the trap's axis; cross to the far side relative to the player - val direction: Direction - val dest = if (northSouth) { - direction = if (tile.y > target.tile.y) Direction.SOUTH else Direction.NORTH - Tile(target.tile.x, if (tile.y > target.tile.y) target.tile.y - 1 else target.tile.y + 2, tile.level) + /** + * The spring flings the player off the trap in a fixed direction + * determined by the trap's rotation. + */ + suspend fun Player.springTrap(trap: GameObject) { + val axisX = trap.width > trap.height + val direction = if (axisX) { + if (trap.rotation == 0) Direction.EAST else Direction.WEST } else { - direction = if (tile.x > target.tile.x) Direction.WEST else Direction.EAST - Tile(if (tile.x > target.tile.x) target.tile.x - 1 else target.tile.x + 2, target.tile.y, tile.level) - } - face(direction) - delay() - anim(anim) - exactMoveDelay(dest, delay = 60, direction = direction) - if (Level.success(levels.get(Skill.Agility), 128..250)) { // TODO unknown rate - message(success, ChatType.Filter) + if (trap.rotation == 1) Direction.NORTH else Direction.SOUTH + } + val out = if (axisX) { + Tile(if (direction == Direction.EAST) trap.tile.x + trap.width else trap.tile.x - 1, tile.y, tile.level) } else { - message(failure) - damage(random.nextInt(20, 41)) // TODO unknown damage + Tile(tile.x, if (direction == Direction.NORTH) trap.tile.y + trap.height else trap.tile.y - 1, tile.level) + } + message("You set off the trap as you pass.") + trap.anim("woodspring") + sound("springtrap") + anim("trap_stumble_back", delay = 10) + exactMoveDelay(out, delay = 56, direction = direction, startDelay = 10) + damage(random.nextInt(20, 41)) // TODO unknown damage + } + + fun Player.arrowVolley(wire: GameObject) { + val axisX = wire.width > wire.height + for (i in 0 until maxOf(wire.width, wire.height)) { + val trapTile = if (axisX) wire.tile.addX(i) else wire.tile.addY(i) + for (side in intArrayOf(-1, 1)) { + val tile = if (axisX) trapTile.addY(side) else trapTile.addX(side) + val launcher = GameObjects.findOrNull(tile, "isafdar_arrow_trap") ?: continue + launcher.anim("arrowtrip") + launcher.tile.shoot("iron_arrow", this, flightTime = 40, height = 16, endHeight = 140, curve = 45) + } + } + sound("tripwire_with_arrows", delay = 20) + } + + /** + * The near trap tile, far trap tile, tile beyond the far side and crossing + * direction for stepping over [target] from the player's side of it. + */ + fun Player.crossing(target: GameObject): Crossing { + val axisX = target.width > target.height + return if (axisX) { + val lane = tile.y.coerceIn(target.tile.y, target.tile.y + target.height - 1) + if (tile.x <= target.tile.x) { + Crossing(Tile(target.tile.x, lane, tile.level), Tile(target.tile.x + target.width - 1, lane, tile.level), Tile(target.tile.x + target.width, lane, tile.level), Direction.EAST) + } else { + Crossing(Tile(target.tile.x + target.width - 1, lane, tile.level), Tile(target.tile.x, lane, tile.level), Tile(target.tile.x - 1, lane, tile.level), Direction.WEST) + } + } else { + val lane = tile.x.coerceIn(target.tile.x, target.tile.x + target.width - 1) + if (tile.y <= target.tile.y) { + Crossing(Tile(lane, target.tile.y, tile.level), Tile(lane, target.tile.y + target.height - 1, tile.level), Tile(lane, target.tile.y + target.height, tile.level), Direction.NORTH) + } else { + Crossing(Tile(lane, target.tile.y + target.height - 1, tile.level), Tile(lane, target.tile.y, tile.level), Tile(lane, target.tile.y - 1, tile.level), Direction.SOUTH) + } + } + } + + data class Crossing(val entry: Tile, val exit: Tile, val out: Tile, val direction: Direction) + + companion object { + private fun findTrap(definition: AreaDefinition, id: String): GameObject? { + for (tile in definition.area) { + return GameObjects.findOrNull(tile, id) ?: continue + } + return null } } } diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/LeafTraps.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/LeafTraps.kt index 7e2b971dc1..b0f25e8165 100644 --- a/game/src/main/kotlin/content/area/tirannwn/isafdar/LeafTraps.kt +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/LeafTraps.kt @@ -1,15 +1,15 @@ package content.area.tirannwn.isafdar -import content.entity.combat.hit.directHit +import content.entity.combat.hit.damage import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.data.definition.AreaDefinition import world.gregs.voidps.engine.entity.character.move.tele 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.chat.noInterest import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.entity.character.sound import world.gregs.voidps.engine.queue.strongQueue import world.gregs.voidps.type.Direction import world.gregs.voidps.type.Tile @@ -22,28 +22,43 @@ class LeafTraps : Script { val site = sites.firstOrNull { target.tile.within(it.centre, 3) } ?: return@objectOperate noInterest() // West, centre and south pits are jumped north-south; the north pit east-west val direction: Direction - val dest = if (site.centre.y == 3262) { + val start: Tile + val dest: Tile + if (site.centre.y == 3262) { direction = if (tile.x > site.centre.x) Direction.WEST else Direction.EAST - Tile(if (tile.x > site.centre.x) site.centre.x - 2 else site.centre.x + 2, site.centre.y) + start = Tile(site.centre.x - direction.delta.x * 2, site.centre.y, tile.level) + dest = Tile(site.centre.x + direction.delta.x * 2, site.centre.y, tile.level) } else { direction = if (tile.y > site.centre.y) Direction.SOUTH else Direction.NORTH - Tile(site.centre.x, if (tile.y > site.centre.y) site.centre.y - 2 else site.centre.y + 2) + start = Tile(site.centre.x, site.centre.y - direction.delta.y * 2, tile.level) + dest = Tile(site.centre.x, site.centre.y + direction.delta.y * 2, tile.level) + } + this["crossing_trap"] = true + if (tile != start) { + // Line up with the trap before jumping + walkToDelay(start, forceWalk = true) + if (tile != start) { + clear("crossing_trap") + return@objectOperate + } } - face(direction) - delay() if (Level.success(levels.get(Skill.Agility), 128..250)) { // TODO unknown rate - anim("leaf_trap_jump") - exactMoveDelay(dest, delay = 60, direction = direction) - message("You safely jump across.", ChatType.Filter) + anim("leaf_trap_jump", delay = 20) + sound("jump", delay = 20) + exactMoveDelay(dest, delay = 79, direction = direction, startDelay = 30) + message("You safely jump across.") } else { fall(this, site) } + clear("crossing_trap") } objectOperate("Climb", "isafdar_protruding_rocks") { (target) -> val site = sites.firstOrNull { target.tile.within(it.pit, 2) } ?: return@objectOperate noInterest() + anim("pit_getup") + delay() + message("You climb out of the pit.") tele(site.exit) - message("You climb out of the pit.", ChatType.Filter) } entered("isafdar_leaf_trap_1", ::stumble) @@ -53,8 +68,16 @@ class LeafTraps : Script { } fun stumble(player: Player, definition: AreaDefinition) { + if (player.contains("crossing_trap")) { + return + } val site = sites.firstOrNull { player.tile.within(it.centre, 3) } ?: return - player.strongQueue("leaf_trap_fall", 1) { + player.strongQueue("leaf_trap_fall") { + player.steps.clear() + player.message("It's a trap!") + player.anim("leaf_trap_stumble") + player.sound("stumble_loop", repeat = 3) + player.delay(1) fall(player, site) } } @@ -62,10 +85,10 @@ class LeafTraps : Script { private data class Site(val centre: Tile, val pit: Tile, val exit: Tile) companion object { - // TODO which pit belongs to the west, centre and north traps is unverified + // TODO which pit belongs to the west and north traps is unverified private val sites = listOf( Site(centre = Tile(2209, 3203), pit = Tile(2313, 9656), exit = Tile(2209, 3201)), - Site(centre = Tile(2267, 3203), pit = Tile(2354, 9656), exit = Tile(2267, 3201)), + Site(centre = Tile(2267, 3203), pit = Tile(2355, 9656), exit = Tile(2267, 3201)), Site(centre = Tile(2274, 3174), pit = Tile(2336, 9656), exit = Tile(2274, 3172)), Site(centre = Tile(2277, 3262), pit = Tile(2354, 9643), exit = Tile(2275, 3262)), ) @@ -73,7 +96,10 @@ class LeafTraps : Script { private fun fall(player: Player, site: Site) { player.message("You fall through and onto some spikes.") player.tele(site.pit) - player.directHit(random.nextInt(20, 41)) // TODO unknown damage + player.anim("pit_getup") + player.say("Ouch!") + player.sound("fall_land") + player.damage(random.nextInt(20, 41)) // TODO unknown damage } } } diff --git a/game/src/main/kotlin/content/skill/agility/shortcut/LogBalance.kt b/game/src/main/kotlin/content/skill/agility/shortcut/LogBalance.kt index e2ef3d0f7e..60d08261c4 100644 --- a/game/src/main/kotlin/content/skill/agility/shortcut/LogBalance.kt +++ b/game/src/main/kotlin/content/skill/agility/shortcut/LogBalance.kt @@ -4,7 +4,6 @@ import content.entity.combat.hit.damage import content.entity.gfx.areaGfx import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message -import world.gregs.voidps.engine.entity.character.move.running 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.clearRenderEmote @@ -112,14 +111,16 @@ class LogBalance : Script { if (tile != from) { return } - val wasRunning = running - running = false message("You walk carefully across the slippery log...", ChatType.Filter) delay() renderEmote("rope_balance") - walkOverDelay(to) + walkTo(to, noCollision = true, forceWalk = true) + var steps = 0 + while (tile != to && steps++ < 10) { + sound("log_balance") + delay() + } clearRenderEmote() - running = wasRunning exp(Skill.Agility, 7.5) message("... and make it safely to the other side.", ChatType.Filter) } diff --git a/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt b/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt index dbc96fc5db..38e5dd4fbd 100644 --- a/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt +++ b/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt @@ -166,20 +166,49 @@ class IsafdarTest : WorldTest() { @Test fun `Pass sticks trap`() { - val player = createPlayer(Tile(2236, 3180)) + val player = createPlayer(Tile(2234, 3181)) val sticks = GameObjects.find(Tile(2235, 3181), "isafdar_sticks_trap") player.objectOption(sticks, "Pass") - tick(8) + tick(10) + assertEquals(Tile(2237, 3181), player.tile) assertTrue(player.containsMessage("You manage to skillfully pass the trap.")) } + @Test + fun `Failing the sticks trap springs the player off it`() { + val player = createPlayer(Tile(2234, 3181)) + val sticks = GameObjects.find(Tile(2235, 3181), "isafdar_sticks_trap") + setRandom(object : FakeRandom() { + override fun nextBits(bitCount: Int): Int = Int.MAX_VALUE + }) + + player.objectOption(sticks, "Pass") + tick(10) + + assertEquals(Tile(2238, 3181), player.tile) + assertTrue(player.containsMessage("You set off the trap as you pass.")) + assertTrue(player.levels.get(Skill.Constitution) < player.levels.getMax(Skill.Constitution)) + } + + @Test + fun `Walking over the sticks trap sets it off`() { + val player = createPlayer(Tile(2234, 3181)) + + player.walk(Tile(2238, 3181)) + tick(10) + + assertEquals(Tile(2238, 3181), player.tile) + assertTrue(player.containsMessage("You set off the trap as you pass.")) + assertTrue(player.levels.get(Skill.Constitution) < player.levels.getMax(Skill.Constitution)) + } + @Test fun `Jump over leaf trap`() { val player = createPlayer(Tile(2274, 3172)) player.levels.set(Skill.Agility, 99) - val leaves = GameObjects.find(Tile(2274, 3174), "isafdar_leaves_2") + val leaves = GameObjects.find(Tile(2274, 3173), "isafdar_leaves_3") player.objectOption(leaves, "Jump") tick(8) From e4b63956fb01d3e5a494ef755b03d1c705f20e19 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 02:45:48 -0700 Subject: [PATCH 03/15] Make walking at the tripwire cross it like a border guard gate Tag the tripwire tiles as border areas so a walk click at the wire force-walks the player through the wall-flagged midline at walk speed, with the arrow launcher volley, damage and poison firing on the way through. Interacted step-overs suppress the volley and roll as before. --- data/area/tirannwn/isafdar/isafdar.areas.toml | 15 +++++++++++ .../area/tirannwn/isafdar/IsafdarTraps.kt | 26 +++++++++++++++++++ .../content/area/tirannwn/IsafdarTest.kt | 12 +++++++++ 3 files changed, 53 insertions(+) diff --git a/data/area/tirannwn/isafdar/isafdar.areas.toml b/data/area/tirannwn/isafdar/isafdar.areas.toml index 42abd4f995..3448d7c697 100644 --- a/data/area/tirannwn/isafdar/isafdar.areas.toml +++ b/data/area/tirannwn/isafdar/isafdar.areas.toml @@ -26,6 +26,21 @@ y = [3173, 3175] x = [2276, 2278] y = [3261, 3263] +[isafdar_tripwire_1] +x = [2215] +y = [3154, 3155] +tags = ["border"] + +[isafdar_tripwire_2] +x = [2220] +y = [3153, 3154] +tags = ["border"] + +[isafdar_tripwire_3] +x = [2285, 2286] +y = [3188] +tags = ["border"] + [isafdar_sticks_trap_1] x = [2200, 2202] y = [3169] diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt index 24d19fd1cc..cb420aee8f 100644 --- a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt @@ -1,6 +1,7 @@ package content.area.tirannwn.isafdar import content.entity.combat.hit.damage +import content.entity.effect.toxin.poison import content.entity.proj.shoot import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message @@ -50,11 +51,36 @@ class IsafdarTraps : Script { clear("crossing_trap") } + entered("isafdar_tripwire_1", ::tripwire) + entered("isafdar_tripwire_2", ::tripwire) + entered("isafdar_tripwire_3", ::tripwire) + + exited("isafdar_tripwire_1", ::exitTripwire) + exited("isafdar_tripwire_2", ::exitTripwire) + exited("isafdar_tripwire_3", ::exitTripwire) + entered("isafdar_sticks_trap_1", ::sticks) entered("isafdar_sticks_trap_2", ::sticks) entered("isafdar_sticks_trap_3", ::sticks) } + // Tagged "border" so walking at the wire crosses it like a border guard gate + fun tripwire(player: Player, definition: AreaDefinition) { + player.steps.update(noCollision = true, noRun = true) + if (player.contains("crossing_trap")) { + return + } + player["delay"] = 2 + val wire = findTrap(definition, "tripwire") ?: return + player.arrowVolley(wire) + player.damage(random.nextInt(20, 41)) // TODO unknown damage + player.poison(player, 10) + } + + fun exitTripwire(player: Player, definition: AreaDefinition) { + player.steps.update(noCollision = false, noRun = false) + } + fun sticks(player: Player, definition: AreaDefinition) { if (player.contains("crossing_trap")) { return diff --git a/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt b/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt index 38e5dd4fbd..1a35d4992e 100644 --- a/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt +++ b/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt @@ -134,6 +134,18 @@ class IsafdarTest : WorldTest() { assertTrue(player.containsMessage("You need an Agility level of 45")) } + @Test + fun `Walking at the tripwire crosses it and fires the arrow volley`() { + val player = createPlayer(Tile(2215, 3156)) + player.running = true + + player.walk(Tile(2215, 3154)) + tick(10) + + assertEquals(Tile(2215, 3153), player.tile) + assertTrue(player.levels.get(Skill.Constitution) < player.levels.getMax(Skill.Constitution)) + } + @Test fun `Step over tripwire`() { val player = createPlayer(Tile(2215, 3153)) From 56461eb70be384b6191897bc0b1bcba61323f951 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 02:57:21 -0700 Subject: [PATCH 04/15] Tripwire arrow volley always hits two 50s and poisons starting at 20 --- .../content/area/tirannwn/isafdar/IsafdarTraps.kt | 11 +++++++---- .../test/kotlin/content/area/tirannwn/IsafdarTest.kt | 10 ++++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt index cb420aee8f..c954e6ea43 100644 --- a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt @@ -31,7 +31,9 @@ class IsafdarTraps : Script { } else { message("You snag the trip wire as you step over it.") arrowVolley(target) - damage(random.nextInt(20, 41)) // TODO unknown damage + damage(50) + damage(50) + poison(this, 20) } walkOverDelay(out) clear("crossing_trap") @@ -73,8 +75,9 @@ class IsafdarTraps : Script { player["delay"] = 2 val wire = findTrap(definition, "tripwire") ?: return player.arrowVolley(wire) - player.damage(random.nextInt(20, 41)) // TODO unknown damage - player.poison(player, 10) + player.damage(50) + player.damage(50) + player.poison(player, 20) } fun exitTripwire(player: Player, definition: AreaDefinition) { @@ -124,7 +127,7 @@ class IsafdarTraps : Script { val tile = if (axisX) trapTile.addY(side) else trapTile.addX(side) val launcher = GameObjects.findOrNull(tile, "isafdar_arrow_trap") ?: continue launcher.anim("arrowtrip") - launcher.tile.shoot("iron_arrow", this, flightTime = 40, height = 16, endHeight = 140, curve = 45) + launcher.tile.shoot("iron_arrow", this, flightTime = 20, height = 16, endHeight = 45, curve = 45) } } sound("tripwire_with_arrows", delay = 20) diff --git a/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt b/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt index 1a35d4992e..80b05f8f81 100644 --- a/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt +++ b/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt @@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test import walk import world.gregs.voidps.engine.entity.character.move.running import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.type.Tile import world.gregs.voidps.type.setRandom @@ -138,12 +139,15 @@ class IsafdarTest : WorldTest() { fun `Walking at the tripwire crosses it and fires the arrow volley`() { val player = createPlayer(Tile(2215, 3156)) player.running = true + player.experience.set(Skill.Constitution, Level.experience(Skill.Constitution, 99)) + player.levels.set(Skill.Constitution, 990) player.walk(Tile(2215, 3154)) tick(10) assertEquals(Tile(2215, 3153), player.tile) - assertTrue(player.levels.get(Skill.Constitution) < player.levels.getMax(Skill.Constitution)) + assertEquals(890, player.levels.get(Skill.Constitution)) + assertEquals(20, player["poison", 0]) } @Test @@ -163,6 +167,8 @@ class IsafdarTest : WorldTest() { fun `Failing to step over tripwire deals damage`() { val player = createPlayer(Tile(2215, 3153)) player.levels.set(Skill.Agility, 1) + player.experience.set(Skill.Constitution, Level.experience(Skill.Constitution, 99)) + player.levels.set(Skill.Constitution, 990) val wire = GameObjects.find(Tile(2215, 3154), "tripwire") setRandom(object : FakeRandom() { override fun nextBits(bitCount: Int): Int = Int.MAX_VALUE @@ -173,7 +179,7 @@ class IsafdarTest : WorldTest() { assertEquals(Tile(2215, 3156), player.tile) assertTrue(player.containsMessage("You snag the trip wire as you step over it.")) - assertTrue(player.levels.get(Skill.Constitution) < player.levels.getMax(Skill.Constitution)) + assertEquals(890, player.levels.get(Skill.Constitution)) } @Test From caf9cb7524010be65ba1f0ed5b80d7822a2ed548 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 03:12:26 -0700 Subject: [PATCH 05/15] Keep facing the direction of travel after squeezing through dense forest Interacting turns the player to face the clicked object, so once the exact move carried them past it they turned back towards it. Re-face the crossing direction when the move completes (dense forest and leaf trap jumps). --- .../content/area/tirannwn/isafdar/DenseForest.kt | 10 ++++++---- .../kotlin/content/area/tirannwn/isafdar/LeafTraps.kt | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt index 4c5ef900a8..1c4d6fd5a0 100644 --- a/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt @@ -46,20 +46,22 @@ class DenseForest : Script { "dense_forest", "dense_forest_hard_2" -> { anim("dense_forest_climb", delay = 30) sound("forest_lowwall", delay = 30) - exactMoveDelay(dest, delay = 94, direction = direction, startDelay = 30) } "dense_forest_2", "dense_forest_hard" -> { anim("dense_forest_double_squeeze", delay = 30) sound("forest_doublesqueeze", delay = 30) - exactMoveDelay(dest, delay = 94, direction = direction, startDelay = 30) - clearAnim() } else -> { anim("dense_forest_squeeze", delay = 30) sound("forest_sidesqueeze", delay = 40) - exactMoveDelay(dest, delay = 94, direction = direction, startDelay = 30) } } + exactMoveDelay(dest, delay = 94, direction = direction, startDelay = 30) + if (target.id == "dense_forest_2" || target.id == "dense_forest_hard") { + clearAnim() + } + // Interacting faced the object, which is now behind; keep facing the way travelled + face(direction) } } } diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/LeafTraps.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/LeafTraps.kt index b0f25e8165..08ca90105b 100644 --- a/game/src/main/kotlin/content/area/tirannwn/isafdar/LeafTraps.kt +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/LeafTraps.kt @@ -45,11 +45,12 @@ class LeafTraps : Script { if (Level.success(levels.get(Skill.Agility), 128..250)) { // TODO unknown rate anim("leaf_trap_jump", delay = 20) sound("jump", delay = 20) - exactMoveDelay(dest, delay = 79, direction = direction, startDelay = 30) + exactMoveDelay(dest, delay = 60, direction = direction, startDelay = 30) message("You safely jump across.") } else { fall(this, site) } + delay() clear("crossing_trap") } From 99eaa8516bfae35880ce1fd91cf5f9fa51935ec7 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 03:18:59 -0700 Subject: [PATCH 06/15] Use the climb-over animation for dense forest 3938 --- .../kotlin/content/area/tirannwn/isafdar/DenseForest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt index 1c4d6fd5a0..58845d3f89 100644 --- a/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt @@ -43,11 +43,11 @@ class DenseForest : Script { val dest = tile.add(direction.delta.x * 3, direction.delta.y * 3) face(direction) when (target.id) { - "dense_forest", "dense_forest_hard_2" -> { + "dense_forest", "dense_forest_2", "dense_forest_hard_2" -> { anim("dense_forest_climb", delay = 30) sound("forest_lowwall", delay = 30) } - "dense_forest_2", "dense_forest_hard" -> { + "dense_forest_hard" -> { anim("dense_forest_double_squeeze", delay = 30) sound("forest_doublesqueeze", delay = 30) } @@ -57,7 +57,7 @@ class DenseForest : Script { } } exactMoveDelay(dest, delay = 94, direction = direction, startDelay = 30) - if (target.id == "dense_forest_2" || target.id == "dense_forest_hard") { + if (target.id == "dense_forest_hard") { clearAnim() } // Interacting faced the object, which is now behind; keep facing the way travelled From 8f4a47771f17e85da54125f60a685700cb3a6d48 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 03:32:39 -0700 Subject: [PATCH 07/15] Correct dense forest double squeeze animation id to 3303 749 is not the squeeze animation in this revision; 3303 is the standard squeeze-through used for dense forest. It self-terminates so the explicit anim clear after the move is no longer needed. --- data/area/tirannwn/isafdar/isafdar.anims.toml | 2 +- .../main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/data/area/tirannwn/isafdar/isafdar.anims.toml b/data/area/tirannwn/isafdar/isafdar.anims.toml index 2e11845b7e..4aeeab567d 100644 --- a/data/area/tirannwn/isafdar/isafdar.anims.toml +++ b/data/area/tirannwn/isafdar/isafdar.anims.toml @@ -20,7 +20,7 @@ id = 839 id = 1237 [dense_forest_double_squeeze] -id = 749 +id = 3303 [elven_overpass_climb_up] id = 2050 diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt index 58845d3f89..efe0920fd9 100644 --- a/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt @@ -57,9 +57,6 @@ class DenseForest : Script { } } exactMoveDelay(dest, delay = 94, direction = direction, startDelay = 30) - if (target.id == "dense_forest_hard") { - clearAnim() - } // Interacting faced the object, which is now behind; keep facing the way travelled face(direction) } From c3f223ff38e1d8da0e4e684ba2fc4c1808dc2f36 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 03:42:31 -0700 Subject: [PATCH 08/15] Use the standard squeeze animation for dense forest 3998 Only 3937 climbs over; every other dense forest passage squeezes through with animation 1237. Remove the separate double squeeze animation entry. --- data/area/tirannwn/isafdar/isafdar.anims.toml | 3 --- .../main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/data/area/tirannwn/isafdar/isafdar.anims.toml b/data/area/tirannwn/isafdar/isafdar.anims.toml index 4aeeab567d..054508ba2e 100644 --- a/data/area/tirannwn/isafdar/isafdar.anims.toml +++ b/data/area/tirannwn/isafdar/isafdar.anims.toml @@ -19,9 +19,6 @@ id = 839 [dense_forest_squeeze] id = 1237 -[dense_forest_double_squeeze] -id = 3303 - [elven_overpass_climb_up] id = 2050 diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt index efe0920fd9..5e6cb1bce9 100644 --- a/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/DenseForest.kt @@ -48,7 +48,7 @@ class DenseForest : Script { sound("forest_lowwall", delay = 30) } "dense_forest_hard" -> { - anim("dense_forest_double_squeeze", delay = 30) + anim("dense_forest_squeeze", delay = 30) sound("forest_doublesqueeze", delay = 30) } else -> { From a3db65b7c91d5621b3c0f6f558db622373b0f898 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 03:58:15 -0700 Subject: [PATCH 09/15] Add the two missing tripwires and three missing sticks traps A full region scan found five tripwires and six sticks traps; wires at (2251,3168) and (2294,3243) and sticks at (2181,3209), (2257,3227) and (2295,3214) had no trigger areas. The last two sticks traps are the vertical variants. --- data/area/tirannwn/isafdar/isafdar.areas.toml | 22 +++++++++++++++++++ .../area/tirannwn/isafdar/IsafdarTraps.kt | 7 ++++++ 2 files changed, 29 insertions(+) diff --git a/data/area/tirannwn/isafdar/isafdar.areas.toml b/data/area/tirannwn/isafdar/isafdar.areas.toml index 3448d7c697..fd831598cc 100644 --- a/data/area/tirannwn/isafdar/isafdar.areas.toml +++ b/data/area/tirannwn/isafdar/isafdar.areas.toml @@ -41,6 +41,16 @@ x = [2285, 2286] y = [3188] tags = ["border"] +[isafdar_tripwire_4] +x = [2251, 2252] +y = [3168] +tags = ["border"] + +[isafdar_tripwire_5] +x = [2294] +y = [3243, 3244] +tags = ["border"] + [isafdar_sticks_trap_1] x = [2200, 2202] y = [3169] @@ -52,3 +62,15 @@ y = [3181] [isafdar_sticks_trap_3] x = [2275, 2277] y = [3163] + +[isafdar_sticks_trap_4] +x = [2181] +y = [3209, 3211] + +[isafdar_sticks_trap_5] +x = [2257, 2259] +y = [3227] + +[isafdar_sticks_trap_6] +x = [2295] +y = [3214, 3216] diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt index c954e6ea43..2b06f939fb 100644 --- a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt @@ -56,14 +56,21 @@ class IsafdarTraps : Script { entered("isafdar_tripwire_1", ::tripwire) entered("isafdar_tripwire_2", ::tripwire) entered("isafdar_tripwire_3", ::tripwire) + entered("isafdar_tripwire_4", ::tripwire) + entered("isafdar_tripwire_5", ::tripwire) exited("isafdar_tripwire_1", ::exitTripwire) exited("isafdar_tripwire_2", ::exitTripwire) exited("isafdar_tripwire_3", ::exitTripwire) + exited("isafdar_tripwire_4", ::exitTripwire) + exited("isafdar_tripwire_5", ::exitTripwire) entered("isafdar_sticks_trap_1", ::sticks) entered("isafdar_sticks_trap_2", ::sticks) entered("isafdar_sticks_trap_3", ::sticks) + entered("isafdar_sticks_trap_4", ::sticks) + entered("isafdar_sticks_trap_5", ::sticks) + entered("isafdar_sticks_trap_6", ::sticks) } // Tagged "border" so walking at the wire crosses it like a border guard gate From a0ce14f05bd368877775e55bb57483523d81be00 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 04:10:06 -0700 Subject: [PATCH 10/15] Cross the sticks trap with a single forced move A successful pass is one exact move from the player to two tiles past the trap anchor in the crossing direction with the walk animation, rather than stepping across tile by tile. --- .../kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt index 2b06f939fb..e277b8592c 100644 --- a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt @@ -40,12 +40,12 @@ class IsafdarTraps : Script { } objectOperate("Pass", "isafdar_sticks_trap") { (target) -> - val (entry, exit, _, direction) = crossing(target) + val (_, _, _, direction) = crossing(target) this["crossing_trap"] = true - walkToDelay(entry.add(direction.inverse()), forceWalk = true) - walkOverDelay(entry) if (Level.success(levels.get(Skill.Agility), 128..250)) { // TODO unknown rate - walkOverDelay(exit) + anim("human_walk_forward") + exactMoveDelay(target.tile.add(direction.delta.x * 2, direction.delta.y * 2), delay = 60, direction = direction) + face(direction) message("You manage to skillfully pass the trap.") } else { springTrap(target) From d8a452f090cbb747ff1d3ad9ad6937d64e61071c Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 04:13:23 -0700 Subject: [PATCH 11/15] Spring the sticks trap when walking through like a border guard gate Tag the sticks strips as border areas so a walk click at them force walks the player through at walk speed, always springing the trap on the way. Successful interacted passes now land one tile past the strip so the player never ends a crossing standing inside the border area. --- data/area/tirannwn/isafdar/isafdar.areas.toml | 6 +++++ .../area/tirannwn/isafdar/IsafdarTraps.kt | 26 +++++++++++++------ .../content/area/tirannwn/IsafdarTest.kt | 4 +-- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/data/area/tirannwn/isafdar/isafdar.areas.toml b/data/area/tirannwn/isafdar/isafdar.areas.toml index fd831598cc..53f294790f 100644 --- a/data/area/tirannwn/isafdar/isafdar.areas.toml +++ b/data/area/tirannwn/isafdar/isafdar.areas.toml @@ -54,23 +54,29 @@ tags = ["border"] [isafdar_sticks_trap_1] x = [2200, 2202] y = [3169] +tags = ["border"] [isafdar_sticks_trap_2] x = [2235, 2237] y = [3181] +tags = ["border"] [isafdar_sticks_trap_3] x = [2275, 2277] y = [3163] +tags = ["border"] [isafdar_sticks_trap_4] x = [2181] y = [3209, 3211] +tags = ["border"] [isafdar_sticks_trap_5] x = [2257, 2259] y = [3227] +tags = ["border"] [isafdar_sticks_trap_6] x = [2295] y = [3214, 3216] +tags = ["border"] diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt index e277b8592c..386858b940 100644 --- a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt @@ -40,11 +40,11 @@ class IsafdarTraps : Script { } objectOperate("Pass", "isafdar_sticks_trap") { (target) -> - val (_, _, _, direction) = crossing(target) + val (_, _, out, direction) = crossing(target) this["crossing_trap"] = true if (Level.success(levels.get(Skill.Agility), 128..250)) { // TODO unknown rate anim("human_walk_forward") - exactMoveDelay(target.tile.add(direction.delta.x * 2, direction.delta.y * 2), delay = 60, direction = direction) + exactMoveDelay(out, delay = 60, direction = direction) face(direction) message("You manage to skillfully pass the trap.") } else { @@ -59,11 +59,11 @@ class IsafdarTraps : Script { entered("isafdar_tripwire_4", ::tripwire) entered("isafdar_tripwire_5", ::tripwire) - exited("isafdar_tripwire_1", ::exitTripwire) - exited("isafdar_tripwire_2", ::exitTripwire) - exited("isafdar_tripwire_3", ::exitTripwire) - exited("isafdar_tripwire_4", ::exitTripwire) - exited("isafdar_tripwire_5", ::exitTripwire) + exited("isafdar_tripwire_1", ::exitTrap) + exited("isafdar_tripwire_2", ::exitTrap) + exited("isafdar_tripwire_3", ::exitTrap) + exited("isafdar_tripwire_4", ::exitTrap) + exited("isafdar_tripwire_5", ::exitTrap) entered("isafdar_sticks_trap_1", ::sticks) entered("isafdar_sticks_trap_2", ::sticks) @@ -71,6 +71,13 @@ class IsafdarTraps : Script { entered("isafdar_sticks_trap_4", ::sticks) entered("isafdar_sticks_trap_5", ::sticks) entered("isafdar_sticks_trap_6", ::sticks) + + exited("isafdar_sticks_trap_1", ::exitTrap) + exited("isafdar_sticks_trap_2", ::exitTrap) + exited("isafdar_sticks_trap_3", ::exitTrap) + exited("isafdar_sticks_trap_4", ::exitTrap) + exited("isafdar_sticks_trap_5", ::exitTrap) + exited("isafdar_sticks_trap_6", ::exitTrap) } // Tagged "border" so walking at the wire crosses it like a border guard gate @@ -87,11 +94,14 @@ class IsafdarTraps : Script { player.poison(player, 20) } - fun exitTripwire(player: Player, definition: AreaDefinition) { + fun exitTrap(player: Player, definition: AreaDefinition) { player.steps.update(noCollision = false, noRun = false) } + // Tagged "border" so walking at the sticks crosses them like a border guard + // gate; doing so without interacting always springs the trap fun sticks(player: Player, definition: AreaDefinition) { + player.steps.update(noCollision = true, noRun = true) if (player.contains("crossing_trap")) { return } diff --git a/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt b/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt index 80b05f8f81..0e62f88fc3 100644 --- a/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt +++ b/game/src/test/kotlin/content/area/tirannwn/IsafdarTest.kt @@ -190,7 +190,7 @@ class IsafdarTest : WorldTest() { player.objectOption(sticks, "Pass") tick(10) - assertEquals(Tile(2237, 3181), player.tile) + assertEquals(Tile(2238, 3181), player.tile) assertTrue(player.containsMessage("You manage to skillfully pass the trap.")) } @@ -214,7 +214,7 @@ class IsafdarTest : WorldTest() { fun `Walking over the sticks trap sets it off`() { val player = createPlayer(Tile(2234, 3181)) - player.walk(Tile(2238, 3181)) + player.walk(Tile(2236, 3181)) tick(10) assertEquals(Tile(2238, 3181), player.tile) From 8da7678d4ec4b6c30fb6e8d1d2076640460f08a3 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 04:20:25 -0700 Subject: [PATCH 12/15] Use the startled-back animation for springing the sticks trap 846 shows a crawl in this revision; 1441 is the era's recoil animation used for sprung traps. Also replace the placeholder fail damage and rate with the known values: 80 damage and a 31-156/256 agility roll. --- data/area/tirannwn/isafdar/isafdar.anims.toml | 2 +- .../kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/data/area/tirannwn/isafdar/isafdar.anims.toml b/data/area/tirannwn/isafdar/isafdar.anims.toml index 054508ba2e..7493f62e7a 100644 --- a/data/area/tirannwn/isafdar/isafdar.anims.toml +++ b/data/area/tirannwn/isafdar/isafdar.anims.toml @@ -2,7 +2,7 @@ id = 1236 [trap_stumble_back] -id = 846 +id = 1441 [leaf_trap_stumble] id = 764 diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt index 386858b940..1e0b9a849a 100644 --- a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt @@ -15,7 +15,6 @@ import world.gregs.voidps.engine.entity.obj.GameObjects import world.gregs.voidps.engine.queue.strongQueue import world.gregs.voidps.type.Direction import world.gregs.voidps.type.Tile -import world.gregs.voidps.type.random class IsafdarTraps : Script { @@ -42,7 +41,7 @@ class IsafdarTraps : Script { objectOperate("Pass", "isafdar_sticks_trap") { (target) -> val (_, _, out, direction) = crossing(target) this["crossing_trap"] = true - if (Level.success(levels.get(Skill.Agility), 128..250)) { // TODO unknown rate + if (Level.success(levels.get(Skill.Agility), 31..156)) { anim("human_walk_forward") exactMoveDelay(out, delay = 60, direction = direction) face(direction) @@ -133,7 +132,7 @@ class IsafdarTraps : Script { sound("springtrap") anim("trap_stumble_back", delay = 10) exactMoveDelay(out, delay = 56, direction = direction, startDelay = 10) - damage(random.nextInt(20, 41)) // TODO unknown damage + damage(80) } fun Player.arrowVolley(wire: GameObject) { From 62f5a0db8cdbba95b20c2b19ddf98a787e30279b Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 04:24:46 -0700 Subject: [PATCH 13/15] Walk across the sticks trap on a successful pass The forced move with the walk animation looked wrong; go back to lining up and stepping across at walk speed, ending just past the strip. --- .../kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt index 1e0b9a849a..68ef97cf60 100644 --- a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt @@ -39,12 +39,12 @@ class IsafdarTraps : Script { } objectOperate("Pass", "isafdar_sticks_trap") { (target) -> - val (_, _, out, direction) = crossing(target) + val (entry, _, out, direction) = crossing(target) this["crossing_trap"] = true + walkToDelay(entry.add(direction.inverse()), forceWalk = true) + walkOverDelay(entry) if (Level.success(levels.get(Skill.Agility), 31..156)) { - anim("human_walk_forward") - exactMoveDelay(out, delay = 60, direction = direction) - face(direction) + walkOverDelay(out) message("You manage to skillfully pass the trap.") } else { springTrap(target) From c11d63ecb764b66b0646309539f62f20bf6b8365 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 04:29:27 -0700 Subject: [PATCH 14/15] Keep the player's facing when the sticks trap flings them back --- .../kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt index 68ef97cf60..f2389d8742 100644 --- a/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/IsafdarTraps.kt @@ -127,11 +127,14 @@ class IsafdarTraps : Script { } else { Tile(tile.x, if (direction == Direction.NORTH) trap.tile.y + trap.height else trap.tile.y - 1, tile.level) } + // Flung backwards but the player keeps facing the way they were + val facing = if (this.direction == Direction.NONE) direction else this.direction message("You set off the trap as you pass.") trap.anim("woodspring") sound("springtrap") anim("trap_stumble_back", delay = 10) - exactMoveDelay(out, delay = 56, direction = direction, startDelay = 10) + exactMoveDelay(out, delay = 56, direction = facing, startDelay = 10) + face(facing) damage(80) } From 7f1be9fd7671466f13e9a531fd25bc2b7c6a75db Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 04:35:51 -0700 Subject: [PATCH 15/15] Jump the leaf trap from the player's own lane Forcing the jump onto the patch's centre column made the client blend the sidestep into the jump as a glide. The patch is three tiles wide, so keep the player's lane (coerced into the patch) and only walk when they genuinely need to line up. --- .../kotlin/content/area/tirannwn/isafdar/LeafTraps.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/game/src/main/kotlin/content/area/tirannwn/isafdar/LeafTraps.kt b/game/src/main/kotlin/content/area/tirannwn/isafdar/LeafTraps.kt index 08ca90105b..685a12aecd 100644 --- a/game/src/main/kotlin/content/area/tirannwn/isafdar/LeafTraps.kt +++ b/game/src/main/kotlin/content/area/tirannwn/isafdar/LeafTraps.kt @@ -24,14 +24,17 @@ class LeafTraps : Script { val direction: Direction val start: Tile val dest: Tile + // Jump from whichever of the three lanes the player is in if (site.centre.y == 3262) { direction = if (tile.x > site.centre.x) Direction.WEST else Direction.EAST - start = Tile(site.centre.x - direction.delta.x * 2, site.centre.y, tile.level) - dest = Tile(site.centre.x + direction.delta.x * 2, site.centre.y, tile.level) + val lane = tile.y.coerceIn(site.centre.y - 1, site.centre.y + 1) + start = Tile(site.centre.x - direction.delta.x * 2, lane, tile.level) + dest = Tile(site.centre.x + direction.delta.x * 2, lane, tile.level) } else { direction = if (tile.y > site.centre.y) Direction.SOUTH else Direction.NORTH - start = Tile(site.centre.x, site.centre.y - direction.delta.y * 2, tile.level) - dest = Tile(site.centre.x, site.centre.y + direction.delta.y * 2, tile.level) + val lane = tile.x.coerceIn(site.centre.x - 1, site.centre.x + 1) + start = Tile(lane, site.centre.y - direction.delta.y * 2, tile.level) + dest = Tile(lane, site.centre.y + direction.delta.y * 2, tile.level) } this["crossing_trap"] = true if (tile != start) {