diff --git a/.data/raw-cache/server/loc/doors.toml b/.data/raw-cache/server/loc/doors.toml index 2ec0bb3f6..2cf0b0ac3 100644 --- a/.data/raw-cache/server/loc/doors.toml +++ b/.data/raw-cache/server/loc/doors.toml @@ -188,3 +188,38 @@ contentGroup = "content.closed_left_picketgate" [object.params] "param.next_loc_stage" = "loc.rustic_openfencegate_l" "param.opensound" = "synth.picketgate_open" +# ── Al-Kharid palace double doors (content/areas/city/al-kharid, M5.ALKHARID.1) ── +# Ported from the pre-sync AlKharidLocEditor. Tagging the closed/open pair with the canonical +# door content groups is what makes the generic DoubleDoorScript bind Open + Close on both +# halves with the standard revert timer; `next_loc_stage` wires each half to its counterpart. +[[object]] +id = "loc.palacedoor_l" +inherit = "loc.palacedoor_l" +contentGroup = "content.closed_left_door" + +[object.params] +"param.next_loc_stage" = "loc.openpalacedoor_l" + +[[object]] +id = "loc.openpalacedoor_l" +inherit = "loc.openpalacedoor_l" +contentGroup = "content.opened_left_door" + +[object.params] +"param.next_loc_stage" = "loc.palacedoor_l" + +[[object]] +id = "loc.palacedoor_r" +inherit = "loc.palacedoor_r" +contentGroup = "content.closed_right_door" + +[object.params] +"param.next_loc_stage" = "loc.openpalacedoor_r" + +[[object]] +id = "loc.openpalacedoor_r" +inherit = "loc.openpalacedoor_r" +contentGroup = "content.opened_right_door" + +[object.params] +"param.next_loc_stage" = "loc.palacedoor_r" diff --git a/.data/raw-cache/server/npcs.toml b/.data/raw-cache/server/npcs.toml index 4af23d6ed..dd52f8df0 100644 --- a/.data/raw-cache/server/npcs.toml +++ b/.data/raw-cache/server/npcs.toml @@ -4496,3 +4496,128 @@ contentGroup = "content.dark_crab_fishing_spot" moveRestrict = "NoMove" wanderRange = 0 +# ══════════════════════════════════════════════════════════════════════════════ +# Al-Kharid module (content/areas/city/al-kharid, M5.F1) +# Ported from the pre-sync AlKharidNpcEditor. +# ══════════════════════════════════════════════════════════════════════════════ + +# Bankers: the rev240 cache does not tag the Al-Kharid bank-booth NPCs with `content.banker`, +# so the generic Banker script (Talk-to / Bank / Collect) would never bind without this overlay. +[[npc]] +id = "npc.kharidbanker1" +inherit = "npc.kharidbanker1" +contentGroup = "content.banker" +wanderRange = 0 + +[[npc]] +id = "npc.kharidbanker2" +inherit = "npc.kharidbanker2" +contentGroup = "content.banker" +wanderRange = 0 + +# Toll-gate border guards — anchored so they cannot wander off the gate. +[[npc]] +id = "npc.borderguard1" +inherit = "npc.borderguard1" +wanderRange = 0 + +[[npc]] +id = "npc.borderguard2" +inherit = "npc.borderguard2" +wanderRange = 0 + +# Named shopkeepers. Sell/buy/change percentages match each OSRS wiki shop page; without them +# `Shops.open` falls back to the cache param defaults (1300 / 400 / 30), which are wrong for +# every one of these stores. Stock itself comes from the upstream `.data/raw-cache/server/shops` +# corpus (inv.gemshop / inv.scimitarshop / inv.legsshop / inv.skirtshop / inv.craftingshop). +[[npc]] +id = "npc.gem_trader" +inherit = "npc.gem_trader" +wanderRange = 1 + +[npc.params] +"param.shop_sell_percentage" = 1000 +"param.shop_buy_percentage" = 700 +"param.shop_change_percentage" = 30 + +[[npc]] +id = "npc.zeke" +inherit = "npc.zeke" +moveRestrict = "Indoors" + +[npc.params] +"param.shop_sell_percentage" = 1000 +"param.shop_buy_percentage" = 550 +"param.shop_change_percentage" = 20 + +[[npc]] +id = "npc.louie_legs" +inherit = "npc.louie_legs" +moveRestrict = "Indoors" + +[npc.params] +"param.shop_sell_percentage" = 1000 +"param.shop_buy_percentage" = 650 +"param.shop_change_percentage" = 10 + +[[npc]] +id = "npc.ranael" +inherit = "npc.ranael" +moveRestrict = "Indoors" + +[npc.params] +"param.shop_sell_percentage" = 1000 +"param.shop_buy_percentage" = 650 +"param.shop_change_percentage" = 10 + +[[npc]] +id = "npc.dommik" +inherit = "npc.dommik" +moveRestrict = "Indoors" + +[npc.params] +"param.shop_sell_percentage" = 1000 +"param.shop_buy_percentage" = 650 +"param.shop_change_percentage" = 20 + +# Silk trader — Talk-to-only haggle, no shop interface (OSRS canon). +[[npc]] +id = "npc.silk_trader" +inherit = "npc.silk_trader" +wanderRange = 1 + +# Prince Ali Rescue quest NPCs — anchored, indoors where appropriate. +[[npc]] +id = "npc.hassan" +inherit = "npc.hassan" +moveRestrict = "Indoors" +wanderRange = 0 + +[[npc]] +id = "npc.osman" +inherit = "npc.osman" +wanderRange = 0 + +[[npc]] +id = "npc.lady_keli" +inherit = "npc.lady_keli" +wanderRange = 0 + +[[npc]] +id = "npc.joe" +inherit = "npc.joe" +wanderRange = 0 + +[[npc]] +id = "npc.prince_ali_prison" +inherit = "npc.prince_ali_prison" +moveRestrict = "Indoors" +wanderRange = 0 + +# Ali Morrisane slot. The rev240 cache ships no dedicated `ali_morrisane` symbol; the Al-Kharid +# populace symbol stands in, and needs an explicit Talk-to op slot so the AliMorrisane dialogue +# script is reachable from the right-click menu. +[[npc]] +id = "npc.al_kharid_man" +inherit = "npc.al_kharid_man" +option1 = "Talk-to" diff --git a/content/areas/city/al-kharid/build.gradle.kts b/content/areas/city/al-kharid/build.gradle.kts new file mode 100644 index 000000000..fc0645dfe --- /dev/null +++ b/content/areas/city/al-kharid/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + id("base-conventions") +} + +dependencies { + implementation(projects.api.pluginCommons) + implementation(projects.api.scriptAdvanced) + implementation(projects.content.quest) +} diff --git a/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/AlKharidScript.kt b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/AlKharidScript.kt new file mode 100644 index 000000000..311cbfcd0 --- /dev/null +++ b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/AlKharidScript.kt @@ -0,0 +1,41 @@ +package org.rsmod.content.areas.city.alkharid + +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * Top-level Al-Kharid plugin script (M5.F1). + * + * Interactive behaviour lives in dedicated per-NPC + per-loc + generic scripts: + * - [AlKharidTollGate] — toll-gate Open/Pay, free after Prince Ali Rescue. + * - [org.rsmod.content.areas.city.alkharid.quests.PrinceAliRescue] — the quest itself (M7.F5). + * - [org.rsmod.content.areas.city.alkharid.npcs.Ranael] — Ranael's Super Skirt Store. + * - [org.rsmod.content.areas.city.alkharid.npcs.LouieLegs] — Louie's Armoured Legs Bazaar. + * - [org.rsmod.content.areas.city.alkharid.npcs.Dommik] — Dommik's Crafting Store. + * - [org.rsmod.content.areas.city.alkharid.npcs.Zeke] — Zeke's Superior Scimitars. + * - [org.rsmod.content.areas.city.alkharid.npcs.GemTrader] — Gem Trader shop. + * - [org.rsmod.content.areas.city.alkharid.npcs.SilkTrader] — Talk-to-only silk haggle (no shop + * interface, per OSRS canon). + * - [org.rsmod.content.areas.city.alkharid.npcs.AliMorrisane] — F2P-portion Ali dialogue. + * - [org.rsmod.content.areas.city.alkharid.npcs.Karim] — Talk-to-only kebab sale, 1gp each. + * - [org.rsmod.content.areas.city.alkharid.npcs.AlKharidGeneralStore] — the two general-store + * clerks opening `inv.generalshop1`. + * + * Everything the pre-sync build expressed as Kotlin config now lives as data: + * - Bank booths use the canonical generic-loc binding in `generic-locs`; the Al-Kharid bankers are + * tagged `content.banker` by an inherit-self stanza in `.data/raw-cache/server/npcs.toml` so the + * generic Banker NPC script binds Talk-to / Bank / Collect. + * - The palace double doors are tagged with the canonical `content.closed_*_door` / + * `content.opened_*_door` groups (plus a `param.next_loc_stage` round-trip) in + * `.data/raw-cache/server/loc/doors.toml`, so the generic `DoubleDoorScript` binds Open + Close. + * - Shop stock comes from upstream's `.data/raw-cache/server/shops` toml corpus; each shopkeeper + * script simply names the matching `inv.x`. The per-shop sell/buy/change percentages are npc + * params in `npcs.toml`. + * - NPC spawns come from upstream's canonical `.data/raw-cache/map/npcs/kharid_desert_region.toml` + * and `draynor.toml`, so this module ships no spawn file of its own. + */ +class AlKharidScript : PluginScript() { + override fun ScriptContext.startup() { + // Intentionally empty: interactive behaviour lives in per-NPC + per-loc + generic scripts. + } +} diff --git a/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/AlKharidTollGate.kt b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/AlKharidTollGate.kt new file mode 100644 index 000000000..458f5fc40 --- /dev/null +++ b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/AlKharidTollGate.kt @@ -0,0 +1,140 @@ +@file:Suppress("SpellCheckingInspection") + +package org.rsmod.content.areas.city.alkharid + +import jakarta.inject.Inject +import org.rsmod.api.invtx.invTakeFee +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpLoc1 +import org.rsmod.api.script.onOpLoc4 +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.content.areas.city.alkharid.quests.PrinceAliRescue +import org.rsmod.game.entity.Npc +import org.rsmod.map.CoordGrid +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * Al-Kharid toll gate — Open / Pay-toll(10gp) on the Lumbridge↔Al-Kharid gate pair. + * + * Free passage is a Prince Ali Rescue completion reward, read through the quest engine + * ([PrinceAliRescue.quest]) rather than off the raw `varp.princequest` value. + */ +class AlKharidTollGate @Inject constructor(private val princeAliRescue: PrinceAliRescue) : + PluginScript() { + override fun ScriptContext.startup() { + // Default gate (from map data): Open=op1, Pay-toll(10gp)=op4. + onOpLoc1(GATE_DEFAULT_LEFT) { openGate() } + onOpLoc1(GATE_DEFAULT_RIGHT) { openGate() } + onOpLoc4(GATE_DEFAULT_LEFT) { payToll() } + onOpLoc4(GATE_DEFAULT_RIGHT) { payToll() } + + // 2op variant: Open=op1, Pay-toll(10gp)=op2. + onOpLoc1(GATE_LEFT_2OP) { openGate() } + onOpLoc1(GATE_RIGHT_2OP) { openGate() } + + // 1op variant (after quest): Open=op1. + onOpLoc1(GATE_LEFT_1OP) { openGateAfterQuest() } + onOpLoc1(GATE_RIGHT_1OP) { openGateAfterQuest() } + + // Border guard dialogue. + onOpNpc1(BORDER_GUARD_1) { talkToGuard(it.npc) } + onOpNpc1(BORDER_GUARD_2) { talkToGuard(it.npc) } + } + + private fun ProtectedAccess.isQuestComplete(): Boolean = + princeAliRescue.quest.isQuestCompleted(player) + + private fun ProtectedAccess.getDestination(): CoordGrid = + if (player.coords.x >= GATE_PIVOT_X) EAST_SIDE_LANDING else WEST_SIDE_LANDING + + private suspend fun ProtectedAccess.openGate() { + if (isQuestComplete()) { + openGateAfterQuest() + return + } + mes("You need to pay 10 gold to pass through the gate.") + val option = choice2("Pay 10 gold.", OPT_PAY, "No thanks.", OPT_DECLINE) + when (option) { + OPT_PAY -> payToll() + OPT_DECLINE -> mes("You decide not to pass through the gate.") + } + } + + private suspend fun ProtectedAccess.payToll() { + if (isQuestComplete()) { + openGateAfterQuest() + return + } + if (!player.invTakeFee(fee = TOLL_FEE)) { + mes("You do not have enough coins to pay the toll.") + return + } + mes("You pay the guard 10 gold.") + telejump(getDestination()) + } + + private fun ProtectedAccess.openGateAfterQuest() { + telejump(getDestination()) + } + + private suspend fun ProtectedAccess.talkToGuard(npc: Npc) { + if (isQuestComplete()) { + startDialogue(npc) { + chatNpc(happy, "You are a friend of Al Kharid. Pass through freely.") + } + return + } + startDialogue(npc) { + chatNpc( + neutral, + "Halt! You must pay a toll of 10 gold coins to pass through this gate.", + ) + val option = + choice2( + "Okay, I will pay.", + OPT_PAY, + "No thanks, I will find another way.", + OPT_DECLINE, + ) + when (option) { + OPT_PAY -> { + if (!access.player.invTakeFee(fee = TOLL_FEE)) { + chatPlayer(sad, "Oh dear, I do not seem to have enough money.") + } else { + chatPlayer(happy, "Here you go.") + chatNpc(happy, "Thank you, you may pass.") + access.telejump(access.getDestination()) + } + } + OPT_DECLINE -> { + chatPlayer(neutral, "No thanks, I will find another way.") + chatNpc(neutral, "Suit yourself.") + } + } + } + } + + public companion object { + /** OSRS canon: the Al-Kharid gate toll is 10gp. */ + public const val TOLL_FEE: Int = 10 + + public const val GATE_DEFAULT_LEFT: String = "loc.kharidmetalgateclosedl" + public const val GATE_DEFAULT_RIGHT: String = "loc.kharidmetalgateclosedr" + public const val GATE_LEFT_2OP: String = "loc.kharidmetalgateclosedl_2op" + public const val GATE_RIGHT_2OP: String = "loc.kharidmetalgateclosedr_2op" + public const val GATE_LEFT_1OP: String = "loc.kharidmetalgateclosedl_1op" + public const val GATE_RIGHT_1OP: String = "loc.kharidmetalgateclosedr_1op" + public const val BORDER_GUARD_1: String = "npc.borderguard1" + public const val BORDER_GUARD_2: String = "npc.borderguard2" + + /** Absolute x of the gate line: east of it means the player is on the Al-Kharid side. */ + private const val GATE_PIVOT_X: Int = 3268 + + private val WEST_SIDE_LANDING: CoordGrid = CoordGrid(0, 51, 50, 5, 28) + private val EAST_SIDE_LANDING: CoordGrid = CoordGrid(0, 51, 50, 2, 28) + + private const val OPT_PAY: Int = 1 + private const val OPT_DECLINE: Int = 2 + } +} diff --git a/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/AlKharidGeneralStore.kt b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/AlKharidGeneralStore.kt new file mode 100644 index 000000000..7ccdd5ec8 --- /dev/null +++ b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/AlKharidGeneralStore.kt @@ -0,0 +1,62 @@ +package org.rsmod.content.areas.city.alkharid.npcs + +import jakarta.inject.Inject +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.api.script.onOpNpc3 +import org.rsmod.api.shops.Shops +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.Player +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * AlKharidGeneralStore — Shop keeper & Shop assistant, the two clerks of the Al Kharid General + * Store (first building east of the palace). + * + * Dialogue tracks the upstream generic shopkeeper transcript: both clerks run the identical + * standard-dialogue script upstream, so one shared dialogue body serves both. Trade opens the + * upstream `inv.generalshop1` cache inventory (`.data/raw-cache/server/shops/generalshop1.toml`) — + * a single shared stockroom for both clerks, not two separate shops. Op3 is the cache's real Trade + * slot: both npc types (`npc.generalshopkeeper3` id 2817, `npc.generalassistant3` id 2818) report + * `ops=[0=Talk-to, 2=Trade]`. + */ +class AlKharidGeneralStore @Inject constructor(private val shops: Shops) : PluginScript() { + override fun ScriptContext.startup() { + onOpNpc1(SHOP_KEEPER) { startDialogue(it.npc) } + onOpNpc1(SHOP_ASSISTANT) { startDialogue(it.npc) } + onOpNpc3(SHOP_KEEPER) { player.openShop(it.npc) } + onOpNpc3(SHOP_ASSISTANT) { player.openShop(it.npc) } + } + + private fun Player.openShop(npc: Npc) { + shops.open(this, npc, SHOP_TITLE, SHOP_INV) + } + + private suspend fun ProtectedAccess.startDialogue(npc: Npc) { + startDialogue(npc) { clerkDialogue(npc) } + } + + private suspend fun Dialogue.clerkDialogue(npc: Npc) { + chatNpc(happy, "Can I help you at all?") + val choice = choice2("Yes please. What are you selling?", OPT_YES, "No thanks.", OPT_NO) + when (choice) { + OPT_YES -> { + chatPlayer(happy, "Yes please. What are you selling?") + player.openShop(npc) + } + OPT_NO -> chatPlayer(neutral, "No thanks.") + } + } + + public companion object { + public const val SHOP_KEEPER: String = "npc.generalshopkeeper3" + public const val SHOP_ASSISTANT: String = "npc.generalassistant3" + public const val SHOP_INV: String = "inv.generalshop1" + public const val SHOP_TITLE: String = "Al Kharid General Store" + + private const val OPT_YES: Int = 1 + private const val OPT_NO: Int = 2 + } +} diff --git a/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/AliMorrisane.kt b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/AliMorrisane.kt new file mode 100644 index 000000000..e287941ff --- /dev/null +++ b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/AliMorrisane.kt @@ -0,0 +1,80 @@ +package org.rsmod.content.areas.city.alkharid.npcs + +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.game.entity.Npc +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * Ali Morrisane — north Al-Kharid trader (M5.ALKHARID.1). + * + * Ali Morrisane's canonical role is the Rogue Trader miniquest (members-only Kharidian Desert + * content) where he sets the player up with the Pollnivneach black-market trade. The F2P-portion + * dialogue we ship matches the pre-miniquest greeting on the OSRS wiki: Ali introduces himself and + * gestures at his uncle's "Discount Wares" stall, directing the player to come back when they're + * ready to do business in the desert. + * + * The rev240 cache still exposes no dedicated `ali_morrisane` NPC symbol, so this dialogue binds + * against the Al-Kharid populace symbol `npc.al_kharid_man` directly. The pre-sync build routed the + * same binding through a Silo-minted `content.alkharid_ali_morrisane` group applied to that very + * NPC type; since `contentGroup` is a type-level attribute, the indirection was never per-spawn and + * bought nothing. The inherit-self `npc.al_kharid_man` overlay in + * `.data/raw-cache/server/npcs.toml` supplies the `Talk-to` op slot the generic populace symbol + * ships without. + */ +class AliMorrisane : PluginScript() { + override fun ScriptContext.startup() { + onOpNpc1(ALI_MORRISANE) { startDialogue(it.npc) } + } + + private suspend fun ProtectedAccess.startDialogue(npc: Npc) { + startDialogue(npc) { aliDialogue() } + } + + private suspend fun Dialogue.aliDialogue() { + chatNpc( + happy, + "Aha! A potential customer! Welcome, welcome — I am Ali Morrisane, master trader of " + + "the Kharidian Desert!", + ) + val choice = + choice3( + "What do you sell?", + OPT_SELL, + "Where do your wares come from?", + OPT_WARES, + "Goodbye.", + OPT_BYE, + ) + when (choice) { + OPT_SELL -> { + chatPlayer(quiz, "What do you sell?") + chatNpc( + happy, + "A little of this, a little of that — gems from the desert, silks from the " + + "south, fine wares from across Gielinor. Come back when you wish to do " + + "business in the desert and we shall talk further.", + ) + } + OPT_WARES -> { + chatPlayer(quiz, "Where do your wares come from?") + chatNpc( + happy, + "My many nephews work the trade routes all across the Kharidian Desert. From " + + "Pollnivneach to Nardah they bring me only the finest goods.", + ) + } + OPT_BYE -> chatPlayer(neutral, "Goodbye.") + } + } + + public companion object { + public const val ALI_MORRISANE: String = "npc.al_kharid_man" + + private const val OPT_SELL: Int = 1 + private const val OPT_WARES: Int = 2 + private const val OPT_BYE: Int = 3 + } +} diff --git a/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/Dommik.kt b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/Dommik.kt new file mode 100644 index 000000000..61b2707a0 --- /dev/null +++ b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/Dommik.kt @@ -0,0 +1,55 @@ +package org.rsmod.content.areas.city.alkharid.npcs + +import jakarta.inject.Inject +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.api.script.onOpNpc3 +import org.rsmod.api.shops.Shops +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.Player +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * Dommik — Dommik's Crafting Store owner (north-east Al-Kharid). M5.ALKHARID.2. + * + * Dialogue tracks the OSRS wiki transcript (Transcript:Dommik): Dommik offers to show his crafting + * supplies. Trade opens the upstream `inv.craftingshop` toml inventory (F2P moulds, chisel, needle, + * thread). + */ +class Dommik @Inject constructor(private val shops: Shops) : PluginScript() { + override fun ScriptContext.startup() { + onOpNpc1(NPC) { startDialogue(it.npc) } + onOpNpc3(NPC) { player.openShop(it.npc) } + } + + private fun Player.openShop(npc: Npc) { + shops.open(this, npc, SHOP_TITLE, SHOP_INV) + } + + private suspend fun ProtectedAccess.startDialogue(npc: Npc) { + startDialogue(npc) { shopDialogue(npc) } + } + + private suspend fun Dialogue.shopDialogue(npc: Npc) { + chatNpc(happy, "Welcome to my crafting store. Want to see my wares?") + val choice = choice2("Yes please.", OPT_YES, "No thanks.", OPT_NO) + when (choice) { + OPT_YES -> { + chatPlayer(happy, "Yes please.") + player.openShop(npc) + } + OPT_NO -> chatPlayer(neutral, "No thanks.") + } + } + + public companion object { + public const val NPC: String = "npc.dommik" + public const val SHOP_INV: String = "inv.craftingshop" + public const val SHOP_TITLE: String = "Dommik's Crafting Store" + + private const val OPT_YES: Int = 1 + private const val OPT_NO: Int = 2 + } +} diff --git a/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/GemTrader.kt b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/GemTrader.kt new file mode 100644 index 000000000..ecdfdb009 --- /dev/null +++ b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/GemTrader.kt @@ -0,0 +1,54 @@ +package org.rsmod.content.areas.city.alkharid.npcs + +import jakarta.inject.Inject +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.api.script.onOpNpc3 +import org.rsmod.api.shops.Shops +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.Player +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * Gem trader — Gem Trader shop owner (south-east of Al-Kharid square). M5.ALKHARID.2. + * + * Dialogue tracks the OSRS wiki transcript (Transcript:Gem trader). Trade opens the upstream + * `inv.gemshop` toml inventory (uncut + cut sapphire, emerald, ruby, and diamond). + */ +class GemTrader @Inject constructor(private val shops: Shops) : PluginScript() { + override fun ScriptContext.startup() { + onOpNpc1(NPC) { startDialogue(it.npc) } + onOpNpc3(NPC) { player.openShop(it.npc) } + } + + private fun Player.openShop(npc: Npc) { + shops.open(this, npc, SHOP_TITLE, SHOP_INV) + } + + private suspend fun ProtectedAccess.startDialogue(npc: Npc) { + startDialogue(npc) { shopDialogue(npc) } + } + + private suspend fun Dialogue.shopDialogue(npc: Npc) { + chatNpc(happy, "Welcome to my gem store. Would you like to look at my wares?") + val choice = choice2("Yes please.", OPT_YES, "No thanks.", OPT_NO) + when (choice) { + OPT_YES -> { + chatPlayer(happy, "Yes please.") + player.openShop(npc) + } + OPT_NO -> chatPlayer(neutral, "No thanks.") + } + } + + public companion object { + public const val NPC: String = "npc.gem_trader" + public const val SHOP_INV: String = "inv.gemshop" + public const val SHOP_TITLE: String = "Gem Trader" + + private const val OPT_YES: Int = 1 + private const val OPT_NO: Int = 2 + } +} diff --git a/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/Karim.kt b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/Karim.kt new file mode 100644 index 000000000..1aee284b7 --- /dev/null +++ b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/Karim.kt @@ -0,0 +1,55 @@ +package org.rsmod.content.areas.city.alkharid.npcs + +import org.rsmod.api.invtx.invTakeFee +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.game.entity.Npc +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * Karim — Al-Kharid kebab seller on the market square (M5.ALKHARID.2). + * + * Per OSRS canon (Transcript:Karim) Karim has no shop interface — kebabs are sold exclusively + * through a Talk-to dialogue at one coin each. Only the standard branch ships here: the Rogue + * Trader miniquest and clue-scroll branches are members-flavoured content out of scope for the F2P + * portion. + */ +class Karim : PluginScript() { + override fun ScriptContext.startup() { + onOpNpc1(NPC) { startDialogue(it.npc) } + } + + private suspend fun ProtectedAccess.startDialogue(npc: Npc) { + startDialogue(npc) { karimDialogue() } + } + + private suspend fun Dialogue.karimDialogue() { + chatNpc(happy, "Would you like to buy a nice kebab? Only one gold.") + val choice = choice2("Yes please.", OPT_YES, "I think I'll give it a miss.", OPT_MISS) + when (choice) { + OPT_YES -> { + if (!access.player.invTakeFee(fee = KEBAB_PRICE)) { + chatPlayer(sad, "Oops, I forgot to bring any money with me.") + chatNpc(neutral, "Come back when you have some.") + } else { + access.invAdd(access.inv, KEBAB) + } + } + OPT_MISS -> chatPlayer(neutral, "I think I'll give it a miss.") + } + } + + public companion object { + public const val NPC: String = "npc.kebab_seller" + + /** OSRS canon: Karim sells kebabs for one coin (Karim wiki page). */ + public const val KEBAB_PRICE: Int = 1 + + private const val KEBAB: String = "obj.kebab" + + private const val OPT_YES: Int = 1 + private const val OPT_MISS: Int = 2 + } +} diff --git a/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/LouieLegs.kt b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/LouieLegs.kt new file mode 100644 index 000000000..f35786d9e --- /dev/null +++ b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/LouieLegs.kt @@ -0,0 +1,54 @@ +package org.rsmod.content.areas.city.alkharid.npcs + +import jakarta.inject.Inject +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.api.script.onOpNpc3 +import org.rsmod.api.shops.Shops +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.Player +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * Louie Legs — Louie's Armoured Legs Bazaar owner (south-east Al-Kharid). M5.ALKHARID.2. + * + * Dialogue tracks the OSRS wiki transcript (Transcript:Louie Legs). Trade opens the upstream + * `inv.legsshop` toml inventory (bronze→adamant platelegs). + */ +class LouieLegs @Inject constructor(private val shops: Shops) : PluginScript() { + override fun ScriptContext.startup() { + onOpNpc1(NPC) { startDialogue(it.npc) } + onOpNpc3(NPC) { player.openShop(it.npc) } + } + + private fun Player.openShop(npc: Npc) { + shops.open(this, npc, SHOP_TITLE, SHOP_INV) + } + + private suspend fun ProtectedAccess.startDialogue(npc: Npc) { + startDialogue(npc) { shopDialogue(npc) } + } + + private suspend fun Dialogue.shopDialogue(npc: Npc) { + chatNpc(happy, "Hello there! Care to look at some fine armoured legs?") + val choice = choice2("Yes please.", OPT_YES, "No thanks.", OPT_NO) + when (choice) { + OPT_YES -> { + chatPlayer(happy, "Yes please.") + player.openShop(npc) + } + OPT_NO -> chatPlayer(neutral, "No thanks.") + } + } + + public companion object { + public const val NPC: String = "npc.louie_legs" + public const val SHOP_INV: String = "inv.legsshop" + public const val SHOP_TITLE: String = "Louie's Armoured Legs Bazaar" + + private const val OPT_YES: Int = 1 + private const val OPT_NO: Int = 2 + } +} diff --git a/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/Ranael.kt b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/Ranael.kt new file mode 100644 index 000000000..a1e0baa2f --- /dev/null +++ b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/Ranael.kt @@ -0,0 +1,54 @@ +package org.rsmod.content.areas.city.alkharid.npcs + +import jakarta.inject.Inject +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.api.script.onOpNpc3 +import org.rsmod.api.shops.Shops +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.Player +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * Ranael — Ranael's Super Skirt Store owner (south-east Al-Kharid). M5.ALKHARID.2. + * + * Dialogue tracks the OSRS wiki transcript (Transcript:Ranael). Trade opens the upstream + * `inv.skirtshop` toml inventory (bronze→adamant plateskirts). + */ +class Ranael @Inject constructor(private val shops: Shops) : PluginScript() { + override fun ScriptContext.startup() { + onOpNpc1(NPC) { startDialogue(it.npc) } + onOpNpc3(NPC) { player.openShop(it.npc) } + } + + private fun Player.openShop(npc: Npc) { + shops.open(this, npc, SHOP_TITLE, SHOP_INV) + } + + private suspend fun ProtectedAccess.startDialogue(npc: Npc) { + startDialogue(npc) { shopDialogue(npc) } + } + + private suspend fun Dialogue.shopDialogue(npc: Npc) { + chatNpc(happy, "Hello, would you like to see my wares?") + val choice = choice2("Yes please.", OPT_YES, "No thanks.", OPT_NO) + when (choice) { + OPT_YES -> { + chatPlayer(happy, "Yes please.") + player.openShop(npc) + } + OPT_NO -> chatPlayer(neutral, "No thanks.") + } + } + + public companion object { + public const val NPC: String = "npc.ranael" + public const val SHOP_INV: String = "inv.skirtshop" + public const val SHOP_TITLE: String = "Ranael's Super Skirt Store" + + private const val OPT_YES: Int = 1 + private const val OPT_NO: Int = 2 + } +} diff --git a/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/SilkTrader.kt b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/SilkTrader.kt new file mode 100644 index 000000000..53be8e122 --- /dev/null +++ b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/SilkTrader.kt @@ -0,0 +1,63 @@ +package org.rsmod.content.areas.city.alkharid.npcs + +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.game.entity.Npc +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * Silk Trader — Al-Kharid silk salesman (south-east Al-Kharid market square). M5.ALKHARID.2. + * + * Per OSRS canon (Silk trader wiki page) the Silk Trader has no shop interface — silk is sold + * exclusively through a Talk-to-only haggle dialogue. The full haggle mechanic (3gp default, 2gp on + * convincing him to drop the price) is members-flavour content that ties into the Ardougne + * silk-running money-making loop; the F2P-portion dialogue we ship is the canonical greeting + + * flavour about the silk trade, matching what an F2P player would experience. + */ +class SilkTrader : PluginScript() { + override fun ScriptContext.startup() { + onOpNpc1(SILK_TRADER) { startDialogue(it.npc) } + } + + private suspend fun ProtectedAccess.startDialogue(npc: Npc) { + startDialogue(npc) { silkTraderDialogue() } + } + + private suspend fun Dialogue.silkTraderDialogue() { + chatNpc(happy, "Greetings, would you be interested in buying some silk?") + val choice = + choice3( + "How much are you selling it for?", + OPT_PRICE, + "What's silk used for?", + OPT_USE, + "No thanks.", + OPT_NO, + ) + when (choice) { + OPT_PRICE -> { + chatPlayer(quiz, "How much are you selling it for?") + chatNpc(neutral, "I'll let you have a roll of silk for 3 gold coins.") + } + OPT_USE -> { + chatPlayer(quiz, "What's silk used for?") + chatNpc( + happy, + "Silk is a fine fabric. The clothiers and tailors of the world know how " + + "to weave wonderful garments from it.", + ) + } + OPT_NO -> chatPlayer(neutral, "No thanks.") + } + } + + public companion object { + public const val SILK_TRADER: String = "npc.silk_trader" + + private const val OPT_PRICE: Int = 1 + private const val OPT_USE: Int = 2 + private const val OPT_NO: Int = 3 + } +} diff --git a/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/Zeke.kt b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/Zeke.kt new file mode 100644 index 000000000..f91016377 --- /dev/null +++ b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/npcs/Zeke.kt @@ -0,0 +1,57 @@ +package org.rsmod.content.areas.city.alkharid.npcs + +import jakarta.inject.Inject +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.api.script.onOpNpc3 +import org.rsmod.api.shops.Shops +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.Player +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * Zeke — Zeke's Superior Scimitars owner (north-east Al-Kharid, just north-east of the bank). + * M5.ALKHARID.2. + * + * Dialogue tracks the OSRS wiki transcript (Transcript:Zeke): Zeke offers to show his scimitars. + * Trade opens the upstream `inv.scimitarshop` toml inventory (bronze/iron/steel/mithril scimitars); + * the sell/buy/change percentages come from the inherit-self `npc.zeke` overlay in + * `.data/raw-cache/server/npcs.toml`. + */ +class Zeke @Inject constructor(private val shops: Shops) : PluginScript() { + override fun ScriptContext.startup() { + onOpNpc1(NPC) { startDialogue(it.npc) } + onOpNpc3(NPC) { player.openShop(it.npc) } + } + + private fun Player.openShop(npc: Npc) { + shops.open(this, npc, SHOP_TITLE, SHOP_INV) + } + + private suspend fun ProtectedAccess.startDialogue(npc: Npc) { + startDialogue(npc) { shopDialogue(npc) } + } + + private suspend fun Dialogue.shopDialogue(npc: Npc) { + chatNpc(happy, "Welcome to my fine scimitar shop. Would you like to see my wares?") + val choice = choice2("Yes please.", OPT_YES, "No thanks.", OPT_NO) + when (choice) { + OPT_YES -> { + chatPlayer(happy, "Yes please.") + player.openShop(npc) + } + OPT_NO -> chatPlayer(neutral, "No thanks.") + } + } + + public companion object { + public const val NPC: String = "npc.zeke" + public const val SHOP_INV: String = "inv.scimitarshop" + public const val SHOP_TITLE: String = "Zeke's Superior Scimitars" + + private const val OPT_YES: Int = 1 + private const val OPT_NO: Int = 2 + } +} diff --git a/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/quests/PrinceAliRescue.kt b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/quests/PrinceAliRescue.kt new file mode 100644 index 000000000..a4b82f21c --- /dev/null +++ b/content/areas/city/al-kharid/src/main/kotlin/org/rsmod/content/areas/city/alkharid/quests/PrinceAliRescue.kt @@ -0,0 +1,605 @@ +@file:Suppress("SpellCheckingInspection") + +package org.rsmod.content.areas.city.alkharid.quests + +import jakarta.inject.Inject +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.repo.obj.ObjRepository +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.content.quest.manager.ItemRewardDisplay +import org.rsmod.content.quest.manager.QuestScript +import org.rsmod.content.quest.manager.rewards +import org.rsmod.game.entity.Npc +import org.rsmod.plugin.scripts.ScriptContext + +/** + * Prince Ali Rescue — F2P novice quest (M7.F5). + * + * Re-expressed on the rev240 quest engine: identity (id, display name, quest points, endstate) is + * read from the `dbrow.quest_princealirescue` cache row, and progress is written to the + * OSRS-canonical `varp.princequest` varp. The cache row's `endstate` is **110**, and the canonical + * varp progression runs in steps of ten, so the stage constants below are raw varp values rather + * than a collapsed 0..n counter. + * + * ## Stages + * - `0` — Chancellor Hassan has not been asked for the quest. + * - [STAGE_TALKED_TO_HASSAN] (`10`) — Hassan accepted the quest and pointed the player at Osman, + * the royal spymaster. Hassan sits in the south room of the Al-Kharid palace. + * - [STAGE_OSMAN_BRIEFED] (`20`) — Osman handed the player the shopping list of disguise + * ingredients (a blond wig, skin-paste, a pink skirt, a key impression, and a soft clay lump). + * Osman's daughter Leela waits in Draynor with the operation plan. + * - [STAGE_LEELA_BRIEFED] (`30`) — Leela explained the full plan: distract Joe with beer, take an + * impression of Lady Keli's key, smith a copy from bronze, free the prince. + * - [STAGE_KEY_IMPRESSED] (`50`) — The player pressed Lady Keli's key into a soft clay lump and is + * carrying a [KEYPRINT]. + * - [STAGE_KEY_FORGED] (`70`) — Leela inspected the impression and the bronze bar and minted a + * [PRINCESKEY] for the player to take to the prison. + * - [STAGE_COMPLETE] (`110`) — Prince Ali is freed. Completion pays the cache-row 3 quest points + * plus 700 coins, and unlocks free passage through the Al-Kharid toll gate (read by + * [org.rsmod.content.areas.city.alkharid.AlKharidTollGate]). + * + * Dialogue text follows [https://oldschool.runescape.wiki/w/Transcript:Prince_Ali_Rescue]. The + * cutscene moments (Lady Keli's key demonstration, Joe's beer nap, the prison escape) are mesbox + * lines rather than choreographed cutscenes. + * + * The script lives in the al-kharid module because the start NPC (Chancellor Hassan) is in the + * Al-Kharid palace. Leela's Talk-to handler lives here too — she is a Draynor NPC, but she is a + * pure quest actor, so this module owns her binding and there is no Draynor → al-kharid edge. + */ +class PrinceAliRescue @Inject constructor(private val objRepo: ObjRepository) : + QuestScript( + "quest_princealirescue", + "varp.princequest", + rewards { item(COINS, COIN_REWARD) }, + ItemRewardDisplay(PRINCESKEY), + ) { + + override fun ScriptContext.init() { + onOpNpc1(HASSAN) { handleHassan(it.npc) } + onOpNpc1(OSMAN) { handleOsman(it.npc) } + onOpNpc1(LADY_KELI) { handleLadyKeli(it.npc) } + onOpNpc1(JOE) { handleJoe(it.npc) } + onOpNpc1(PRINCE_ALI) { handlePrinceAli(it.npc) } + onOpNpc1(LEELA) { handleLeela(it.npc) } + } + + // ──────────────────────────────────────────────────────────────────────── + // Chancellor Hassan — Al-Kharid palace, ground floor southern room. + // ──────────────────────────────────────────────────────────────────────── + + private suspend fun ProtectedAccess.handleHassan(npc: Npc) { + when { + quest.isQuestNotStarted(player) -> startDialogue(npc) { hassanOffer() } + quest.isQuestCompleted(player) -> startDialogue(npc) { hassanComplete() } + else -> startDialogue(npc) { hassanInProgress() } + } + } + + private suspend fun Dialogue.hassanOffer() { + chatNpc(sad, "Help us, please, brave adventurer!") + chatPlayer(quiz, "What's the matter?") + chatNpc( + sad, + "Our young Prince Ali has been kidnapped. We fear he is being held by Lady Keli " + + "north of Draynor Village. The Lumbridge guards refuse to help — we need an " + + "outsider to slip in and free him.", + ) + val accepted = + choice2( + "Yes, I will help.", + true, + "Sorry, I'm too busy.", + false, + title = "Start the Prince Ali Rescue quest?", + ) + if (!accepted) { + chatPlayer(sad, "Sorry, I'm too busy.") + chatNpc(sad, "I understand. Please come back if you change your mind.") + return + } + chatPlayer(happy, "Yes, I will help.") + chatNpc( + happy, + "Thank you, friend. Go and speak to my spymaster Osman — he stands by the fountain " + + "just outside the palace. He has been planning the rescue and will tell you " + + "what is needed.", + ) + access.advanceTo(STAGE_TALKED_TO_HASSAN) + } + + private suspend fun Dialogue.hassanInProgress() { + chatNpc( + quiz, + "How fares the rescue, my friend? Osman should be able to help you more than I can.", + ) + } + + private suspend fun Dialogue.hassanComplete() { + chatNpc( + happy, + "Thank you again, brave adventurer. Al-Kharid owes you a great debt for returning " + + "the prince to us.", + ) + } + + // ──────────────────────────────────────────────────────────────────────── + // Osman — outside the palace, by the fountain. + // ──────────────────────────────────────────────────────────────────────── + + private suspend fun ProtectedAccess.handleOsman(npc: Npc) { + when { + quest.isQuestNotStarted(player) -> startDialogue(npc) { osmanNotStarted() } + quest.isQuestCompleted(player) -> startDialogue(npc) { osmanComplete() } + else -> startDialogue(npc) { osmanInProgress(quest.getQuestStage(player)) } + } + } + + private suspend fun Dialogue.osmanNotStarted() { + chatNpc( + neutral, + "Greetings. I am Osman, spymaster to Chancellor Hassan. If you wish to help us " + + "with our troubles, speak to the chancellor first.", + ) + } + + private suspend fun Dialogue.osmanInProgress(stage: Int) { + if (stage >= STAGE_OSMAN_BRIEFED) { + chatNpc( + neutral, + "My daughter Leela is waiting for you south of the Draynor wheat fields. She " + + "has the operation plan.", + ) + return + } + chatPlayer(quiz, "Hassan sent me. How do we get Prince Ali out?") + chatNpc( + neutral, + "Listen carefully. Lady Keli wears a key on her belt — get an impression of it " + + "with a lump of soft clay. We will smith a copy from a bronze bar.", + ) + chatNpc( + neutral, + "The guard, Joe, can be put to sleep with poison brewed by Aggie in Draynor. With " + + "him snoring, you can slip past to the prince's cell.", + ) + chatNpc( + neutral, + "Ali must walk out disguised. You will need a blond wig (Ned in Draynor makes wigs " + + "from wool — Aggie dyes them yellow), skin-coloured paste (Aggie again), and a " + + "pink skirt (Ranael in Al-Kharid sells them).", + ) + chatNpc( + neutral, + "My daughter Leela will help you in Draynor. She is waiting south of the wheat " + + "fields. Go and speak with her once you have the disguise pieces.", + ) + access.advanceTo(STAGE_OSMAN_BRIEFED) + } + + private suspend fun Dialogue.osmanComplete() { + chatNpc(happy, "You have served Al-Kharid well, friend. The prince is safe again.") + } + + // ──────────────────────────────────────────────────────────────────────── + // Leela — south of the Draynor wheat fields. Drives the key impression + forge. + // ──────────────────────────────────────────────────────────────────────── + + private suspend fun ProtectedAccess.handleLeela(npc: Npc) { + when { + quest.isQuestNotStarted(player) -> startDialogue(npc) { leelaNotStarted() } + quest.isQuestCompleted(player) -> startDialogue(npc) { leelaComplete() } + else -> startDialogue(npc) { leelaInProgress(quest.getQuestStage(player)) } + } + } + + private suspend fun Dialogue.leelaNotStarted() { + chatNpc( + neutral, + "I'm sorry, adventurer, this is family business. Speak to my father Osman in " + + "Al-Kharid if you wish to help.", + ) + } + + private suspend fun Dialogue.leelaInProgress(stage: Int) { + when { + stage < STAGE_OSMAN_BRIEFED -> + chatNpc(neutral, "Talk to my father first. He has the briefing for the rescue.") + stage == STAGE_OSMAN_BRIEFED -> leelaBriefing() + stage == STAGE_LEELA_BRIEFED -> leelaAwaitingImpression() + stage == STAGE_KEY_IMPRESSED -> leelaForgeKey() + else -> leelaAwaitingRescue() + } + } + + private suspend fun Dialogue.leelaBriefing() { + chatPlayer(quiz, "Osman sent me. What do you need me to do?") + chatNpc( + neutral, + "Listen — Lady Keli loves to brag about her keys. Ask her about them and she will " + + "show you the one to the cell. While she is showing off, press a lump of soft " + + "clay against it to take the impression.", + ) + chatNpc( + neutral, + "Bring me the impression and a bronze bar; I will smith you a working copy. While " + + "I do that, distract Joe with a beer.", + ) + chatNpc( + neutral, + "Don't forget Prince Ali's disguise: blond wig, skin-paste, and a pink skirt. He " + + "must walk out as a noblewoman.", + ) + access.advanceTo(STAGE_LEELA_BRIEFED) + } + + private suspend fun Dialogue.leelaAwaitingImpression() { + if (access.invTotal(access.inv, SOFTCLAY) < 1) { + chatNpc( + quiz, + "Do you have a lump of soft clay yet? Press it against Lady Keli's key while " + + "she shows it off.", + ) + return + } + chatPlayer(quiz, "I have a lump of soft clay. How do I get the impression?") + chatNpc( + neutral, + "Go to Lady Keli's hideout and ask to see her key — she loves to brag. Press the " + + "clay against it while she's holding it up. Come back to me with the print.", + ) + } + + private suspend fun Dialogue.leelaForgeKey() { + if (access.invTotal(access.inv, KEYPRINT) < 1) { + chatNpc( + quiz, + "You need to bring me the key impression first. Go and press a lump of soft " + + "clay against Lady Keli's key.", + ) + return + } + if (access.invTotal(access.inv, BRONZE_BAR) < 1) { + chatNpc( + quiz, + "I need a bronze bar to smith the copy. Bring one from the Al-Kharid furnace " + + "or the Grand Exchange.", + ) + return + } + chatPlayer(happy, "I have the impression and a bronze bar.") + access.invDel(access.inv, KEYPRINT, 1) + access.invDel(access.inv, BRONZE_BAR, 1) + mesbox( + "Leela presses the bronze bar against the clay impression and works it on a small " + + "furnace. The bronze cools into a working key." + ) + access.invAddOrDrop(objRepo, PRINCESKEY) + chatNpc( + happy, + "There you go. Take this princes' key to Prince Ali's cell. Don't forget the " + + "disguise pieces — and a beer for Joe.", + ) + access.advanceTo(STAGE_KEY_FORGED) + } + + private suspend fun Dialogue.leelaAwaitingRescue() { + chatNpc( + quiz, + "You have the key, friend. Go and free Prince Ali. Be wary of Joe — a beer should " + + "put him out for long enough.", + ) + } + + private suspend fun Dialogue.leelaComplete() { + chatNpc(happy, "Well done, friend. My father will be pleased.") + } + + // ──────────────────────────────────────────────────────────────────────── + // Lady Keli — her hideout. Bragging about her key drops the impression. + // ──────────────────────────────────────────────────────────────────────── + + private suspend fun ProtectedAccess.handleLadyKeli(npc: Npc) { + when { + quest.isQuestNotStarted(player) -> startDialogue(npc) { keliFlavour() } + quest.isQuestCompleted(player) -> startDialogue(npc) { keliPostQuest() } + else -> startDialogue(npc) { keliInProgress(quest.getQuestStage(player)) } + } + } + + private suspend fun Dialogue.keliFlavour() { + chatNpc( + angry, + "What do you want, peasant? I am Lady Keli, the greatest jailer in the land!", + ) + } + + private suspend fun Dialogue.keliInProgress(stage: Int) { + if (stage < STAGE_LEELA_BRIEFED) { + chatNpc( + angry, + "Go away, peasant. I am busy guarding the most important prisoner in Al-Kharid.", + ) + return + } + if (stage >= STAGE_KEY_IMPRESSED) { + chatNpc(angry, "Why do you keep coming back here? I've nothing to say to you.") + return + } + chatPlayer( + happy, + "I've heard you have the finest keys in all of Misthalin. Could I see one?", + ) + chatNpc( + happy, + "Ha! Of course. Behold — the only key to Prince Ali's cell. Forged from solid " + + "bronze, the work of the greatest smith in Al-Kharid!", + ) + if (access.invTotal(access.inv, SOFTCLAY) < 1) { + chatPlayer(sad, "Magnificent — though I don't have any soft clay to press it on.") + chatNpc( + neutral, + "Then stop wasting my time, peasant. Come back when you have something useful " + + "to do.", + ) + return + } + chatPlayer(neutral, "May I get a closer look?") + chatNpc(happy, "But of course. Behold!") + access.invDel(access.inv, SOFTCLAY, 1) + mesbox( + "While Lady Keli holds the key aloft you press the soft clay against it. A " + + "perfect impression of the key is left in the clay." + ) + access.invAddOrDrop(objRepo, KEYPRINT) + chatNpc(angry, "Right, that's enough. Be off with you.") + access.advanceTo(STAGE_KEY_IMPRESSED) + } + + private suspend fun Dialogue.keliPostQuest() { + chatNpc(angry, "You! You're the thief who freed the prince! I'll have your head for this!") + } + + // ──────────────────────────────────────────────────────────────────────── + // Joe — the jail-guard slacker. Takes a beer and naps. + // ──────────────────────────────────────────────────────────────────────── + + private suspend fun ProtectedAccess.handleJoe(npc: Npc) { + when { + quest.isQuestNotStarted(player) -> startDialogue(npc) { joeFlavour() } + quest.isQuestCompleted(player) -> startDialogue(npc) { joePostQuest() } + else -> startDialogue(npc) { joeInProgress(quest.getQuestStage(player)) } + } + } + + private suspend fun Dialogue.joeFlavour() { + chatNpc(neutral, "Get lost, traveller. I'm on duty.") + } + + private suspend fun Dialogue.joeInProgress(stage: Int) { + if (stage < STAGE_LEELA_BRIEFED) { + chatNpc(neutral, "Get lost, traveller. I'm on duty.") + return + } + chatPlayer(happy, "Hot work guarding the prince, isn't it? Care for a beer?") + if (access.invTotal(access.inv, BEER) < 1) { + chatNpc(sad, "Ha! Got any on you? I'm parched.") + return + } + chatNpc(happy, "Why thank you, friend. Don't mind if I do.") + access.invDel(access.inv, BEER, 1) + mesbox("Joe drains the beer in one gulp and slumps against the wall, snoring soundly.") + } + + private suspend fun Dialogue.joePostQuest() { + chatNpc(angry, "I'll get you back for that beer trick someday, traveller.") + } + + // ──────────────────────────────────────────────────────────────────────── + // Prince Ali — locked in the prison cell. Hands over disguise and completes. + // ──────────────────────────────────────────────────────────────────────── + + private suspend fun ProtectedAccess.handlePrinceAli(npc: Npc) { + when { + quest.isQuestNotStarted(player) -> startDialogue(npc) { aliFlavour() } + quest.isQuestCompleted(player) -> startDialogue(npc) { aliPostQuest() } + else -> startDialogue(npc) { aliInProgress(quest.getQuestStage(player)) } + } + } + + private suspend fun Dialogue.aliFlavour() { + chatNpc(sad, "Help me, please! I am Prince Ali of Al-Kharid!") + } + + private suspend fun Dialogue.aliInProgress(stage: Int) { + if (stage < STAGE_KEY_FORGED) { + chatNpc(sad, "I cannot escape without help. Please bring me a disguise!") + return + } + if (access.invTotal(access.inv, PRINCESKEY) < 1) { + chatNpc( + sad, + "I cannot escape without the key to my cell. Leela should be able to smith " + + "you one.", + ) + return + } + val missing = missingDisguisePieces(access) + if (missing.isNotEmpty()) { + chatNpc( + sad, + "I cannot walk out without a complete disguise. You still need: " + + missing.joinToString(", ") + + ".", + ) + return + } + chatPlayer(happy, "Quickly — put these on. I have a key to your cell.") + mesbox( + "Prince Ali dons the blond wig, smears the skin-paste over his face, and pulls on " + + "the pink skirt. He turns the key and slips out of the cell." + ) + access.invDel(access.inv, BLONDWIG, 1) + access.invDel(access.inv, SKINPASTE, 1) + access.invDel(access.inv, PINK_SKIRT, 1) + access.invDel(access.inv, PRINCESKEY, 1) + chatNpc( + happy, + "Thank you, friend! Tell my father and Osman that I am safe. Take this purse for " + + "your trouble.", + ) + access.advanceTo(STAGE_COMPLETE) + } + + private suspend fun Dialogue.aliPostQuest() { + chatNpc(happy, "Greetings, friend! Al-Kharid is forever in your debt.") + } + + /** Test-visible: drives Chancellor Hassan's Talk-to branch against a spawned [npc]. */ + public suspend fun talkToHassanForTest(access: ProtectedAccess, npc: Npc) { + access.handleHassan(npc) + } + + /** Test-visible: drives Osman's Talk-to branch against a spawned [npc]. */ + public suspend fun talkToOsmanForTest(access: ProtectedAccess, npc: Npc) { + access.handleOsman(npc) + } + + /** Test-visible: drives Leela's Talk-to branch against a spawned [npc]. */ + public suspend fun talkToLeelaForTest(access: ProtectedAccess, npc: Npc) { + access.handleLeela(npc) + } + + /** Test-visible: drives Lady Keli's Talk-to branch against a spawned [npc]. */ + public suspend fun talkToLadyKeliForTest(access: ProtectedAccess, npc: Npc) { + access.handleLadyKeli(npc) + } + + /** Test-visible: drives Joe's Talk-to branch against a spawned [npc]. */ + public suspend fun talkToJoeForTest(access: ProtectedAccess, npc: Npc) { + access.handleJoe(npc) + } + + /** Test-visible: drives Prince Ali's Talk-to branch against a spawned [npc]. */ + public suspend fun talkToPrinceAliForTest(access: ProtectedAccess, npc: Npc) { + access.handlePrinceAli(npc) + } + + private fun missingDisguisePieces(access: ProtectedAccess): List { + val missing = mutableListOf() + if (access.invTotal(access.inv, BLONDWIG) < 1) missing += "a blond wig" + if (access.invTotal(access.inv, SKINPASTE) < 1) missing += "skin-paste" + if (access.invTotal(access.inv, PINK_SKIRT) < 1) missing += "a pink skirt" + return missing + } + + /** + * Jumps the quest varp straight to [target]. + * + * The canonical `princequest` progression runs in steps of ten, so every advance is a jump of + * more than one stage; [org.rsmod.content.quest.manager.Quest.advanceQuestStage] takes a delta. + */ + private fun ProtectedAccess.advanceTo(target: Int) { + val current = quest.getQuestStage(player) + if (current >= target) { + return + } + quest.advanceQuestStage(this, target - current) + } + + override fun subTitle(): String = + "speaking to Chancellor Hassan in the Al-Kharid palace." + + override fun questLog(player: ProtectedAccess) = + questJournal(player) { + description( + "Prince Ali of Al-Kharid has been kidnapped by Lady Keli " + + "and is being held north of Draynor Village. Chancellor Hassan " + + "has asked me to bring him home." + ) { + hideWhenQuestCompleted() + } + + objective("I must speak to Osman by the Al-Kharid palace fountain.") { + stageAtLeast(STAGE_OSMAN_BRIEFED, "Osman gave me the plan for the rescue.") + .strike() + .finalise() + } + + objective("I must find Leela, south of the Draynor wheat fields.") { + stageAtLeast(STAGE_LEELA_BRIEFED, "Leela briefed me on the whole operation.") + .strike() + .finalise() + } + + objective( + "I must press a lump of soft clay against Lady Keli's key while she " + + "brags about it." + ) { + stageAtLeast(STAGE_KEY_IMPRESSED, "I have an impression of Lady Keli's key.") + .strike() + .finalise() + } + + objective( + "I must take the key impression and a bronze bar back to Leela so she " + + "can smith a copy." + ) { + stageAtLeast(STAGE_KEY_FORGED, "Leela smithed me a working princes' key.") + .strike() + .finalise() + } + + objective( + "I must gather Prince Ali's disguise — a blond wig, " + + "skin-paste and a pink skirt — distract Joe " + + "with a beer, and unlock the cell." + ) { + visibleWhen { quest.getQuestStage(access.player) >= STAGE_KEY_FORGED } + } + } + + override fun completedLog(player: ProtectedAccess) = + completionJournal(player) { + line( + "Chancellor Hassan and his spymaster Osman asked me to free Prince Ali from Lady Keli's hideout north of Draynor Village." + ) + line( + "I took an impression of Lady Keli's cell key in soft clay, and Osman's daughter Leela smithed me a copy from a bronze bar." + ) + line( + "With Joe the guard snoring off a beer, I dressed the prince in a blond wig, skin-paste and a pink skirt and walked him out of the cell." + ) + line("Al-Kharid now lets me through its toll gate for free.") + } + + public companion object { + // ── Stage values. Canonical `dbrow.quest_princealirescue` endstate = 110. ── + public const val STAGE_TALKED_TO_HASSAN: Int = 10 + public const val STAGE_OSMAN_BRIEFED: Int = 20 + public const val STAGE_LEELA_BRIEFED: Int = 30 + public const val STAGE_KEY_IMPRESSED: Int = 50 + public const val STAGE_KEY_FORGED: Int = 70 + public const val STAGE_COMPLETE: Int = 110 + + /** OSRS-canonical coin reward on completion (3 quest points come from the cache row). */ + public const val COIN_REWARD: Int = 700 + + public const val HASSAN: String = "npc.hassan" + public const val OSMAN: String = "npc.osman" + public const val LEELA: String = "npc.leela" + public const val LADY_KELI: String = "npc.lady_keli" + public const val JOE: String = "npc.joe" + public const val PRINCE_ALI: String = "npc.prince_ali_prison" + + public const val COINS: String = "obj.coins" + public const val SOFTCLAY: String = "obj.softclay" + public const val KEYPRINT: String = "obj.keyprint" + public const val BRONZE_BAR: String = "obj.bronze_bar" + public const val PRINCESKEY: String = "obj.princeskey" + public const val BLONDWIG: String = "obj.blondwig" + public const val SKINPASTE: String = "obj.skinpaste" + public const val PINK_SKIRT: String = "obj.pink_skirt" + public const val BEER: String = "obj.beer" + } +}