Graves Rewrite

Rewrote graves from scratch using new systems and abundant unit testing
Fixed a race condition bug that would prevent classes implementing PersistWorld from properly... persisting
Fixed a bug where graves were (still) too generous
Fixed a bug where graves would not persist across server reboots
Fixed a bug where graves would not recognize a relogged player
Fixed numerous other bugs with graves, see the unit tests for complete coverage
Grave system now supports quest requirements
This commit is contained in:
Ceikry
2022-09-17 14:40:25 +00:00
committed by Ryan
parent 95bd5ab4b7
commit f406b8aa98
23 changed files with 948 additions and 797 deletions
+2
View File
@@ -2078,6 +2078,8 @@ fun registerHintIcon(player: Player, location: Location, height: Int) {
* @param node the Node to register a hint icon for.
*/
fun registerHintIcon(player: Player, node: Node) {
if (getAttribute(player, "hinticon", null) != null)
return
setAttribute(player, "hinticon", HintIconManager.registerHintIcon(player, node))
}
@@ -0,0 +1,175 @@
package rs09.game.node.entity.player.graves
import api.TickListener
import api.clearHintIcon
import api.registerHintIcon
import api.sendMessage
import core.game.node.entity.npc.AbstractNPC
import core.game.node.entity.player.Player
import core.game.node.item.GroundItem
import core.game.node.item.GroundItemManager
import core.game.node.item.Item
import core.game.world.map.Location
import core.plugin.Initializable
import org.rs09.consts.NPCs
import rs09.game.world.GameWorld
import rs09.game.world.repository.Repository
import rs09.tools.secondsToTicks
import rs09.tools.stringtools.colorize
import rs09.tools.ticksToSeconds
import kotlin.properties.Delegates
@Initializable
class Grave : AbstractNPC {
lateinit var type: GraveType
private val items = ArrayList<GroundItem>()
var ownerUsername: String = ""
var ownerUid: Int = -1
var ticksRemaining = -1
constructor() : super(NPCs.GRAVESTONE_6571, Location.create(0,0,0), false)
private constructor(id: Int, location: Location) : super(id, location)
override fun construct(id: Int, location: Location, vararg objects: Any): AbstractNPC {
return Grave(id, location)
}
override fun getIds(): IntArray {
return GraveType.ids
}
fun configureType(type: GraveType) {
this.type = type
this.transform(type.npcId)
this.ticksRemaining = secondsToTicks(type.durationMinutes * 60)
}
fun initialize(player: Player, location: Location, inventory: Array<Item>) {
if (!GraveController.allowGenerate(player))
return
this.ownerUid = player.details.uid
this.ownerUsername = player.username
this.location = location
this.isRespawn = false
this.isWalks = false
this.isNeverWalks = true
for (item in inventory) {
if (GraveController.shouldRelease(item.id)) {
sendMessage(player, "Your ${item.name.lowercase().replace("jar", "")} has escaped.")
continue
}
if (GraveController.shouldCrumble(item.id)) {
sendMessage(player, "Your ${item.name.lowercase()} has crumbled to dust.")
continue
}
val finalItem = GraveController.checkTransform(item)
val gi = GroundItemManager.create(finalItem, this.location, player)
gi.isRemainPrivate = true
gi.decayTime = secondsToTicks(type.durationMinutes * 60)
this.items.add(gi)
}
if (items.isEmpty()) {
clear()
return
}
this.init()
if (GraveController.activeGraves[ownerUid] != null) {
val oldGrave = GraveController.activeGraves[ownerUid]
oldGrave?.collapse()
}
GraveController.activeGraves[ownerUid] = this
sendMessage(player, colorize("%RBecause of your current gravestone, you have ${type.durationMinutes} minutes to get your items back."))
}
fun setupFromJsonParams(playerUid: Int, ticks: Int, location: Location, items: Array<Item>, username: String) {
this.ownerUid = playerUid
this.ticksRemaining = ticks
this.location = location
this.isRespawn = false
this.isWalks = false
this.isNeverWalks = true
this.ownerUsername = username
for (item in items) {
val gi = GroundItemManager.create(item, location, playerUid, GameWorld.ticks + ticksRemaining)
gi.isRemainPrivate = true
this.items.add(gi)
}
this.transform(type.npcId)
this.init()
}
override fun tick() {
//Grave should not do anything else on tick, that is all handled by GraveController.
if (Repository.uid_map[ownerUid] != null) {
val p = Repository.uid_map[ownerUid] ?: return
registerHintIcon(p, this)
}
}
fun addTime(ticks: Int) {
ticksRemaining += ticks
for (gi in items) {
gi.decayTime = ticksRemaining
}
if (ticksRemaining < 30)
transform(type.npcId + 2)
else if (ticksRemaining < 90)
transform(type.npcId + 1)
else
transform(type.npcId)
}
fun collapse() {
for (item in items) {
GroundItemManager.destroy(item)
}
clear()
GraveController.activeGraves.remove(ownerUid)
if (Repository.uid_map[ownerUid] != null) {
val p = Repository.uid_map[ownerUid] ?: return
clearHintIcon(p)
}
}
fun demolish() {
val owner = Repository.uid_map[ownerUid] ?: return
for (item in items) {
if (!item.isRemoved)
item.decayTime = secondsToTicks(45)
}
clear()
sendMessage(owner, "It looks like it'll last another ${getFormattedTimeRemaining()}.")
sendMessage(owner, "You demolish it anyway.")
GraveController.activeGraves.remove(ownerUid)
clearHintIcon(owner)
}
fun getItems() : Array<GroundItem> {
return this.items.toTypedArray()
}
fun retrieveFormattedText(): String {
return type.text
.replace("@name", ownerUsername)
.replace("@mins", getFormattedTimeRemaining())
}
fun getFormattedTimeRemaining() : String {
val seconds = ticksToSeconds(ticksRemaining)
val timeQty = if (seconds / 60 > 0) seconds / 60 else seconds
val timeUnit = (if (seconds / 60 > 0) "minute" else "second") + if (timeQty > 1) "s" else ""
return "$timeQty $timeUnit"
}
}
@@ -0,0 +1,286 @@
package rs09.game.node.entity.player.graves
import api.*
import core.game.interaction.Interaction
import core.game.node.Node
import core.game.node.entity.combat.ImpactHandler
import core.game.node.entity.player.Player
import core.game.node.entity.player.info.Rights
import core.game.node.entity.player.link.IronmanMode
import core.game.node.entity.player.link.audio.Audio
import core.game.node.entity.skill.Skills
import core.game.node.item.Item
import core.game.system.task.Pulse
import core.game.world.map.Location
import org.json.simple.JSONArray
import org.json.simple.JSONObject
import org.rs09.consts.Items
import rs09.ServerStore
import rs09.game.interaction.InteractionListener
import rs09.game.system.command.Privilege
import rs09.game.world.GameWorld
import rs09.game.world.repository.Repository
import rs09.tools.secondsToTicks
import rs09.tools.stringtools.colorize
import rs09.tools.ticksToSeconds
import java.util.Map
import kotlin.math.min
class GraveController : PersistWorld, TickListener, InteractionListener, Commands {
override fun defineListeners() {
on(GraveType.ids, NPC, "read", handler = this::onGraveReadOption)
on(GraveType.ids, NPC, "bless", handler = this::onGraveBlessed)
on(GraveType.ids, NPC, "repair", handler = this::onGraveRepaired)
on(GraveType.ids, NPC, "demolish", handler = this::onGraveDemolished)
}
override fun defineCommands() {
define("forcegravedeath", Privilege.ADMIN, "", "Forces a death that should produce a grave.") {player, _ ->
player.details.rights = Rights.REGULAR_PLAYER
setAttribute(player, "tutorial:complete", true)
player.impactHandler.manualHit(player, player.skills.lifepoints, ImpactHandler.HitsplatType.NORMAL)
notify(player, "Grave created at ${player.location}")
GameWorld.Pulser.submit(object : Pulse(15) {
override fun pulse(): Boolean {
player.details.rights = Rights.ADMINISTRATOR
sendMessage(player, "Rights restored")
return true
}
})
}
}
override fun tick() {
for (grave in activeGraves.values.toTypedArray()) {
if (grave.ticksRemaining == -1) return
if (grave.ticksRemaining == secondsToTicks(30) || grave.ticksRemaining == secondsToTicks(90)) {
grave.transform(grave.id + 1)
}
if (grave.ticksRemaining == 0) {
grave.collapse()
}
grave.ticksRemaining--
}
}
private fun onGraveReadOption(player: Player, node: Node) : Boolean {
val grave = node as? Grave ?: return false
var isGraniteBackground = false
when (grave.type) {
in GraveType.SMALL_GS..GraveType.ANGEL_DEATH -> isGraniteBackground = true
}
if (isGraniteBackground)
setVarbit(player, 4191, 1)
else
setVarbit(player, 4191, 0)
openInterface(player, 266)
setInterfaceText(player, grave.retrieveFormattedText(), 266, 23)
if (player.details.uid == grave.ownerUid) {
sendMessage(player, "It looks like it'll survive another ${grave.getFormattedTimeRemaining()}.")
sendMessage(player, "Isn't there something a bit odd about reading your own gravestone?")
}
return true
}
private fun onGraveBlessed(player: Player, node: Node) : Boolean {
val g = node as? Grave ?: return false
if (getAttribute(g, "blessed", false)) {
sendMessage(player, "This grave has already been blessed.")
return true
}
if (player.details.uid == g.ownerUid) {
sendMessage(player, "The gods don't seem to approve of people attempting to bless their own gravestones.")
return true
}
if (getStatLevel(player, Skills.PRAYER) < 70) {
sendMessage(player, "You need a Prayer level of 70 to bless a grave.")
return true
}
val blessAmount = min(60, player.skills.prayerPoints.toInt() - 10)
if (blessAmount <= 0) {
sendMessage(player, "You do not have enough prayer points to do that.")
return true
}
g.addTime(secondsToTicks(blessAmount * 60))
player.skills.prayerPoints -= blessAmount
setAttribute(g, "blessed", true)
playAudio(player, Audio(2674))
animate(player, 645)
val gOwner = Repository.uid_map[g.ownerUid]
if (gOwner != null) {
sendMessage(gOwner, colorize("%RYour grave has been blessed."))
}
return true
}
private fun onGraveRepaired(player: Player, node: Node) : Boolean {
val g = node as? Grave ?: return false
if (getAttribute(g, "repaired", false)) {
sendMessage(player, "This grave has already been repaired.")
return true
}
if (getStatLevel(player, Skills.PRAYER) < 2) {
sendMessage(player, "You need a Prayer level of 2 to bless a grave.")
return true
}
if (player.skills.prayerPoints < 1.0) {
sendMessage(player, "You do not have enough prayer points to do that.")
return true
}
val restoreAmount = min(5, player.skills.prayerPoints.toInt())
g.addTime(secondsToTicks(restoreAmount * 60))
player.skills.prayerPoints -= restoreAmount
setAttribute(g, "repaired", true)
playAudio(player, Audio(2674))
animate(player, 645)
return true
}
private fun onGraveDemolished(player: Player, node: Node) : Boolean {
val g = node as? Grave ?: return false
if (player.details.uid != g.ownerUid) {
sendMessage(player, "You cannot demolish someone else's gravestone!")
return true
}
g.demolish()
return true
}
override fun save() {
serializeToServerStore()
}
override fun parse() {
deserializeFromServerStore()
}
companion object {
val activeGraves = HashMap<Int, Grave>()
var childCounter = 0
val ATTR_GTYPE = "/save:gravetype"
@JvmStatic fun produceGrave(type: GraveType): Grave {
val g = Grave()
g.configureType(type)
return g
}
@JvmStatic fun shouldCrumble(item: Int) : Boolean {
when (item) {
Items.ECTOPHIAL_4251 -> return true
in Items.SMALL_POUCH_5509..Items.GIANT_POUCH_5515 -> return true
}
return itemDefinition(item).hasAction("destroy")
}
@JvmStatic fun shouldRelease(item: Int) : Boolean {
when (item) {
Items.CHINCHOMPA_9976 -> return true
Items.CHINCHOMPA_10033 -> return true
in Items.BABY_IMPLING_JAR_11238..Items.DRAGON_IMPLING_JAR_11257 -> return itemDefinition(item).isUnnoted
}
return false
}
@JvmStatic fun checkTransform(item: Item) : Item {
if (item.hasItemPlugin())
return item.plugin.getDeathItem(item)
return item
}
@JvmStatic fun allowGenerate(player: Player) : Boolean {
if (player.skullManager.isSkulled)
return false
if (player.skullManager.isWilderness)
return false
if (player.ironmanManager.mode == IronmanMode.HARDCORE)
return false
return true
}
@JvmStatic fun getGraveType(player: Player) : GraveType {
return GraveType.values()[getAttribute(player, ATTR_GTYPE, 0)]
}
@JvmStatic fun updateGraveType(player: Player, type: GraveType) {
setAttribute(player, ATTR_GTYPE, type.ordinal)
}
@JvmStatic fun hasGraveAt(loc: Location) : Boolean {
return activeGraves.values.toTypedArray().any { it.location == loc }
}
fun serializeToServerStore() {
val archive = ServerStore.getArchive("active-graves")
for ((uid,grave) in activeGraves) {
val g = JSONObject()
g["ticksRemaining"] = grave.ticksRemaining
g["location"] = grave.location.toString()
g["type"] = grave.type.ordinal
g["username"] = grave.ownerUsername
val items = JSONArray()
for (item in grave.getItems()) {
val i = JSONObject()
i["id"] = item.id
i["amount"] = item.amount
i["charge"] = item.charge
items.add(i)
}
g["items"] = items
archive["$uid"] = g
}
}
fun deserializeFromServerStore() {
val archive = ServerStore.getArchive("active-graves")
for (entry in archive.entries as Set<Map.Entry<String, JSONObject>>) {
val g = entry.value as JSONObject
val uid = (entry.key as String).toInt()
val type = g["type"].toString().toInt()
val ticks = g["ticksRemaining"].toString().toInt()
val location = Location.fromString(g["location"].toString())
val username = g["username"].toString()
val items = ArrayList<Item>()
val itemsRaw = g["items"] as JSONArray
for (itemRaw in itemsRaw) {
val item = itemRaw as JSONObject
val id = item["id"].toString().toInt()
val amount = item["amount"].toString().toInt()
val charge = item["charge"].toString().toInt()
items.add(Item(id, amount, charge))
}
val grave = produceGrave(GraveType.values()[type])
grave.setupFromJsonParams(uid, ticks, location, items.toTypedArray(), username)
activeGraves[uid] = grave
}
}
}
}
@@ -0,0 +1,65 @@
package rs09.game.node.entity.player.graves
import api.*
import core.game.node.item.Item
import org.rs09.consts.Components
import org.rs09.consts.Items
import rs09.game.interaction.InterfaceListener
class GravePurchaseInterface : InterfaceListener {
val BUTTON_CONFIRM = 34
val AVAILABLE_GRAVES_BITFIELD = 0xFFF //Enable all graves, authentically the quest-locked ones should be excluded from the bitfield, but y'know.
val AVAILABLE_GRAVES_VARBIT = 4191
val CURRENT_GRAVE_VARBIT = 4190
override fun defineInterfaceListeners() {
onOpen (Components.GRAVESTONE_SHOP_652) {player, _ ->
val userType = GraveController.getGraveType(player).ordinal
setVarbit(player, AVAILABLE_GRAVES_VARBIT, AVAILABLE_GRAVES_BITFIELD)
setVarbit(player, CURRENT_GRAVE_VARBIT, userType)
val settings = IfaceSettingsBuilder()
.enableAllOptions()
.build()
player.packetDispatch.sendIfaceSettings(settings, 34, Components.GRAVESTONE_SHOP_652, 0, 13)
return@onOpen true
}
on (Components.GRAVESTONE_SHOP_652, BUTTON_CONFIRM) { player, _, _, _, slot, _ ->
val selectedType = GraveType.values()[slot]
val userType = GraveController.getGraveType(player)
val activeGrave = GraveController.activeGraves[player.details.uid]
if (activeGrave != null){
sendDialogue(player, "You cannot change graves while you have a grave active.")
return@on true
}
if (selectedType == userType) {
sendDialogue(player, "You already have that gravestone!")
return@on true
}
val cost = selectedType.cost
val requirement = selectedType.requiredQuest
if (requirement.isNotEmpty() && !isQuestComplete(player, requirement)) {
sendDialogue(player, "That gravestone requires completion of $requirement.")
return@on true
}
if (selectedType != GraveType.MEM_PLAQUE && amountInInventory(player, Items.COINS_995) < cost) {
sendDialogue(player, "You do not have enough coins to afford that gravestone.")
return@on true
}
if (selectedType == GraveType.MEM_PLAQUE || removeItem(player, Item(995, cost))) {
GraveController.updateGraveType(player, selectedType)
sendDialogue(player, "Your grave has been updated.")
}
closeInterface(player)
return@on true
}
}
}
@@ -0,0 +1,28 @@
package rs09.game.node.entity.player.graves
import org.rs09.consts.NPCs
enum class GraveType(val npcId: Int, val cost: Int, val durationMinutes: Int, val isMembers: Boolean, val requiredQuest: String = "", val text: String) {
MEM_PLAQUE(NPCs.GRAVE_MARKER_6565, 0, 2, false, text = "In memory of @name,<br>who died here."),
FLAG(NPCs.GRAVE_MARKER_6568, 50, 2, false, text = MEM_PLAQUE.text),
SMALL_GS(NPCs.GRAVESTONE_6571, 500, 2, false, text = "In loving memory of our dear friend @name,<br>who died in this place @mins ago."),
ORNATE_GS(NPCs.GRAVESTONE_6574, 5000, 3, false, text = SMALL_GS.text),
FONT_OF_LIFE(NPCs.GRAVESTONE_6577, 50000, 4, true, text = "In your travels,<br>pause awhile to remember @name,<br>who passed away at this spot."),
STELE(NPCs.STELE_6580, 50000, 4, true, text = FONT_OF_LIFE.text),
SARA_SYMBOL(NPCs.SARADOMIN_SYMBOL_6583, 50000, 4, true, text = "@name,<br>an enlightened servant of Saradomin,<br>perished in this place."),
ZAM_SYMBOL(NPCs.ZAMORAK_SYMBOL_6586, 50000, 4, true, text = "@name,<br>a most bloodthirsty follower of Zamorak,<br>perished in this place."),
GUTH_SYMBOL(NPCs.GUTHIX_SYMBOL_6589, 50000, 4, true, text = "@name,<br>who walked with the Balance of Guthix,<br>perished in this place."),
BAND_SYMBOL(NPCs.BANDOS_SYMBOL_6592, 50000, 4, true, requiredQuest = "Land of the Goblins", text = "@name,<br>a vicious warrior dedicated to Bandos,<br>perished in this place. "),
ARMA_SYMBOL(NPCs.ARMADYL_SYMBOL_6595, 50000, 4, true, requiredQuest = "Temple of Ikov", text = "@name,<br>a follower of the Law of Armadyl,<br>perished in this place."),
ZARO_SYMBOL(NPCs.MEMORIAL_STONE_6598, 50000, 4, true, requiredQuest = "Desert Treasure", text = "@name,<br>servant of the Unknown Power,<br>perished in this place."),
ANGEL_DEATH(NPCs.MEMORIAL_STONE_6601, 500000, 5, true, text = "Ye frail mortals who gaze upon this sight,<br>forget not the fate of @name, once mighty, now<br>surrendered to the inescapable grasp of destiny.<br><i>Requiescat in pace.</i>");
companion object {
val ids = values().fold(ArrayList<Int>()) {list, type ->
list.add(type.npcId)
list.add(type.npcId + 1)
list.add(type.npcId + 2)
list
}.toIntArray()
}
}
@@ -1,20 +1,18 @@
package rs09.game.node.entity.player.info.login
import api.PersistPlayer
import core.game.interaction.item.brawling_gloves.BrawlingGloves
import core.game.node.entity.combat.CombatSpell
import core.game.node.entity.player.Player
import core.game.node.entity.player.link.IronmanMode
import core.game.node.entity.player.link.SpellBookManager
import core.game.node.entity.player.link.emote.Emotes
import core.game.node.entity.player.link.grave.GraveType
import core.game.node.entity.player.link.music.MusicEntry
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.json.simple.JSONArray
import org.json.simple.JSONObject
import org.json.simple.parser.JSONParser
import rs09.ServerConstants
import rs09.game.node.entity.player.graves.GraveController
import rs09.game.node.entity.player.graves.GraveType
import rs09.game.node.entity.skill.farming.CompostBins
import rs09.game.node.entity.skill.farming.FarmingPatch
import rs09.game.system.SystemLogger
@@ -287,8 +285,9 @@ class PlayerSaveParser(val player: Player) {
fun parseGrave() {
saveFile ?: return
val graveData = (saveFile!!["grave_type"] as String).toInt()
player.graveManager.type = GraveType.values()[graveData]
val graveData = (saveFile!!["grave_type"] as? String)?.toInt() ?: return
val type = GraveType.values()[graveData]
GraveController.updateGraveType(player, type)
}
fun parseAppearance() {
@@ -39,7 +39,6 @@ class PlayerSaver (val player: Player){
saveAppearance(saveFile)
saveSpellbook(saveFile)
saveVarps(saveFile)
saveGraveType(saveFile)
saveSavedData(saveFile)
saveAutocast(saveFile)
saveConfigs(saveFile)
@@ -517,10 +516,6 @@ class PlayerSaver (val player: Player){
root.put("activityData",activityData)
}
fun saveGraveType(root: JSONObject){
root.put("grave_type",player.graveManager.type.ordinal.toString())
}
fun saveSpellbook(root: JSONObject){
root.put("spellbook",player.spellBookManager.spellBook.toString())
}
@@ -14,6 +14,7 @@ import core.game.world.map.RegionManager
import core.plugin.CorePluginTypes.StartupPlugin
import core.tools.RandomFunction
import rs09.ServerConstants
import rs09.ServerStore
import rs09.auth.AuthProvider
import rs09.game.system.Auth
import rs09.game.system.SystemLogger
@@ -169,7 +170,9 @@ object GameWorld {
ConfigParser().prePlugin()
ClassScanner.scanClasspath()
ClassScanner.loadPureInterfaces()
worldPersists.forEach { it.parse() }
val s = worldPersists.filterIsInstance<ServerStore>().first()
s.parse()
worldPersists.filter { it !is ServerStore }.forEach { it.parse() }
ClassScanner.loadSideEffectfulPlugins()
configParser.postPlugin()
startupListeners.forEach { it.startup() }
@@ -17,7 +17,7 @@ class PulseRunner {
fun updateAll() {
val pulseCount = pulses.size
for (i in 0..pulseCount) {
for (i in 0 until pulseCount) {
val pulse = pulses.take()
val elapsedTime = measure {