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..ea4a0162b7 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") } } @@ -152,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/main/kotlin/content/area/asgarnia/falador/WysonTheGardener.kt b/game/src/main/kotlin/content/area/asgarnia/falador/WysonTheGardener.kt index a0146f0c36..13b4ad5708 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,132 @@ 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 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.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.") + } - 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 { + /* + Woad leaves + */ + + 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.") + 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.") } else { player("I don't have enough coins to buy the leaves. I'll come back later.") @@ -85,15 +134,148 @@ 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.") + addOrDrop("woad_leaf", 2) 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 + } } 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/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") + } +} 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) + } }