From f28e766f9bbaa722d5ac855a0e8e7314dd5323f0 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 30 Aug 2026 15:31:32 -0700 Subject: [PATCH] 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")) + } +}