From 52c25946b930783d422393d0d5451b07d6b8911a Mon Sep 17 00:00:00 2001 From: Major Date: Sat, 23 Sep 2017 04:12:08 +0100 Subject: [PATCH] Add shops plugin Thanks to tlf30 for a lot of work on this. --- game/plugin/shops/build.gradle | 12 + game/plugin/shops/src/action.kt | 66 ++++ game/plugin/shops/src/dls.kt | 350 ++++++++++++++++++ game/plugin/shops/src/shop.kt | 333 +++++++++++++++++ game/plugin/shops/src/shop.plugin.kts | 46 +++ .../handler/ItemVerificationHandler.java | 2 +- 6 files changed, 808 insertions(+), 1 deletion(-) create mode 100644 game/plugin/shops/build.gradle create mode 100644 game/plugin/shops/src/action.kt create mode 100644 game/plugin/shops/src/dls.kt create mode 100644 game/plugin/shops/src/shop.kt create mode 100644 game/plugin/shops/src/shop.plugin.kts diff --git a/game/plugin/shops/build.gradle b/game/plugin/shops/build.gradle new file mode 100644 index 00000000..1147de62 --- /dev/null +++ b/game/plugin/shops/build.gradle @@ -0,0 +1,12 @@ +plugin { + name = "shops" + packageName = "org.apollo.game.plugin.shops" + authors = [ + "Stuart", + "Major", + "tlf30" + ] + dependencies = [ + "util:lookup", + ] +} diff --git a/game/plugin/shops/src/action.kt b/game/plugin/shops/src/action.kt new file mode 100644 index 00000000..db9561e4 --- /dev/null +++ b/game/plugin/shops/src/action.kt @@ -0,0 +1,66 @@ +import org.apollo.game.action.DistancedAction +import org.apollo.game.message.handler.ItemVerificationHandler.InventorySupplier +import org.apollo.game.message.impl.SetWidgetTextMessage +import org.apollo.game.model.entity.Mob +import org.apollo.game.model.entity.Player +import org.apollo.game.model.inter.InterfaceListener +import org.apollo.game.model.inv.Inventory +import org.apollo.game.model.inv.SynchronizationInventoryListener + +/** + * A [DistancedAction] that opens a [Shop]. + */ +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 + + override fun executeAction() { + mob.interactingMob = npc + + val closeListener = addInventoryListeners(mob, shop.inventory) + mob.send(SetWidgetTextMessage(Interfaces.SHOP_NAME, shop.name)) + + mob.interfaceSet.openWindowWithSidebar(closeListener, Interfaces.SHOP_WINDOW, Interfaces.INVENTORY_SIDEBAR) + stop() + } + + /** + * Adds [SynchronizationInventoryListener]s to the [Player] and [Shop] [Inventories][Inventory], returning an + * [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) + + player.inventory.addListener(invListener) + player.inventory.forceRefresh() + + shop.addListener(shopListener) + shop.forceRefresh() + + return InterfaceListener { + mob.interfaceSet.close() + mob.resetInteractingMob() + + mob.inventory.removeListener(invListener) + shop.removeListener(shopListener) + } + } + +} + +/** + * An [InventorySupplier] that returns a [Player]'s [Inventory] if they are browsing a shop. + */ +class PlayerInventorySupplier : InventorySupplier { + + override fun getInventory(player: Player): Inventory? { + return when { + player.interfaceSet.contains(Interfaces.SHOP_WINDOW) -> player.inventory + else -> null + } + } + +} \ No newline at end of file diff --git a/game/plugin/shops/src/dls.kt b/game/plugin/shops/src/dls.kt new file mode 100644 index 00000000..19100bbd --- /dev/null +++ b/game/plugin/shops/src/dls.kt @@ -0,0 +1,350 @@ +import CategoryWrapper.Affix +import org.apollo.cache.def.NpcDefinition +import org.jetbrains.kotlin.utils.keysToMap + +/** + * 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().keysToMap { built } + + 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) -> lookup_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(lookup_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/shop.kt b/game/plugin/shops/src/shop.kt new file mode 100644 index 00000000..90e81086 --- /dev/null +++ b/game/plugin/shops/src/shop.kt @@ -0,0 +1,333 @@ +import Shop.Companion.ExchangeType.BUYING +import Shop.Companion.ExchangeType.SELLING +import Shop.PurchasePolicy.ANY +import Shop.PurchasePolicy.NOTHING +import Shop.PurchasePolicy.OWNED +import org.apollo.cache.def.ItemDefinition +import org.apollo.game.model.Item +import org.apollo.game.model.entity.Player +import org.apollo.game.model.inv.Inventory +import org.apollo.game.model.inv.Inventory.StackMode.STACK_ALWAYS + +/** + * Contains shop-related interface ids. + */ +object Interfaces { + + /** + * 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 + +} + +/** + * The [Map] from npc ids to [Shop]s. + */ +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 currency The [Currency] used when making exchanges with this [Shop]. + * @param purchases This [Shop]'s attitude towards purchasing items from players. + */ +class Shop( + val name: String, + val action: Int, + private val sells: Map, + 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. + */ + val inventory = Inventory(CAPACITY, STACK_ALWAYS) + + init { + sells.forEach { (id, amount) -> inventory.add(id, amount) } + } + + /** + * Restocks this [Shop], adding and removing items as necessary to move the stock closer to its initial state. + */ + fun restock() { + for (item in inventory.items.filterNotNull()) { + val id = item.id + + if (!sells(id) || item.amount > sells[id]!!) { + inventory.remove(id) + } else if (item.amount < sells[id]!!) { + inventory.add(id) + } + } + } + + /** + * Sells an item to a [Player]. + */ + fun sell(player: Player, slot: Int, option: Int) { + val item = inventory.get(slot) + val id = item.id + val itemCost = value(id, SELLING) + + if (option == VALUATION_OPTION) { + val itemId = ItemDefinition.lookup(id).name + player.sendMessage("$itemId: currently costs $itemCost ${currency.name(itemCost)}.") + return + } + + var buying: Int = amount(option) + var unavailable = false + + val amount = item.amount + if (buying > amount) { + buying = amount + unavailable = true + } + + val stackable = item.definition.isStackable + val slotsRequired = when { + stackable && player.inventory.contains(id) -> 0 + !stackable -> buying + else -> 1 + } + + val freeSlots = player.inventory.freeSlots() + var full = false + + if (slotsRequired > freeSlots) { + buying = freeSlots + full = true + } + + val totalCost = buying * itemCost + val totalCurrency = player.inventory.getAmount(currency.id) + var unaffordable = false + + if (totalCost > totalCurrency) { + buying = totalCurrency / itemCost + unaffordable = true + } + + if (buying > 0) { + player.inventory.remove(currency.id, totalCost) + val remaining = player.inventory.add(id, buying) + + if (remaining > 0) { + player.inventory.add(currency.id, remaining * itemCost) + } + + if (buying >= amount && sells(id)) { + // If the item is from the shop's main stock, set its amount to zero so it can be restocked over time. + inventory.set(slot, Item(id, 0)) + } else { + inventory.remove(id, buying - remaining) + } + } + + val message = when { + unaffordable -> "You don't have enough ${currency.name}." + full -> "You don't have enough inventory space." + unavailable -> "The shop has run out of stock." + else -> return + } + + player.sendMessage(message) + } + + /** + * Purchases the item from the specified [Player]. + */ + fun buy(seller: Player, slot: Int, option: Int) { + val player = seller.inventory + val id = player.get(slot).id + + if (!verifyPurchase(seller, id)) { + return + } + + val value = value(id, BUYING) + if (option == VALUATION_OPTION) { + seller.sendMessage("${ItemDefinition.lookup(id).name}: shop will buy for $value ${currency.name(value)}.") + return + } + + val amount = Math.min(player.getAmount(id), amount(option)) + + player.remove(id, amount) + inventory.add(id, amount) + + if (value != 0) { + player.add(currency.id, value * amount) + } + } + + /** + * Returns the value of the item with the specified id. + * + * @param method The [ExchangeType]. + */ + private fun value(item: Int, method: ExchangeType): Int { + val value = ItemDefinition.lookup(item).value + + return when (method) { + BUYING -> when (purchases) { + NOTHING -> throw UnsupportedOperationException("Cannot get sell value in shop that doesn't buy.") + OWNED -> (value * 0.6).toInt() + ANY -> (value * 0.4).toInt() + } + SELLING -> when (purchases) { + ANY -> Math.ceil(value * 0.8).toInt() + else -> value + } + } + } + + /** + * Verifies that the [Player] can actually sell an item with the given id to this [Shop]. + * + * @param id The id of the [Item] to sell. + */ + private fun verifyPurchase(player: Player, id: Int): Boolean { + val item = ItemDefinition.lookup(id) + + if (!purchases(id) || item.isMembersOnly && !player.isMembers || item.value == 0) { + player.sendMessage("You can't sell this item to this shop.") + return false + } else if (inventory.freeSlots() == 0 && !inventory.contains(id)) { + player.sendMessage("The shop is currently full at the moment.") + return false + } + + return true + } + + /** + * Returns whether or not this [Shop] will purchase an item with the given id. + * + * @param id The id of the [Item] purchase buy. + */ + private fun purchases(id: Int): Boolean { + return id != currency.id && when (purchases) { + NOTHING -> false + OWNED -> sells.containsKey(id) + ANY -> true + } + } + + /** + * Returns whether or not this [Shop] sells the item with the given id. + * + * @param id The id of the [Item] to sell. + */ + private fun sells(id: Int): Boolean = sells.containsKey(id) + +} \ No newline at end of file diff --git a/game/plugin/shops/src/shop.plugin.kts b/game/plugin/shops/src/shop.plugin.kts new file mode 100644 index 00000000..8dd974a8 --- /dev/null +++ b/game/plugin/shops/src/shop.plugin.kts @@ -0,0 +1,46 @@ + +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 { + 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/src/main/java/org/apollo/game/message/handler/ItemVerificationHandler.java b/game/src/main/java/org/apollo/game/message/handler/ItemVerificationHandler.java index 5f61d804..a6e96c9c 100644 --- a/game/src/main/java/org/apollo/game/message/handler/ItemVerificationHandler.java +++ b/game/src/main/java/org/apollo/game/message/handler/ItemVerificationHandler.java @@ -31,7 +31,7 @@ public final class ItemVerificationHandler extends MessageHandler