From 8d4d511dccb1ac3b184c8fe64d5d0fbf7789ecfa Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 17 Aug 2026 17:08:11 -0700 Subject: [PATCH 1/4] Add Wyson mole part exchange and fix giant mole burrow crash Complete Wyson's conversation tree from the wiki transcript: the unusual flower bed branch, Giant Mole combat advice, the 25gp bulk woad leaf haggle, and the mole part exchange that replaces the old TODO. Mole claws and skins trade for cheap seed nests, noses for seed nests, matching the item split in game. Completing the Falador hard tasks adds a white lily seed per skin. Parts are swapped one at a time in their own transaction so a full inventory stops the trade rather than rolling all of it back, since a skin plus seed needs two slots for the one it frees. Nests are given unnoted because no noted bird nest exists in the cache. The old five and ten coin refusals looped back to the price menu; the transcript ends the conversation there, and the menu is already at the five option cap. Also fix two bugs found while verifying this: Giant mole burrows threw a NullPointerException on Area.random returning null, which it does after 100 failed attempts to fit a size 3 npc. The throw landed between the burrow down and burrow up animations, so a failed relocation left the mole rendered underground permanently. It now resurfaces in place and logs a warning. Falador varbit ids for i_heard_you_like_mudskips and these_arent_the_coins_youre_looking_for were swapped. Task structs carry a task index whose 115..151 range maps contiguously onto varbits 5691..5727 ordered by index; every other entry matches, those two were an exact swap. The hard task check for the seed bonus read the wrong bit as a result. Rename falador_easy.varbits.toml to falador_tasks.varbits.toml since it holds all four difficulty sets, matching the other areas. --- ...arbits.toml => falador_tasks.varbits.toml} | 4 +- .../area/asgarnia/falador/GiantMole.kt | 9 +- .../area/asgarnia/falador/WysonTheGardener.kt | 272 +++++++++++++++--- 3 files changed, 239 insertions(+), 46 deletions(-) rename data/achievement/{falador_easy.varbits.toml => falador_tasks.varbits.toml} (100%) diff --git a/data/achievement/falador_easy.varbits.toml b/data/achievement/falador_tasks.varbits.toml similarity index 100% rename from data/achievement/falador_easy.varbits.toml rename to data/achievement/falador_tasks.varbits.toml index 21faa78042..9b40043a11 100644 --- a/data/achievement/falador_easy.varbits.toml +++ b/data/achievement/falador_tasks.varbits.toml @@ -127,7 +127,7 @@ format = "boolean" persist = true [these_arent_the_coins_youre_looking_for_task] -id = 5723 +id = 5708 format = "boolean" persist = true @@ -158,7 +158,7 @@ format = "boolean" persist = true [i_heard_you_like_mudskips_task] -id = 5708 +id = 5723 format = "boolean" persist = true diff --git a/game/src/main/kotlin/content/area/asgarnia/falador/GiantMole.kt b/game/src/main/kotlin/content/area/asgarnia/falador/GiantMole.kt index 5ac44dfbfb..bd7a9407e0 100644 --- a/game/src/main/kotlin/content/area/asgarnia/falador/GiantMole.kt +++ b/game/src/main/kotlin/content/area/asgarnia/falador/GiantMole.kt @@ -125,8 +125,15 @@ class GiantMole : Script { areaSound("giant_mole_burrow_down", mole.tile) areaGfx("burrow_dust", tileToDust) pause(1) + // Area.random gives up after 100 attempts to fit the mole, so it can legitimately fail; + // surface where it stands rather than throwing, which would leave the mole burrowed + // underground for good. val newLocation = gianMoleSpawns.random(mole) - mole.tele(newLocation!!) + if (newLocation == null) { + logger.warn { "failed to find a spawn tile for Giant Mole, surfacing in place." } + } else { + mole.tele(newLocation) + } mole.anim("mole_burrow_up") } } diff --git a/game/src/main/kotlin/content/area/asgarnia/falador/WysonTheGardener.kt b/game/src/main/kotlin/content/area/asgarnia/falador/WysonTheGardener.kt index a0146f0c36..61b3270749 100644 --- a/game/src/main/kotlin/content/area/asgarnia/falador/WysonTheGardener.kt +++ b/game/src/main/kotlin/content/area/asgarnia/falador/WysonTheGardener.kt @@ -1,83 +1,134 @@ package content.area.asgarnia.falador +import content.achievement.Tasks import content.entity.player.dialogue.* import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.intEntry import content.entity.player.dialogue.type.item import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.World import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.item.floor.FloorItems -import world.gregs.voidps.engine.inv.add -import world.gregs.voidps.engine.inv.inventory -import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.inv.* +import world.gregs.voidps.engine.inv.transact.operation.AddItem.add +import world.gregs.voidps.engine.inv.transact.operation.RemoveItem.remove +/** + * Wyson's conversation tree. Source: https://runescape.wiki/w/Transcript:Wyson_the_gardener + */ class WysonTheGardener : Script { init { npcOperate("Talk-to", "wyson_the_gardener") { npc("I'm the head gardener around here. If you're looking for woad leaves, or if you need help with owt, I'm yer man.") - choice { - option("Yes please, I need woad leaves.") { - npc("How much are you willing to pay?") - choice { - option("How about five coins?") { - npc("No no, that's far too little. Woad leaves are hard to get. I used to have plenty but someone kept stealing them off me!") - choice { - option("How about ten coins?") { - howAboutTen() - } - option("How about 15 coins?") { - buyWoadLeaf() - } - option("How about 20 coins?") { - buyWoadLeaves() - } - option("Actually, I've changed my mind.") - } - } - option("How about ten coins?") { - howAboutTen() - } - option("How about 15 coins?") { - buyWoadLeaf() - } - option("How about 20 coins?") { - buyWoadLeaves() - } - option("Actually, I've changed my mind.") + menu() + } + } + + suspend fun Player.menu() { + choice("What would you like to talk about?") { + option("Ask about the unusual flower bed.") { + flowerBed() + } + option("Ask about trading for bird nests.") { + exchangeMoleBits() + } + option("Ask about woad leaves.") { + woadLeaves() + } + option("Ask for advice for fighting the Giant Mole.") { + player("Could you give me some advice for fighting the Giant Mole?") + npc("Alright. I'll give you some tips while you're down there.") + } + option("Leave.") { + leave() + } + } + } + + suspend fun Player.leave() { + player("Sorry, but I'm not interested.") + npc("Fair enough.") + } + + suspend fun Player.flowerBed() { + player("What's up with the strange flower bed?") + npc("Oh, the new lilies? Yes, we've just started...") + player("No, I meant the distorted plants whose exposed roots reach down into darkness unknowable.") + npc("Ah.") + npc("I was hoping it wasn't that noticeable.") + npc("I just wanted to make sure Falador had the best park in Gielinor, but I got carried away with some of 'Malignius Mortifer's Super-Ultra-Flora-Growth potion'.") + npc("To make matters worse, it's affected one of the local moles and she keeps ruining my flowerbeds.") + player("Is there anything I can do to help?") + npc("Maybe someone could squeeze down there and deal with her.") + npc("Could you do it? I can offer you some bird nests in exchange for any claws or hides you find.") + choice("Select an Option") { + option("Okay.") { + agreeToHelp() + } + option("Why are you even collecting mole claws?") { + npc("The wife loves them.") + choice("Select an Option") { + option("... Okay.") { + agreeToHelp() + } + option("Leave") { + leave() } } - option("Sorry, but I'm not interested.") { - npc("Fair enough.") - } + } + option("Leave.") { + leave() } } } - // TODO add selling mole parts + suspend fun Player.agreeToHelp() { + player("Okay, I'll put a stop to it.") + npc("Thank you, adventurer.") + } + + /* + Woad leaves + */ - suspend fun Player.howAboutTen() { - npc("No no, that's far too little. Woad leaves are hard to get. I used to have plenty but someone kept stealing them off me!") - choice { + suspend fun Player.woadLeaves() { + player("Yes please, I need woad leaves.") + npc("How much are you willing to pay?") + choice("What would you like to say?") { + option("How about 5 coins?") { + tooLittle() + } + option("How about 10 coins?") { + tooLittle() + } option("How about 15 coins?") { buyWoadLeaf() } option("How about 20 coins?") { buyWoadLeaves() } - option("Actually, I've changed my mind.") + option("Tell me your price.") { + haggle() + } } } + suspend fun Player.tooLittle() { + npc("No no, that's far too little. Woad leaves are hard to get. I used to have plenty but someone kept stealing them off me.") + } + suspend fun Player.buyWoadLeaf() { npc("Mmmm... okay, that sounds fair.") if (inventory.remove("coins", 15)) { if (!inventory.add("woad_leaf")) { FloorItems.add(tile, "woad_leaf", disappearTicks = 300, owner = this) } - item("woad_leaf", "You buy a woad leaf from Wyson.") player("Thanks.") + item("woad_leaf", "You buy a woad leaf from Wyson.") npc("I'll be around if you have any more gardening needs.") } else { player("I don't have enough coins to buy the leaves. I'll come back later.") @@ -85,15 +136,150 @@ class WysonTheGardener : Script { } suspend fun Player.buyWoadLeaves() { - npc("Okay, that's more than fair.") + npc("Ok that's more than fair.") + npc("Here, have two. You're a generous person.") if (inventory.remove("coins", 20)) { if (!inventory.add("woad_leaf", 2)) { FloorItems.add(tile, "woad_leaf", 2, disappearTicks = 300, owner = this) } - item("woad_leaf", "You buy a pair of woad leaves from Wyson.") player("Thanks.") + item("woad_leaf", "Wyson gives you a pair of woad leaves.") } else { player("I don't have enough coins to buy the leaves. I'll come back later.") } } + + suspend fun Player.haggle() { + npc("Hmph. The art of haggling really is lost these days. Fine. $HAGGLED_PRICE coins a piece.") + val requested = intEntry("How many would you like? ($HAGGLED_PRICE coins each)") + if (requested <= 0) { + return + } + val affordable = minOf(requested, inventory.count("coins") / HAGGLED_PRICE) + if (affordable <= 0) { + player("I don't have enough coins to buy the leaves. I'll come back later.") + return + } + var bought = 0 + while (bought < affordable && + inventory.transaction { + remove("coins", HAGGLED_PRICE) + add("woad_leaf") + } + ) { + bought++ + } + if (bought == 0) { + message("You don't have enough inventory space.") + return + } + player("Thanks.") + message("Wyson sells you: $bought x Woad leaf") + } + + /* + Mole part exchange + */ + + suspend fun Player.exchangeMoleBits() { + val held = MOLE_PARTS.filter { inventory.contains(it) } + if (held.isEmpty()) { + npc("If you get any mole claws, skins or noses from that giant beastie down there, I'll happily exchange them for some bird nests.") + player("Okay, I'll come back if I find any.") + return + } + if (!World.members) { + message("You need to be on a members' world to use this feature.") + return + } + val seeds = faladorHardTasksComplete(this) + if (seeds) { + npc("If I'm not mistaken, you've got some mole bits there! I'll trade them for bird nests, if you like. I also see that you're a shield holder; a champion of Falador, eh? I'll throw some special seeds into the bargain.") + } else { + npc("If I'm not mistaken, you've got some bits from a big mole there! I'll trade them for bird nests, if you like.") + } + choice("Choose an option:") { + if (held.contains("mole_claw")) { + option("Yes, I will trade the mole claws.") { + trade(listOf("mole_claw"), seeds) + } + } + if (held.contains("mole_skin")) { + option("Okay, I will trade the mole skin.") { + trade(listOf("mole_skin"), seeds) + } + } + if (held.contains("mole_nose")) { + option("Can I trade the mole nose?") { + trade(listOf("mole_nose"), seeds) + } + } + if (held.size == 2) { + option("I'd like to trade both.") { + trade(held, seeds) + } + } + if (held.size == 3) { + option("I'd like to trade all three.") { + trade(held, seeds) + } + } + option("Actually, I've changed my mind.") { + menu() + } + } + } + + /** + * Swaps each of the [parts] for a bird nest, one at a time so a full inventory only stops the + * trade rather than cancelling it, plus a white lily seed per mole skin when [seeds] is true. + */ + suspend fun Player.trade(parts: List, seeds: Boolean) { + var traded = 0 + var lilies = 0 + for (part in parts) { + val nest = if (part == "mole_nose") "birds_nest_seeds_1" else "birds_nest_seeds_2" + val lily = seeds && part == "mole_skin" + var remaining = inventory.count(part) + while (remaining > 0 && + inventory.transaction { + remove(part) + add(nest) + if (lily) { + add("white_lily_seed") + } + } + ) { + remaining-- + traded++ + if (lily) { + lilies++ + } + } + if (remaining > 0) { + break + } + } + if (traded == 0) { + message("You don't have enough inventory space.") + return + } + if (lilies > 0) { + npc("If you don't plan on using those seeds, some of the other gardeners may be interested in them.") + } + } + + companion object { + private const val HAGGLED_PRICE = 25 + private const val FALADOR_TASK_AREA = 3 + private const val HARD_TASK_DIFFICULTY = 4 + private val MOLE_PARTS = listOf("mole_claw", "mole_skin", "mole_nose") + + fun faladorHardTasksComplete(player: Player): Boolean = Tasks.forEach(FALADOR_TASK_AREA) { + if (definition["task_difficulty", 0] == HARD_TASK_DIFFICULTY && !Tasks.isCompleted(player, definition.stringId)) { + return@forEach false + } + null + } ?: true + } } From 7e80fa564131cd1b388b2a05ea65eeb5e8e8f850 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 17 Aug 2026 17:34:16 -0700 Subject: [PATCH 2/4] Fix extinguishing light sources throwing on a stray column dot Light.extinguish looked up the replacement item with row.item(".unlit"). RowDefinition.item already prepends the row id, so the path built was extinguish...unlit. Tables.get splits on "." and destructures the first three parts, leaving column empty, which failed as "Column not found". Line 29 already used the bare form for "type", and light_source.tables.toml declares the column as unlit. This threw whenever a player carrying a lit candle or torch had their light snuffed out, so no light source could ever be extinguished. Add a regression test covering candles and torches swapping to their unlit form, and lanterns deliberately staying lit. --- .../main/kotlin/content/skill/firemaking/Light.kt | 2 +- .../content/skill/firemaking/FiremakingTest.kt | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/game/src/main/kotlin/content/skill/firemaking/Light.kt b/game/src/main/kotlin/content/skill/firemaking/Light.kt index a01b6aab9f..3cb7eab22b 100644 --- a/game/src/main/kotlin/content/skill/firemaking/Light.kt +++ b/game/src/main/kotlin/content/skill/firemaking/Light.kt @@ -31,7 +31,7 @@ object Light { // Don't extinguish lanterns continue } - val unlit = row.item(".unlit") + val unlit = row.item("unlit") replace(index, item.id, unlit) } } diff --git a/game/src/test/kotlin/content/skill/firemaking/FiremakingTest.kt b/game/src/test/kotlin/content/skill/firemaking/FiremakingTest.kt index cde7c78ac2..8611113040 100644 --- a/game/src/test/kotlin/content/skill/firemaking/FiremakingTest.kt +++ b/game/src/test/kotlin/content/skill/firemaking/FiremakingTest.kt @@ -29,4 +29,19 @@ internal class FiremakingTest : WorldTest() { assertEquals(start.add(Direction.WEST), player.tile) assertTrue(player.experience.get(Skill.Firemaking) > 0) } + + @Test + fun `Extinguishing swaps lit candles and torches for their unlit form`() { + val player = createPlayer(emptyTile) + player.inventory.add("white_candle_lit") + player.inventory.add("lit_torch") + player.inventory.add("candle_lantern_lit_white") + + Light.extinguish(player) + + assertEquals("white_candle", player.inventory[0].id) + assertEquals("unlit_torch", player.inventory[1].id) + // Lanterns are deliberately left burning. + assertEquals("candle_lantern_lit_white", player.inventory[2].id) + } } From dea0f0221a53ece8a7096e7dcdf0e0f830770570 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 17 Aug 2026 17:34:29 -0700 Subject: [PATCH 3/4] Stop one player's failure stranding dirt-on-screen overlays handleDirtOnScreen opened the overlay for every nearby player before queueing the timer that closes it again, so a throw part way through the open loop skipped the queue entirely and left everyone in range staring at dirt until they logged out. Register the timer first, and guard each player individually so one failure costs that player their dirt effect rather than everyone else's. Guard the close loop too. World queue blocks are run unguarded inside a game loop stage, and GameLoop catches outside its while loop, so a throw while closing would have ended the game loop instead of just the overlay. Add a test covering nearby players being blinded, their lit light sources being snuffed out, and the overlay clearing itself afterwards. --- .../area/asgarnia/falador/GiantMole.kt | 22 ++++++++++--- .../area/asgarnia/falador/GiantMoleTest.kt | 32 +++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 game/src/test/kotlin/content/area/asgarnia/falador/GiantMoleTest.kt diff --git a/game/src/main/kotlin/content/area/asgarnia/falador/GiantMole.kt b/game/src/main/kotlin/content/area/asgarnia/falador/GiantMole.kt index bd7a9407e0..ea4a0162b7 100644 --- a/game/src/main/kotlin/content/area/asgarnia/falador/GiantMole.kt +++ b/game/src/main/kotlin/content/area/asgarnia/falador/GiantMole.kt @@ -159,13 +159,25 @@ class GiantMole : Script { nearMole.add(player) } } - for (player in nearMole) { - player.open("dirt_on_screen") - Light.extinguish(player) - } + // Queued before the overlay is opened so that a failure while blinding players can never + // skip the close and strand everyone behind a dirt screen until they log out. World.queue("dirt_on_screen_timer_player", 3) { for (player in nearMole) { - player.close("dirt_on_screen") + // World queue blocks run unguarded inside a game loop stage, so one player + // failing here would otherwise take the whole loop down with it. + try { + player.close("dirt_on_screen") + } catch (e: Exception) { + logger.error(e) { "Failed to clear dirt for '${player.accountName}'." } + } + } + } + for (player in nearMole) { + try { + player.open("dirt_on_screen") + Light.extinguish(player) + } catch (e: Exception) { + logger.error(e) { "Failed to throw dirt at '${player.accountName}'." } } } } diff --git a/game/src/test/kotlin/content/area/asgarnia/falador/GiantMoleTest.kt b/game/src/test/kotlin/content/area/asgarnia/falador/GiantMoleTest.kt new file mode 100644 index 0000000000..98ebe06672 --- /dev/null +++ b/game/src/test/kotlin/content/area/asgarnia/falador/GiantMoleTest.kt @@ -0,0 +1,32 @@ +package content.area.asgarnia.falador + +import WorldTest +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory + +internal class GiantMoleTest : WorldTest() { + + @Test + fun `Thrown dirt blinds nearby players then clears itself`() { + val tile = emptyTile + val one = createPlayer(tile, name = "one") + val two = createPlayer(tile.addX(2), name = "two") + one.inventory.add("white_candle_lit") + + val script = scripts.filterIsInstance().first() + script.handleDirtOnScreen(tile) + + assertTrue(one.interfaces.contains("dirt_on_screen"), "nearest player should be blinded") + assertTrue(two.interfaces.contains("dirt_on_screen"), "second player should be blinded") + // Lit light sources are snuffed out by the dirt. + assertTrue(one.inventory.contains("white_candle"), "lit candle should have been extinguished") + + tick(5) + + assertFalse(one.interfaces.contains("dirt_on_screen"), "dirt should clear on its own") + assertFalse(two.interfaces.contains("dirt_on_screen"), "dirt should clear for every player") + } +} From 536b7e8d8ac40cde4374dbf5827d4eed7cc6addc Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Tue, 18 Aug 2026 05:57:57 -0700 Subject: [PATCH 4/4] Use addOrDrop helper for Wyson woad leaf purchases --- .../content/area/asgarnia/falador/WysonTheGardener.kt | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/game/src/main/kotlin/content/area/asgarnia/falador/WysonTheGardener.kt b/game/src/main/kotlin/content/area/asgarnia/falador/WysonTheGardener.kt index 61b3270749..13b4ad5708 100644 --- a/game/src/main/kotlin/content/area/asgarnia/falador/WysonTheGardener.kt +++ b/game/src/main/kotlin/content/area/asgarnia/falador/WysonTheGardener.kt @@ -7,11 +7,11 @@ import content.entity.player.dialogue.type.intEntry import content.entity.player.dialogue.type.item import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player +import content.entity.player.inv.item.addOrDrop import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.entity.World import world.gregs.voidps.engine.entity.character.player.Player -import world.gregs.voidps.engine.entity.item.floor.FloorItems import world.gregs.voidps.engine.inv.* import world.gregs.voidps.engine.inv.transact.operation.AddItem.add import world.gregs.voidps.engine.inv.transact.operation.RemoveItem.remove @@ -124,9 +124,7 @@ class WysonTheGardener : Script { suspend fun Player.buyWoadLeaf() { npc("Mmmm... okay, that sounds fair.") if (inventory.remove("coins", 15)) { - if (!inventory.add("woad_leaf")) { - FloorItems.add(tile, "woad_leaf", disappearTicks = 300, owner = this) - } + addOrDrop("woad_leaf") player("Thanks.") item("woad_leaf", "You buy a woad leaf from Wyson.") npc("I'll be around if you have any more gardening needs.") @@ -139,9 +137,7 @@ class WysonTheGardener : Script { npc("Ok that's more than fair.") npc("Here, have two. You're a generous person.") if (inventory.remove("coins", 20)) { - if (!inventory.add("woad_leaf", 2)) { - FloorItems.add(tile, "woad_leaf", 2, disappearTicks = 300, owner = this) - } + addOrDrop("woad_leaf", 2) player("Thanks.") item("woad_leaf", "Wyson gives you a pair of woad leaves.") } else {