diff --git a/game/plugin/locations/al-kharid/src/shops.plugin.kts b/game/plugin/locations/al-kharid/src/shops.plugin.kts index ec55c37a..2d51292b 100644 --- a/game/plugin/locations/al-kharid/src/shops.plugin.kts +++ b/game/plugin/locations/al-kharid/src/shops.plugin.kts @@ -1,6 +1,6 @@ package org.apollo.plugin.locations.alKharid -import org.apollo.game.plugin.shops.shop +import org.apollo.game.plugin.shops.builder.shop shop("Al-Kharid General Store") { operated by "Shop keeper"(524) and "Shop assistant"(525) diff --git a/game/plugin/locations/edgeville/src/shops.plugin.kts b/game/plugin/locations/edgeville/src/shops.plugin.kts index cb43fa59..0a5facec 100644 --- a/game/plugin/locations/edgeville/src/shops.plugin.kts +++ b/game/plugin/locations/edgeville/src/shops.plugin.kts @@ -1,6 +1,6 @@ package org.apollo.plugin.locations.edgeville -import org.apollo.game.plugin.shops.shop +import org.apollo.game.plugin.shops.builder.shop shop("Edgeville General Store") { operated by "Shop keeper"(528) and "Shop assistant"(529) diff --git a/game/plugin/locations/falador/src/shops.plugin.kts b/game/plugin/locations/falador/src/shops.plugin.kts index c79007e6..5b7446fa 100644 --- a/game/plugin/locations/falador/src/shops.plugin.kts +++ b/game/plugin/locations/falador/src/shops.plugin.kts @@ -1,6 +1,6 @@ package org.apollo.plugin.locations.falador -import org.apollo.game.plugin.shops.shop +import org.apollo.game.plugin.shops.builder.shop shop("Falador General Store") { operated by "Shop keeper"(524) and "Shop assistant"( 525) diff --git a/game/plugin/locations/lumbridge/src/shops.plugin.kts b/game/plugin/locations/lumbridge/src/shops.plugin.kts index e67a4c8a..942bb62d 100644 --- a/game/plugin/locations/lumbridge/src/shops.plugin.kts +++ b/game/plugin/locations/lumbridge/src/shops.plugin.kts @@ -1,6 +1,6 @@ package org.apollo.plugin.locations.lumbridge -import org.apollo.game.plugin.shops.shop +import org.apollo.game.plugin.shops.builder.shop shop("Lumbridge General Store") { operated by "Shop keeper" and "Shop assistant" diff --git a/game/plugin/locations/varrock/src/shops.plugin.kts b/game/plugin/locations/varrock/src/shops.plugin.kts index 4ee6c7e6..cc3d1488 100644 --- a/game/plugin/locations/varrock/src/shops.plugin.kts +++ b/game/plugin/locations/varrock/src/shops.plugin.kts @@ -1,6 +1,6 @@ package org.apollo.plugin.locations.varrock -import org.apollo.game.plugin.shops.shop +import org.apollo.game.plugin.shops.builder.shop shop("Aubury's Rune Shop.") { operated by "Aubury" diff --git a/game/plugin/shops/src/dsl.kt b/game/plugin/shops/src/dsl.kt deleted file mode 100644 index 7974ff6d..00000000 --- a/game/plugin/shops/src/dsl.kt +++ /dev/null @@ -1,348 +0,0 @@ -package org.apollo.game.plugin.shops - -import org.apollo.cache.def.NpcDefinition -import org.apollo.game.plugin.api.Definitions -import org.apollo.game.plugin.shops.CategoryWrapper.Affix - -/** - * Creates a [Shop]. - * - * @param name The name of the shop. - */ -fun shop(name: String, builder: ShopBuilder.() -> Unit) { - val shop = ShopBuilder(name) - builder(shop) - - val built = shop.build() - val operators = shop.operators().map { it to built }.toMap() - - SHOPS.putAll(operators) -} - -/** - * A [DslMarker] for the shop DSL. - */ -@DslMarker -annotation class ShopDslMarker - -/** - * A builder for a [Shop]. - */ -@ShopDslMarker -class ShopBuilder(val name: String) { - - /** - * Overloads function invokation on strings to map `"ambiguous_npc_name"(id)` to a [Pair]. - */ - operator fun String.invoke(id: Int): Pair = Pair(this, id) - - /** - * Adds a sequence of items to this Shop, grouped together (in the DSL) for convenience. Items will be displayed - * in the same order they are provided. - * - * @param name The name of the category. - * @param affix The method of affixation between the item and category name (see [Affix]). - * @param depluralise Whether or not the category name should have the "s". - * @param builder The builder used to add items to the category. - */ - fun category( - name: String, - affix: Affix = Affix.Suffix, - depluralise: Boolean = true, - builder: CategoryWrapper.() -> Unit - ) { - val items = mutableListOf>() - builder.invoke(CategoryWrapper(items)) - - val category = when { - depluralise -> name.removeSuffix("s") - else -> name - } - - val affixed = items.map { (name, amount) -> Pair(affix.join(name, category), amount) } - sold.addAll(affixed) - } - - /** - * Creates a [SellBuilder] with the specified [amount]. - */ - fun sell(amount: Int): SellBuilder = SellBuilder(amount, sold) - - /** - * The id on the operator npc's action menu used to open the shop. - */ - val action = ActionBuilder() - - /** - * The type of [Currency] the [Shop] makes exchanges with. - */ - var trades = CurrencyBuilder() - - /** - * The [Shop]'s policy towards purchasing items from players. - */ - var buys = PurchasesBuilder() - - /** - * Redundant variable used only to complete the [PurchasesBuilder] (e.g. `buys no items`). - */ - val items = Unit - - /** - * Places the category name before the item name (inserting a space between the names). - */ - val prefix = Affix.Prefix - - /** - * Prevents the category name from being joined to the item name in any way. - */ - val nothing = Affix.None - - /** - * The [OperatorBuilder] used to collate the [Shop]'s operators. - */ - val operated = OperatorBuilder() - - /** - * The [List] of items sold by the shop, as (name, amount) [Pair]s. - */ - private val sold = mutableListOf>() - - /** - * Converts this builder into a [Shop]. - */ - internal fun build(): Shop { - val items = sold.associateBy({ (first) -> Definitions.item(first)!!.id }, Pair::second) - val npc = NpcDefinition.lookup(operators().first()) - - return Shop(name, action.action(npc), items, trades.currency, buys.policy) - } - - /** - * Gets the [List] of shop operator ids. - */ - internal fun operators(): MutableList = operated.operators -} - -@ShopDslMarker -class CategoryWrapper(private val items: MutableList>) { - - /** - * The method of joining the item and category name. - */ - sealed class Affix(private val joiner: (item: String, category: String) -> String) { - - /** - * Appends the category after the item name (with a space between). - */ - object Suffix : Affix({ item, affix -> "$item $affix" }) - - /** - * Prepends the category before the item name (with a space between). - */ - object Prefix : Affix({ item, affix -> "$affix $item" }) - - /** - * Does not join the category at all (i.e. only returns the item name). - */ - object None : Affix({ item, _ -> item }) - - /** - * Joins the item and category name in the expected manner. - */ - fun join(item: String, category: String): String = joiner(item, category) - } - - /** - * Creates a [SellBuilder] with the specified [amount]. - */ - fun sell(amount: Int): SellBuilder = SellBuilder(amount, items) -} - -/** - * A builder to provide the list of shop operators - the npcs that can be interacted with to access the shop. - */ -@ShopDslMarker -class OperatorBuilder internal constructor() { - - /** - * The [List] of shop operators. - */ - val operators: MutableList = mutableListOf() - - /** - * Adds a shop operator, using the specified [name] to resolve the npc id. - */ - infix fun by(name: String): OperatorBuilder { - operators.add(Definitions.npc(name)!!.id) - return this - } - - /** - * Adds a shop operator, using the specified [name] to resolve the npc id. - */ - infix fun and(name: String): OperatorBuilder = by(name) - - /** - * Adds a shop operator, using the specified [name] to resolve the npc id. - */ - operator fun plus(name: String): OperatorBuilder = and(name) - - /** - * Adds a shop operator with the specified npc id. Intended to be used with the overloaded String invokation - * operator, solely to disambiguate between npcs with the same name (e.g. - * `"Shopkeeper"(500) vs `"Shopkeeper"(501)`). Use [by(String][by] if the npc name is unambiguous. - */ - infix fun by(pair: Pair): OperatorBuilder { - operators.add(pair.second) - return this - } - - /** - * Adds a shop operator with the specified npc id. Intended to be used with the overloaded String invokation - * operator, solely to disambiguate between npcs with the same name (e.g. - * `"Shopkeeper"(500) vs `"Shopkeeper"(501)`). Use [by(String][by] if the npc name is unambiguous. - */ - infix fun and(pair: Pair): OperatorBuilder = by(pair) - - /** - * Adds a shop operator with the specified npc id. Intended to be used with the overloaded String invokation - * operator, solely to disambiguate between npcs with the same name (e.g. - * `"Shopkeeper"(500) vs `"Shopkeeper"(501)`). Use [by(String][by] if the npc name is unambiguous. - */ - operator fun plus(pair: Pair): OperatorBuilder = by(pair) -} - -/** - * A builder to provide the action id used to open the shop. - */ -@ShopDslMarker -class ActionBuilder { - - private var action: String = "Trade" - - private var actionId: Int? = null - - /** - * Sets the name or id of the action used to open the shop interface with an npc. Defaults to "Trade". - * - * If specifying an id it must account for hidden npc menu actions (if any exist) - if "Open Shop" is the first - * action displayed when the npc is right-clicked, it does not necessarily mean that the action id is `1`. - * - * @param action The `name` (as a [String]) or `id` (as an `Int`) of the npc's action menu, to open the shop. - * @throws IllegalArgumentException If `action` is not a [String] or [Int]. - */ - override fun equals(@Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") action: Any?): Boolean { - if (action is String) { - this.action = action - return true - } else if (action is Int) { - actionId = action - return true - } - - throw IllegalArgumentException("The Npc option must be provided as a String (the option name) or the ") - } - - /** - * Returns the open shop action slot. - * - * @throws IllegalArgumentException If the action id or name is invalid. - */ - internal fun action(npc: NpcDefinition): Int { - actionId?.let { action -> - if (npc.hasInteraction(action - 1)) { // ActionMessages are 1-based - return action - } - - throw IllegalArgumentException("Npc ${npc.name} does not have an an action $action.") - } - - val index = npc.interactions.indexOf(action) - when (index) { - -1 -> throw IllegalArgumentException("Npc ${npc.name} does not have an an action $action.") - else -> return index + 1 // ActionMessages are 1-based - } - } - - /** - * Throws [UnsupportedOperationException]. - */ - override fun hashCode(): Int = throw UnsupportedOperationException("ActionBuilder is a utility class for a DSL " + - "and improperly implements equals() - it should not be used anywhere outside of the DSL.") -} - -/** - * A builder to provide the currency used by the [Shop]. - */ -@ShopDslMarker -class CurrencyBuilder { - - internal var currency = Currency.COINS - - /** - * Overloads the `in` operator on [Currency] to achieve e.g. `trades in tokkul`. - */ - operator fun Currency.contains(builder: CurrencyBuilder): Boolean { - builder.currency = this - return true - } -} - -/** - * A builder to provide the [Shop.PurchasePolicy]. - */ -@ShopDslMarker -class PurchasesBuilder { - - internal var policy = Shop.PurchasePolicy.OWNED - - /** - * Instructs the shop to purchase no items, regardless of whether or not it sells it. - */ - infix fun no(@Suppress("UNUSED_PARAMETER") items: Unit) { - policy = Shop.PurchasePolicy.NOTHING - } - - /** - * Instructs the shop to purchase any tradeable item. - */ - infix fun any(@Suppress("UNUSED_PARAMETER") items: Unit) { - policy = Shop.PurchasePolicy.ANY - } -} - -/** - * A builder to provide the items to sell. - * - * @param amount The amount to sell (of each item). - * @param items The [MutableList] to insert the given items into. - */ -@ShopDslMarker -class SellBuilder(val amount: Int, val items: MutableList>) { - - infix fun of(lambda: SellBuilder.() -> Unit) = lambda.invoke(this) - - /** - * Provides an item with the specified name. - * - * @name The item name. Must be unambiguous. - */ - infix fun of(name: String) = items.add(Pair(name, amount)) - - /** - * Overloads unary minus on Strings so that item names can be listed. - */ - operator fun String.unaryMinus() = items.add(Pair(this, amount)) - - /** - * Overloads the unary minus on Pairs so that name+id pairs can be listed. Only intended to be used with the - * overloaded String invokation operator. - */ // ShopBuilder uses the lookup plugin, which can operate on _ids tacked on the end - operator fun Pair.unaryMinus() = items.add(Pair("${this.first}_${this.second}", amount)) - - /** - * Overloads function invokation on Strings to map `"ambiguous_npc_name"(id)` to a [Pair]. - */ - operator fun String.invoke(id: Int): Pair = Pair(this, id) -} diff --git a/game/plugin/shops/src/org/apollo/game/plugin/shops/Currency.kt b/game/plugin/shops/src/org/apollo/game/plugin/shops/Currency.kt new file mode 100644 index 00000000..6548f4d3 --- /dev/null +++ b/game/plugin/shops/src/org/apollo/game/plugin/shops/Currency.kt @@ -0,0 +1,26 @@ +package org.apollo.game.plugin.shops + +import org.apollo.game.plugin.api.Definitions + +/** + * A [Shop]'s method of payment. + * + * @param id The item id of the currency. + * @param plural Whether or not the name of this currency is plural. + */ +data class Currency(val id: Int, val plural: Boolean = false) { + + val name = requireNotNull(Definitions.item(id).name?.toLowerCase()) { "Currencies must have a name." } + + fun name(amount: Int): String { + return when { + amount == 1 && plural -> name.removeSuffix("s") + else -> name + } + } + + companion object { + val COINS = Currency(995, plural = true) + } + +} \ No newline at end of file diff --git a/game/plugin/shops/src/action.kt b/game/plugin/shops/src/org/apollo/game/plugin/shops/OpenShopAction.kt similarity index 68% rename from game/plugin/shops/src/action.kt rename to game/plugin/shops/src/org/apollo/game/plugin/shops/OpenShopAction.kt index 8f31158f..a87a3125 100644 --- a/game/plugin/shops/src/action.kt +++ b/game/plugin/shops/src/org/apollo/game/plugin/shops/OpenShopAction.kt @@ -1,7 +1,7 @@ package org.apollo.game.plugin.shops import org.apollo.game.action.DistancedAction -import org.apollo.game.message.handler.ItemVerificationHandler.InventorySupplier +import org.apollo.game.message.handler.ItemVerificationHandler import org.apollo.game.message.impl.SetWidgetTextMessage import org.apollo.game.model.entity.Mob import org.apollo.game.model.entity.Player @@ -15,16 +15,17 @@ import org.apollo.game.model.inv.SynchronizationInventoryListener class OpenShopAction( player: Player, private val shop: Shop, - val npc: Mob -) : DistancedAction(0, true, player, npc.position, 1) { // TODO this needs to follow the NPC if they move + private val operator: Mob +) : DistancedAction(0, true, player, operator.position, 1) { // TODO this needs to follow the NPC if they move override fun executeAction() { - mob.interactingMob = npc + mob.interactingMob = operator val closeListener = addInventoryListeners(mob, shop.inventory) - mob.send(SetWidgetTextMessage(Interfaces.SHOP_NAME, shop.name)) + mob.send(SetWidgetTextMessage(ShopInterfaces.SHOP_NAME, shop.name)) - mob.interfaceSet.openWindowWithSidebar(closeListener, Interfaces.SHOP_WINDOW, Interfaces.INVENTORY_SIDEBAR) + mob.interfaceSet.openWindowWithSidebar(closeListener, ShopInterfaces.SHOP_WINDOW, + ShopInterfaces.INVENTORY_SIDEBAR) stop() } @@ -33,8 +34,8 @@ class OpenShopAction( * [InterfaceListener] that removes them when the interface is closed. */ private fun addInventoryListeners(player: Player, shop: Inventory): InterfaceListener { - val invListener = SynchronizationInventoryListener(player, Interfaces.INVENTORY_CONTAINER) - val shopListener = SynchronizationInventoryListener(player, Interfaces.SHOP_CONTAINER) + val invListener = SynchronizationInventoryListener(player, ShopInterfaces.INVENTORY_CONTAINER) + val shopListener = SynchronizationInventoryListener(player, ShopInterfaces.SHOP_CONTAINER) player.inventory.addListener(invListener) player.inventory.forceRefresh() @@ -55,12 +56,14 @@ class OpenShopAction( /** * An [InventorySupplier] that returns a [Player]'s [Inventory] if they are browsing a shop. */ -class PlayerInventorySupplier : InventorySupplier { +object PlayerInventorySupplier : ItemVerificationHandler.InventorySupplier { override fun getInventory(player: Player): Inventory? { - return when { - player.interfaceSet.contains(Interfaces.SHOP_WINDOW) -> player.inventory - else -> null + return if (Interfaces.SHOP_WINDOW in player.interfaceSet) { + player.inventory + } else { + null } } + } \ No newline at end of file diff --git a/game/plugin/shops/src/shop.kt b/game/plugin/shops/src/org/apollo/game/plugin/shops/Shop.kt similarity index 93% rename from game/plugin/shops/src/shop.kt rename to game/plugin/shops/src/org/apollo/game/plugin/shops/Shop.kt index f5490906..08f9014c 100644 --- a/game/plugin/shops/src/shop.kt +++ b/game/plugin/shops/src/org/apollo/game/plugin/shops/Shop.kt @@ -47,35 +47,13 @@ object Interfaces { */ val SHOPS = mutableMapOf() -/** - * A [Shop]'s method of payment. - * - * @param id The item id of the currency. - * @param plural Whether or not the name of this currency is plural. - */ -data class Currency(val id: Int, val plural: Boolean = false) { - - companion object { - val COINS = Currency(995, plural = true) - } - - val name: String = ItemDefinition.lookup(id).name?.toLowerCase() - ?: throw IllegalArgumentException("Currencies must have a name.") - - fun name(amount: Int): String { - return when { - amount == 1 && plural -> name.removeSuffix("s") - else -> name - } - } -} - /** * An in-game shop, operated by one or more npcs. * * @param name The name of the shop. * @param action The id of the NpcActionMessage sent (by the client) when a player opens this shop. * @param sells The [Map] from item id to amount sold. + * @param operators The [List] of Npc ids that can open this shop. * @param currency The [Currency] used when making exchanges with this [Shop]. * @param purchases This [Shop]'s attitude towards purchasing items from players. */ @@ -83,68 +61,11 @@ class Shop( val name: String, val action: Int, private val sells: Map, + val operators: List, private val currency: Currency = Currency.COINS, private val purchases: PurchasePolicy = OWNED ) { - companion object { - - /** - * The amount of pulses between shop inventory restocking. - */ - const val RESTOCK_INTERVAL = 100 - - /** - * The capacity of a [Shop]. - */ - private const val CAPACITY = 30 - - /** - * The type of exchange occurring between the [Player] and [Shop]. - */ - private enum class ExchangeType { BUYING, SELLING } - - /** - * The option id for item valuation. - */ - private const val VALUATION_OPTION = 1 - - /** - * Returns the amount that a player tried to buy or sell. - * - * @param option The id of the option the player selected. - */ - private fun amount(option: Int): Int { - return when (option) { - 2 -> 1 - 3 -> 5 - 4 -> 10 - else -> throw IllegalArgumentException("Option must be 1-4") - } - } - } - - /** - * The [Shop]s policy regarding purchasing items from players. - */ - enum class PurchasePolicy { - - /** - * Never purchase anything from players. - */ - NOTHING, - - /** - * Only purchase items that this Shop sells by default. - */ - OWNED, - - /** - * Purchase any tradeable items. - */ - ANY - } - /** * The [Inventory] containing this [Shop]'s current items. */ @@ -183,7 +104,7 @@ class Shop( return } - var buying: Int = amount(option) + var buying = amount(option) var unavailable = false val amount = item.amount @@ -328,4 +249,64 @@ class Shop( * @param id The id of the [Item] to sell. */ private fun sells(id: Int): Boolean = sells.containsKey(id) + + /** + * The [Shop]s policy regarding purchasing items from players. + */ + enum class PurchasePolicy { + + /** + * Never purchase anything from players. + */ + NOTHING, + + /** + * Only purchase items that this Shop sells by default. + */ + OWNED, + + /** + * Purchase any tradeable items. + */ + ANY + } + + companion object { + + /** + * The amount of pulses between shop inventory restocking. + */ + const val RESTOCK_INTERVAL = 100 + + /** + * The capacity of a [Shop]. + */ + private const val CAPACITY = 30 + + /** + * The type of exchange occurring between the [Player] and [Shop]. + */ + private enum class ExchangeType { BUYING, SELLING } + + /** + * The option id for item valuation. + */ + private const val VALUATION_OPTION = 1 + + /** + * Returns the amount that a player tried to buy or sell. + * + * @param option The id of the option the player selected. + */ + private fun amount(option: Int): Int { + return when (option) { + 2 -> 1 + 3 -> 5 + 4 -> 10 + else -> throw IllegalArgumentException("Option must be 1-4") + } + } + + } + } \ No newline at end of file diff --git a/game/plugin/shops/src/org/apollo/game/plugin/shops/ShopInterfaces.kt b/game/plugin/shops/src/org/apollo/game/plugin/shops/ShopInterfaces.kt new file mode 100644 index 00000000..4a678bd8 --- /dev/null +++ b/game/plugin/shops/src/org/apollo/game/plugin/shops/ShopInterfaces.kt @@ -0,0 +1,33 @@ +package org.apollo.game.plugin.shops + +/** + * Contains shop-related interface ids. + */ +internal object ShopInterfaces { + + /** + * The container interface id for the player's inventory. + */ + const val INVENTORY_CONTAINER = 3823 + + /** + * The sidebar id for the inventory, when a Shop window is open. + */ + const val INVENTORY_SIDEBAR = 3822 + + /** + * The shop window interface id. + */ + const val SHOP_WINDOW = 3824 + + /** + * The container interface id for the shop's inventory. + */ + const val SHOP_CONTAINER = 3900 + + /** + * The id of the text widget that displays a shop's name. + */ + const val SHOP_NAME = 3901 + +} \ No newline at end of file diff --git a/game/plugin/shops/src/org/apollo/game/plugin/shops/Shops.plugin.kts b/game/plugin/shops/src/org/apollo/game/plugin/shops/Shops.plugin.kts new file mode 100644 index 00000000..4d3be298 --- /dev/null +++ b/game/plugin/shops/src/org/apollo/game/plugin/shops/Shops.plugin.kts @@ -0,0 +1,47 @@ +package org.apollo.game.plugin.shops + +import org.apollo.game.message.handler.ItemVerificationHandler +import org.apollo.game.message.impl.ItemActionMessage +import org.apollo.game.message.impl.NpcActionMessage +import org.apollo.game.model.entity.Mob +import org.apollo.game.scheduling.ScheduledTask + +fun Mob.shop(): Shop? = SHOPS[definition.id] + +start { world -> + ItemVerificationHandler.addInventory(ShopInterfaces.SHOP_CONTAINER) { it.interactingMob?.shop()?.inventory } + ItemVerificationHandler.addInventory(ShopInterfaces.INVENTORY_CONTAINER, PlayerInventorySupplier) + + world.schedule(object : ScheduledTask(Shop.RESTOCK_INTERVAL, false) { + override fun execute() = SHOPS.values.distinct().forEach(Shop::restock) + }) +} + +on { NpcActionMessage::class } + .then { player -> + val npc = player.world.npcRepository.get(index) + val shop = npc.shop() ?: return@then + + if (shop.action == option) { + player.startAction(OpenShopAction(player, shop, npc)) + terminate() + } + } + + +on { ItemActionMessage::class } + .where { interfaceId == ShopInterfaces.SHOP_CONTAINER || interfaceId == ShopInterfaces.INVENTORY_CONTAINER } + .then { player -> + if (ShopInterfaces.SHOP_WINDOW !in player.interfaceSet) { + return@then + } + + val shop = player.interactingMob?.shop() ?: return@then + when (interfaceId) { + ShopInterfaces.INVENTORY_CONTAINER -> shop.buy(player, slot, option) + ShopInterfaces.SHOP_CONTAINER -> shop.sell(player, slot, option) + else -> error("Supposedly unreacheable case.") + } + + terminate() + } diff --git a/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/ActionBuilder.kt b/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/ActionBuilder.kt new file mode 100644 index 00000000..bfd251a1 --- /dev/null +++ b/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/ActionBuilder.kt @@ -0,0 +1,62 @@ +package org.apollo.game.plugin.shops.builder + +import org.apollo.cache.def.NpcDefinition + +/** + * A builder to provide the action id used to open the shop. + */ +@ShopDslMarker +class ActionBuilder { + + private var action: String = "Trade" + + private var actionId: Int? = null + + /** + * Sets the name or id of the action used to open the shop interface with an npc. Defaults to "Trade". + * + * If specifying an id it must account for hidden npc menu actions (if any exist) - if "Open Shop" is the first + * action displayed when the npc is right-clicked, it does not necessarily mean that the action id is `1`. + * + * @param action The `name` (as a [String]) or `id` (as an `Int`) of the npc's action menu, to open the shop. + * @throws IllegalArgumentException If `action` is not a [String] or [Int]. + */ // TODO this is dumb, replace it + override fun equals(@Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") action: Any?): Boolean { + if (action is String) { + this.action = action + return true + } else if (action is Int) { + actionId = action + return true + } + + throw IllegalArgumentException("The Npc option must be provided as a String (the option name) or an Int (the option index)\"") + } + + /** + * Returns the open shop action slot. + * + * @throws IllegalArgumentException If the action id or name is invalid. + */ + internal fun slot(npc: NpcDefinition): Int { + actionId?.let { action -> + require(npc.hasInteraction(action - 1)) { + "Npc ${npc.name} does not have an an action $action." // action - 1 because ActionMessages are 1-based + } + + return action + } + + val index = npc.interactions.indexOf(action) + require(index != -1) { "Npc ${npc.name} does not have an an action $action." } + + return index + 1 // ActionMessages are 1-based + } + + /** + * Throws [UnsupportedOperationException]. + */ + override fun hashCode(): Int = throw UnsupportedOperationException("ActionBuilder is a utility class for a DSL " + + "and improperly implements equals() - it should not be used anywhere outside of the DSL.") + +} \ No newline at end of file diff --git a/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/CategoryBuilder.kt b/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/CategoryBuilder.kt new file mode 100644 index 00000000..5f10c470 --- /dev/null +++ b/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/CategoryBuilder.kt @@ -0,0 +1,59 @@ +package org.apollo.game.plugin.shops.builder + +/** + * A builder for a category - a collection of sold items that share a common prefix or suffix. + * + * ``` + * category("mould") { + * sell(10) of "Ring" + * sell(2) of "Necklace" + * sell(10) of "Amulet" + * } + * ``` + */ +@ShopDslMarker +class CategoryBuilder { + + /** + * The items that this shop sells, as a pair of item name to amount sold. + */ + private val items = mutableListOf>() + + /** + * Creates a [SellBuilder] with the specified [amount]. + */ + fun sell(amount: Int): SellBuilder = SellBuilder(amount, items) + + /** + * Builds this category into a list of sold items, represented as a pair of item name to amount sold. + */ + fun build(): List> = items + + /** + * The method of joining the item and category name. + */ + sealed class Affix(private val joiner: (item: String, category: String) -> String) { + + /** + * Appends the category after the item name (with a space between). + */ + object Suffix : Affix({ item, affix -> "$item $affix" }) + + /** + * Prepends the category before the item name (with a space between). + */ + object Prefix : Affix({ item, affix -> "$affix $item" }) + + /** + * Does not join the category at all (i.e. only returns the item name). + */ + object None : Affix({ item, _ -> item }) + + /** + * Joins the item and category name in the expected manner. + */ + fun join(item: String, category: String): String = joiner(item, category) + + } + +} \ No newline at end of file diff --git a/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/CurrencyBuilder.kt b/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/CurrencyBuilder.kt new file mode 100644 index 00000000..f4d81224 --- /dev/null +++ b/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/CurrencyBuilder.kt @@ -0,0 +1,25 @@ +package org.apollo.game.plugin.shops.builder + +import org.apollo.game.plugin.shops.Currency + +/** + * A builder to provide the currency used by the [Shop]. + */ +@ShopDslMarker +class CurrencyBuilder { + + private var currency = Currency.COINS + + /** + * Overloads the `in` operator on [Currency] to achieve e.g. `trades in tokkul`. + * + * This function violates the contract for the `in` operator and is only to be used inside the Shops DSL. + */ + operator fun Currency.contains(builder: CurrencyBuilder): Boolean { + builder.currency = this + return true + } + + fun build(): Currency = currency + +} \ No newline at end of file diff --git a/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/OperatorBuilder.kt b/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/OperatorBuilder.kt new file mode 100644 index 00000000..7585cbbb --- /dev/null +++ b/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/OperatorBuilder.kt @@ -0,0 +1,82 @@ +package org.apollo.game.plugin.shops.builder + +import org.apollo.game.plugin.api.Definitions + +/** + * A builder to provide the list of shop operators - the npcs that can be interacted with to access the shop. + * + * ``` + * shop("General Store.") { + * operated by "Shopkeeper"(522) and "Shop assistant"(523) and "Shop assistant"(524) + * ... + * } + * ``` + */ +@ShopDslMarker +class OperatorBuilder internal constructor(private val shopName: String) { + + /** + * The [List] of shop operator ids. + */ + private val operators = mutableListOf() + + /** + * Adds a shop operator, using the specified [name] to resolve the npc id. + */ + infix fun by(name: String): OperatorBuilder { + val npc = requireNotNull(Definitions.npc(name)) { + "Failed to resolve npc named `$name` when building shop $shopName." + } + + operators += npc.id + return this + } + + /** + * Adds a shop operator, using the specified [name] to resolve the npc id. + * + * An alias for [by]. + */ + infix fun and(name: String): OperatorBuilder = by(name) + + /** + * Adds a shop operator, using the specified [name] to resolve the npc id. + * + * An alias for [by]. + */ + operator fun plus(name: String): OperatorBuilder = and(name) + + /** + * Adds a shop operator with the specified npc id. Intended to be used with the overloaded String invokation + * operator, solely to disambiguate between npcs with the same name (e.g. `"Shopkeeper"(500) vs + * `"Shopkeeper"(501)`). Use [by(String)][by] if the npc name is unambiguous. + */ + infix fun by(pair: Pair): OperatorBuilder { + operators += pair.second + return this + } + + /** + * Adds a shop operator with the specified npc id. Intended to be used with the overloaded String invokation + * operator, solely to disambiguate between npcs with the same name (e.g. `"Shopkeeper"(500) vs + * `"Shopkeeper"(501)`). Use [by(String)][by] if the npc name is unambiguous. + * + * An alias for [by(Pair)][by]. + */ + infix fun and(pair: Pair): OperatorBuilder = by(pair) + + /** + * Adds a shop operator with the specified npc id. Intended to be used with the overloaded String invokation + * operator, solely to disambiguate between npcs with the same name (e.g. `"Shopkeeper"(500) vs + * `"Shopkeeper"(501)`). Use [by(String)][by] if the npc name is unambiguous. + * + * An alias for [by(Pair)][by]. + */ + operator fun plus(pair: Pair): OperatorBuilder = by(pair) + + /** + * Builds this [OperatorBuilder] into a [List] of operator npc ids. + */ + fun build(): List = operators + +} \ No newline at end of file diff --git a/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/PurchasesBuilder.kt b/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/PurchasesBuilder.kt new file mode 100644 index 00000000..9adfa8de --- /dev/null +++ b/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/PurchasesBuilder.kt @@ -0,0 +1,29 @@ +package org.apollo.game.plugin.shops.builder + +import org.apollo.game.plugin.shops.Shop + +/** + * A builder to provide the [Shop.PurchasePolicy]. + */ +@ShopDslMarker +class PurchasesBuilder { + + private var policy = Shop.PurchasePolicy.OWNED + + /** + * Instructs the shop to purchase no items, regardless of whether or not it sells it. + */ + infix fun no(@Suppress("UNUSED_PARAMETER") items: Unit) { + policy = Shop.PurchasePolicy.NOTHING + } + + /** + * Instructs the shop to purchase any tradeable item. + */ + infix fun any(@Suppress("UNUSED_PARAMETER") items: Unit) { + policy = Shop.PurchasePolicy.ANY + } + + fun build(): Shop.PurchasePolicy = policy + +} \ No newline at end of file diff --git a/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/SellBuilder.kt b/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/SellBuilder.kt new file mode 100644 index 00000000..fe955056 --- /dev/null +++ b/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/SellBuilder.kt @@ -0,0 +1,43 @@ +package org.apollo.game.plugin.shops.builder + +/** + * A builder to provide the items to sell. + * + * @param amount The amount to sell (of each item). + * @param items The [MutableList] to insert the given items into. + */ +@ShopDslMarker +class SellBuilder(val amount: Int, val items: MutableList>) { + + infix fun of(lambda: SellBuilder.() -> Unit) = lambda(this) + + /** + * Provides an item with the specified name. + * + * @name The item name. Must be unambiguous. + */ + infix fun of(name: String) { + items += Pair(name, amount) + } + + /** + * Overloads unary minus on Strings so that item names can be listed. + */ + operator fun String.unaryMinus() { + of(this) + } + + /** + * Overloads the unary minus on Pairs so that name+id pairs can be listed. Only intended to be used with the + * overloaded String invokation operator. + */ // ShopBuilder uses the lookup plugin, which can operate on _ids tacked on the end + operator fun Pair.unaryMinus() { + items += Pair("${first}_$second", amount) + } + + /** + * Overloads function invokation on Strings to map `"ambiguous_npc_name"(id)` to a [Pair]. + */ + operator fun String.invoke(id: Int): Pair = Pair(this, id) + +} \ No newline at end of file diff --git a/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/ShopBuilder.kt b/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/ShopBuilder.kt new file mode 100644 index 00000000..00b6b3b9 --- /dev/null +++ b/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/ShopBuilder.kt @@ -0,0 +1,117 @@ +package org.apollo.game.plugin.shops.builder + +import org.apollo.cache.def.NpcDefinition +import org.apollo.game.plugin.api.Definitions +import org.apollo.game.plugin.shops.Currency +import org.apollo.game.plugin.shops.SHOPS +import org.apollo.game.plugin.shops.Shop +import org.apollo.game.plugin.shops.builder.CategoryBuilder.Affix + +/** + * Creates a [Shop]. + * + * @param name The name of the shop. + */ +fun shop(name: String, builder: ShopBuilder.() -> Unit) { + val shop = ShopBuilder(name).apply(builder).build() + + shop.operators.associateByTo(SHOPS, { it }, { shop }) +} + +/** + * A builder for a [Shop]. + */ +@ShopDslMarker +class ShopBuilder(val name: String) { + + /** + * The id on the operator npc's action menu used to open the shop. + */ + val action = ActionBuilder() + + /** + * The type of [Currency] the [Shop] makes exchanges with. + */ + var trades = CurrencyBuilder() + + /** + * The [OperatorBuilder] used to collate the [Shop]'s operators. + */ + val operated = OperatorBuilder(name) + + /** + * The [Shop]'s policy towards purchasing items from players. + */ + var buys = PurchasesBuilder() + + /** + * Redundant variable used in the purchases dsl, to complete the [PurchasesBuilder] (e.g. `buys no items`). + */ + val items = Unit + + /** + * Used in the category dsl. Places the category name before the item name (inserting a space between the names). + */ + val prefix = Affix.Prefix + + /** + * Used in the category dsl. Prevents the category name from being joined to the item name in any way. + */ + val nothing = Affix.None + + /** + * The [List] of items sold by the shop, as (name, amount) [Pair]s. + */ + private val sold = mutableListOf>() + + /** + * Overloads function invokation on strings to map `"ambiguous_npc_name"(id)` to a [Pair]. + */ + operator fun String.invoke(id: Int): Pair = Pair(this, id) + + /** + * Adds a sequence of items to this Shop, grouped together (in the DSL) for convenience. Items will be displayed + * in the same order they are provided. + * + * @param name The name of the category. + * @param affix The method of affixation between the item and category name (see [Affix]). + * @param depluralise Whether or not the category name should have the "s". + * @param builder The builder that adds items to the category. + */ + fun category( + name: String, + affix: Affix = Affix.Suffix, + depluralise: Boolean = true, // TODO search for both with and without plural + builder: CategoryBuilder.() -> Unit + ) { + val items = CategoryBuilder().apply(builder).build() + + val category = when { + depluralise -> name.removeSuffix("s") + else -> name + } + + sold += items.map { (name, amount) -> Pair(affix.join(name, category), amount) } + } + + /** + * Creates a [SellBuilder] with the specified [amount]. + */ + fun sell(amount: Int): SellBuilder = SellBuilder(amount, sold) + + /** + * Converts this builder into a [Shop]. + */ + internal fun build(): Shop { + val operators = operated.build() + val npc = NpcDefinition.lookup(operators.first()) + + val items = sold.associateBy( + { requireNotNull(Definitions.item(it.first)?.id) { "Failed to find item ${it.first} in shop $name." } }, + { it.second } + ) + + return Shop(name, action.slot(npc), items, operators, trades.build(), buys.build()) + } + +} diff --git a/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/ShopDslMarker.kt b/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/ShopDslMarker.kt new file mode 100644 index 00000000..3187a23b --- /dev/null +++ b/game/plugin/shops/src/org/apollo/game/plugin/shops/builder/ShopDslMarker.kt @@ -0,0 +1,7 @@ +package org.apollo.game.plugin.shops.builder + +/** + * A [DslMarker] for the shop DSL. + */ +@DslMarker +internal annotation class ShopDslMarker \ No newline at end of file diff --git a/game/plugin/shops/src/shop.plugin.kts b/game/plugin/shops/src/shop.plugin.kts deleted file mode 100644 index 644e96c3..00000000 --- a/game/plugin/shops/src/shop.plugin.kts +++ /dev/null @@ -1,50 +0,0 @@ -import org.apollo.game.message.handler.ItemVerificationHandler -import org.apollo.game.message.impl.ItemActionMessage -import org.apollo.game.message.impl.NpcActionMessage -import org.apollo.game.model.entity.Mob -import org.apollo.game.plugin.shops.Interfaces -import org.apollo.game.plugin.shops.OpenShopAction -import org.apollo.game.plugin.shops.PlayerInventorySupplier -import org.apollo.game.plugin.shops.SHOPS -import org.apollo.game.plugin.shops.Shop -import org.apollo.game.scheduling.ScheduledTask - -fun Mob.shop(): Shop? = SHOPS[definition.id] - -start { - ItemVerificationHandler.addInventory(Interfaces.SHOP_CONTAINER) { it.interactingMob?.shop()?.inventory } - ItemVerificationHandler.addInventory(Interfaces.INVENTORY_CONTAINER, PlayerInventorySupplier()) - - it.schedule(object : ScheduledTask(Shop.RESTOCK_INTERVAL, false) { - override fun execute() = SHOPS.values.distinct().forEach(Shop::restock) - }) -} - -on { NpcActionMessage::class } - .then { - val npc = it.world.npcRepository.get(index) - val shop = npc.shop() ?: return@then - - if (shop.action == option) { - it.startAction(OpenShopAction(it, shop, npc)) - terminate() - } - } - - -on { ItemActionMessage::class } - .where { interfaceId == Interfaces.SHOP_CONTAINER || interfaceId == Interfaces.INVENTORY_CONTAINER } - .then { - if (!it.interfaceSet.contains(Interfaces.SHOP_WINDOW)) { - return@then - } - - val shop = it.interactingMob?.shop() ?: return@then - when (interfaceId) { - Interfaces.INVENTORY_CONTAINER -> shop.buy(it, slot, option) - Interfaces.SHOP_CONTAINER -> shop.sell(it, slot, option) - else -> throw IllegalStateException("Supposedly unreacheable case.") - } - - terminate() - } \ No newline at end of file diff --git a/game/plugin/shops/test/org/apollo/game/plugin/shops/CurrencyTests.kt b/game/plugin/shops/test/org/apollo/game/plugin/shops/CurrencyTests.kt new file mode 100644 index 00000000..de897d0c --- /dev/null +++ b/game/plugin/shops/test/org/apollo/game/plugin/shops/CurrencyTests.kt @@ -0,0 +1,27 @@ +package org.apollo.game.plugin.shops + +import org.apollo.cache.def.ItemDefinition +import org.apollo.game.plugin.testing.junit.ApolloTestingExtension +import org.apollo.game.plugin.testing.junit.api.annotations.ItemDefinitions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(ApolloTestingExtension::class) +class CurrencyTests { + + @Test + fun `items used as currencies must have names in their definitions`() { + assertThrows("Should not be able to create a Currency with an item missing a name") { + Currency(id = ITEM_MISSING_NAME) + } + } + + private companion object { + private const val ITEM_MISSING_NAME = 0 + + @ItemDefinitions + private val unnamed = listOf(ItemDefinition(ITEM_MISSING_NAME)) + } + +} \ No newline at end of file diff --git a/game/plugin/shops/test/org/apollo/game/plugin/shops/ShopActionTests.kt b/game/plugin/shops/test/org/apollo/game/plugin/shops/ShopActionTests.kt new file mode 100644 index 00000000..643fcd5f --- /dev/null +++ b/game/plugin/shops/test/org/apollo/game/plugin/shops/ShopActionTests.kt @@ -0,0 +1,21 @@ +package org.apollo.game.plugin.shops + +import org.apollo.game.model.entity.Player +import org.apollo.game.plugin.testing.junit.ApolloTestingExtension +import org.apollo.game.plugin.testing.junit.api.ActionCapture +import org.apollo.game.plugin.testing.junit.api.annotations.TestMock +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(ApolloTestingExtension::class) +class ShopActionTests { + + @TestMock + lateinit var player: Player + + @TestMock + lateinit var action: ActionCapture + + + + +} \ No newline at end of file