Add shops plugin

Thanks to tlf30 for a lot of work on this.
This commit is contained in:
Major
2017-09-23 04:12:08 +01:00
parent 6d202abbc2
commit 52c25946b9
6 changed files with 808 additions and 1 deletions
+12
View File
@@ -0,0 +1,12 @@
plugin {
name = "shops"
packageName = "org.apollo.game.plugin.shops"
authors = [
"Stuart",
"Major",
"tlf30"
]
dependencies = [
"util:lookup",
]
}
+66
View File
@@ -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<Player>(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
}
}
}
+350
View File
@@ -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<String, Int> = 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<Pair<String, Int>>()
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<Pair<String, Int>>()
/**
* Converts this builder into a [Shop].
*/
internal fun build(): Shop {
val items = sold.associateBy({ (first) -> lookup_item(first)!!.id }, Pair<String, Int>::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<Int> = operated.operators
}
@ShopDslMarker
class CategoryWrapper(private val items: MutableList<Pair<String, Int>>) {
/**
* 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<Int> = 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<String, Int>): 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<String, Int>): 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<String, Int>): 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<Pair<String, Int>>) {
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<String, Int>.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<String, Int> = Pair(this, id)
}
+333
View File
@@ -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<Int, Shop>()
/**
* 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<Int, Int>,
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)
}
+46
View File
@@ -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()
}
@@ -31,7 +31,7 @@ public final class ItemVerificationHandler extends MessageHandler<InventoryItemM
* Gets the appropriate {@link Inventory}.
*
* @param player The {@link Player} who prompted the verification call.
* @return The inventory. Must not be {@code null}.
* @return The inventory, or {@code null} to immediately fail verification.
*/
Inventory getInventory(Player player);