From ddd70eb065e814601d12e58b0bc56da0d5e4287f Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 10 Jun 2026 06:44:55 -0700 Subject: [PATCH 01/15] feat: add report abuse, mute, ban and black mark systems --- data/social/report/report.ifaces.toml | 9 + data/social/report/report.vars.toml | 10 + .../gregs/voidps/storage/DatabaseStorage.kt | 16 +- .../world/gregs/voidps/storage/Tables.kt | 14 ++ .../voidps/storage/DatabaseStorageTest.kt | 30 +++ .../engine/client/PlayerAccountLoader.kt | 12 +- .../client/instruction/InstructionHandlers.kt | 3 + .../gregs/voidps/engine/data/AbuseReport.kt | 23 ++ .../world/gregs/voidps/engine/data/Reports.kt | 43 ++++ .../gregs/voidps/engine/data/SafeStorage.kt | 18 ++ .../world/gregs/voidps/engine/data/Storage.kt | 5 + .../voidps/engine/data/file/FileStorage.kt | 17 ++ .../engine/client/PlayerAccountLoaderTest.kt | 23 ++ .../voidps/engine/data/AccountManagerTest.kt | 3 + .../voidps/engine/data/SafeStorageTest.kt | 30 +++ .../engine/data/file/FileStorageTest.kt | 30 +++ .../main/kotlin/content/social/chat/Chat.kt | 17 ++ .../kotlin/content/social/chat/ChatHistory.kt | 47 ++++ .../main/kotlin/content/social/report/Ban.kt | 106 +++++++++ .../content/social/report/BlackMarks.kt | 102 +++++++++ .../main/kotlin/content/social/report/Mute.kt | 91 ++++++++ .../content/social/report/ReportAbuse.kt | 72 +++++- .../main/kotlin/content/social/report/Rule.kt | 29 +++ game/src/main/resources/game.properties | 3 + .../content/social/report/PunishmentsTest.kt | 55 +++++ .../content/social/report/ReportAbuseTest.kt | 213 ++++++++++++++++++ .../network/client/instruction/ReportAbuse.kt | 8 +- .../protocol/decode/ReportAbuseDecoder.kt | 6 +- 28 files changed, 1024 insertions(+), 11 deletions(-) create mode 100644 data/social/report/report.vars.toml create mode 100644 engine/src/main/kotlin/world/gregs/voidps/engine/data/AbuseReport.kt create mode 100644 engine/src/main/kotlin/world/gregs/voidps/engine/data/Reports.kt create mode 100644 game/src/main/kotlin/content/social/chat/ChatHistory.kt create mode 100644 game/src/main/kotlin/content/social/report/Ban.kt create mode 100644 game/src/main/kotlin/content/social/report/BlackMarks.kt create mode 100644 game/src/main/kotlin/content/social/report/Mute.kt create mode 100644 game/src/main/kotlin/content/social/report/Rule.kt create mode 100644 game/src/test/kotlin/content/social/report/PunishmentsTest.kt create mode 100644 game/src/test/kotlin/content/social/report/ReportAbuseTest.kt diff --git a/data/social/report/report.ifaces.toml b/data/social/report/report.ifaces.toml index 9a4eb0b0cc..c2e2016439 100644 --- a/data/social/report/report.ifaces.toml +++ b/data/social/report/report.ifaces.toml @@ -13,3 +13,12 @@ id = 110 [report_abuse] id = 594 +[.mute_confirm] +id = 8 + +[.mute_entry] +id = 52 + +[.mute_select] +id = 66 + diff --git a/data/social/report/report.vars.toml b/data/social/report/report.vars.toml new file mode 100644 index 0000000000..2d98c1c149 --- /dev/null +++ b/data/social/report/report.vars.toml @@ -0,0 +1,10 @@ +[muted_until] +format = "long" +persist = true + +[banned_until] +format = "long" +persist = true + +[black_marks] +persist = true diff --git a/database/src/main/kotlin/world/gregs/voidps/storage/DatabaseStorage.kt b/database/src/main/kotlin/world/gregs/voidps/storage/DatabaseStorage.kt index d63e503089..4daab8e58b 100644 --- a/database/src/main/kotlin/world/gregs/voidps/storage/DatabaseStorage.kt +++ b/database/src/main/kotlin/world/gregs/voidps/storage/DatabaseStorage.kt @@ -5,6 +5,7 @@ import com.zaxxer.hikari.HikariDataSource import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList import org.jetbrains.exposed.sql.transactions.transaction +import world.gregs.voidps.engine.data.AbuseReport import world.gregs.voidps.engine.data.PlayerSave import world.gregs.voidps.engine.data.Storage import world.gregs.voidps.engine.data.config.AccountDefinition @@ -200,6 +201,19 @@ class DatabaseStorage : Storage { saveHistories(accounts, playerIds) } + override fun saveReport(report: AbuseReport): Unit = transaction { + ReportsTable.insert { + it[reporter] = report.reporter + it[reported] = report.reported + it[rule] = report.rule + it[ruleName] = report.ruleName + it[mute] = report.mute + it[suggestion] = report.suggestion + it[time] = report.time + it[evidence] = report.evidence + } + } + override fun exists(accountName: String): Boolean = transaction { val lower = accountName.lowercase() AccountsTable @@ -565,7 +579,7 @@ class DatabaseStorage : Storage { } } - internal val tables = arrayOf(AccountsTable, ExperienceTable, LevelsTable, VariablesTable, InventoriesTable, OffersTable, ActiveOffersTable, PlayerHistoryTable, ClaimsTable, ItemHistoryTable) + internal val tables = arrayOf(AccountsTable, ExperienceTable, LevelsTable, VariablesTable, InventoriesTable, OffersTable, ActiveOffersTable, PlayerHistoryTable, ClaimsTable, ItemHistoryTable, ReportsTable) private const val TYPE_STRING = 0.toByte() private const val TYPE_INT = 1.toByte() diff --git a/database/src/main/kotlin/world/gregs/voidps/storage/Tables.kt b/database/src/main/kotlin/world/gregs/voidps/storage/Tables.kt index e5a00a2914..75eb3e2aba 100644 --- a/database/src/main/kotlin/world/gregs/voidps/storage/Tables.kt +++ b/database/src/main/kotlin/world/gregs/voidps/storage/Tables.kt @@ -157,6 +157,20 @@ internal object ClaimsTable : Table("grand_exchange_claims") { val coins = integer("coins") } +internal object ReportsTable : Table("abuse_reports") { + val id = integer("id").autoIncrement().uniqueIndex() + val reporter = varchar("reporter", 12) + val reported = text("reported") + val rule = integer("rule") + val ruleName = text("rule_name") + val mute = bool("mute") + val suggestion = text("suggestion") + val time = long("time") + val evidence = array("evidence") + + override val primaryKey = PrimaryKey(id, name = "pk_report_id") +} + internal object ItemHistoryTable : Table("grand_exchange_item_history") { val item = text("item") val timestamp = long("timestamp") diff --git a/database/src/test/kotlin/world/gregs/voidps/storage/DatabaseStorageTest.kt b/database/src/test/kotlin/world/gregs/voidps/storage/DatabaseStorageTest.kt index 146fe7627b..ded369ce94 100644 --- a/database/src/test/kotlin/world/gregs/voidps/storage/DatabaseStorageTest.kt +++ b/database/src/test/kotlin/world/gregs/voidps/storage/DatabaseStorageTest.kt @@ -5,11 +5,41 @@ import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.transactions.transaction import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import world.gregs.voidps.engine.data.AbuseReport +import kotlin.test.assertEquals class DatabaseStorageTest : StorageTest(), DatabaseTest { override val storage = DatabaseStorage() + @Test + fun `Store an abuse report`() { + val report = AbuseReport( + reporter = "mod_steve", + reported = "Durial321", + rule = 6, + ruleName = "Macroing", + mute = true, + suggestion = "extra info", + time = 1234567890, + evidence = listOf("[00:00:01] public: free armour trimming", "[00:00:02] public: selling gf"), + ) + + storage.saveReport(report) + + transaction { + val row = ReportsTable.selectAll().single() + assertEquals(report.reporter, row[ReportsTable.reporter]) + assertEquals(report.reported, row[ReportsTable.reported]) + assertEquals(report.rule, row[ReportsTable.rule]) + assertEquals(report.ruleName, row[ReportsTable.ruleName]) + assertEquals(report.mute, row[ReportsTable.mute]) + assertEquals(report.suggestion, row[ReportsTable.suggestion]) + assertEquals(report.time, row[ReportsTable.time]) + assertEquals(report.evidence, row[ReportsTable.evidence]) + } + } + @Test fun `Saving variable with invalid format throws exception`() { assertThrows { diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/client/PlayerAccountLoader.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/client/PlayerAccountLoader.kt index 1c49ce6d54..cc5d571463 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/client/PlayerAccountLoader.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/client/PlayerAccountLoader.kt @@ -55,7 +55,12 @@ class PlayerAccountLoader( client.disconnect(Response.GAME_UPDATE) return null } - var player = storage.load(username)?.toPlayer() + val save = storage.load(username) + if (save != null && banned(save.variables)) { + client.disconnect(Response.ACCOUNT_DISABLED) + return null + } + var player = save?.toPlayer() if (player == null) { if (!Settings["development.accountCreation", false]) { client.disconnect(Response.INVALID_CREDENTIALS) @@ -73,6 +78,11 @@ class PlayerAccountLoader( } } + private fun banned(variables: Map): Boolean { + val until = (variables["banned_until"] as? Number)?.toLong() ?: return false + return until > System.currentTimeMillis() + } + suspend fun connect(player: Player, client: Client, displayMode: Int = 0, viewport: Boolean = true) { if (!accounts.setup(player, client, displayMode, viewport)) { logger.warn { "Error setting up account" } diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InstructionHandlers.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InstructionHandlers.kt index f4b0815a27..0a4c418937 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InstructionHandlers.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/client/instruction/InstructionHandlers.kt @@ -50,6 +50,7 @@ class InstructionHandlers( var chatTypeChangeHandler: ChatTypeChange.(Player) -> Unit = empty() var clanChatKickHandler: ClanChatKick.(Player) -> Unit = empty() var clanChatRankHandler: ClanChatRank.(Player) -> Unit = empty() + var reportAbuseHandler: ReportAbuse.(Player) -> Unit = empty() private fun empty(): I.(Player) -> Unit { val logger = InlineLogger("InstructionHandler") @@ -98,6 +99,7 @@ class InstructionHandlers( is ChatTypeChange -> chatTypeChangeHandler.invoke(instruction, player) is ClanChatKick -> clanChatKickHandler.invoke(instruction, player) is ClanChatRank -> clanChatRankHandler.invoke(instruction, player) + is ReportAbuse -> reportAbuseHandler.invoke(instruction, player) is SongEnd -> songEndHandler.invoke(instruction, player) else -> return false } @@ -132,6 +134,7 @@ inline fun instruction(noinline handler: I.(Player) -> ChatTypeChange::class -> get().chatTypeChangeHandler = handler as ChatTypeChange.(Player) -> Unit ClanChatKick::class -> get().clanChatKickHandler = handler as ClanChatKick.(Player) -> Unit ClanChatRank::class -> get().clanChatRankHandler = handler as ClanChatRank.(Player) -> Unit + ReportAbuse::class -> get().reportAbuseHandler = handler as ReportAbuse.(Player) -> Unit else -> throw UnsupportedOperationException("Unknown Instruction type: ${I::class}") } } diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/data/AbuseReport.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/data/AbuseReport.kt new file mode 100644 index 0000000000..e60063be31 --- /dev/null +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/data/AbuseReport.kt @@ -0,0 +1,23 @@ +package world.gregs.voidps.engine.data + +/** + * A player submitted report about another player breaking a rule + * @param reporter Account name of the player filing the report + * @param reported Display name of the accused player as submitted + * @param rule Identifier of the rule broken + * @param ruleName Readable name of the rule broken + * @param mute Whether a moderator requested the accused be muted + * @param suggestion Additional text submitted with the report + * @param time Epoch millisecond timestamp the report was received + * @param evidence Recent chat messages sent by the accused player + */ +data class AbuseReport( + val reporter: String, + val reported: String, + val rule: Int, + val ruleName: String, + val mute: Boolean, + val suggestion: String, + val time: Long, + val evidence: List, +) diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/data/Reports.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/data/Reports.kt new file mode 100644 index 0000000000..027fcd89e8 --- /dev/null +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/data/Reports.kt @@ -0,0 +1,43 @@ +package world.gregs.voidps.engine.data + +import com.github.michaelbull.logging.InlineLogger +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Saves abuse reports off the game thread, falling back to [fallback] storage on failure + */ +class Reports( + private val storage: Storage, + private val fallback: Storage = storage, + private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), +) { + private val logger = InlineLogger() + + private val fallbackHandler = CoroutineExceptionHandler { _, exception -> + logger.error(exception) { "Fallback report save failed!" } + } + + fun queue(report: AbuseReport) { + if (Settings["storage.disabled", false]) { + return + } + val handler = CoroutineExceptionHandler { _, exception -> + logger.error(exception) { "Error saving abuse report!" } + scope.launch(fallbackHandler) { + withContext(NonCancellable) { + fallback.saveReport(report) + } + } + } + scope.launch(handler) { + withContext(NonCancellable) { + storage.saveReport(report) + } + } + } +} diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/data/SafeStorage.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/data/SafeStorage.kt index c6df99b110..43d0a82016 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/data/SafeStorage.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/data/SafeStorage.kt @@ -58,6 +58,24 @@ class SafeStorage( } } + override fun saveReport(report: AbuseReport) { + val parent = directory.resolve("reports/") + parent.mkdirs() + val file = parent.resolve("${report.time}-${report.reporter}.toml") + file.writeText( + buildString { + appendLine("reporter = \"${report.reporter}\"") + appendLine("reported = \"${report.reported}\"") + appendLine("rule = ${report.rule}") + appendLine("rule_name = \"${report.ruleName}\"") + appendLine("mute = ${report.mute}") + appendLine("suggestion = \"${report.suggestion}\"") + appendLine("time = ${report.time}") + appendLine("evidence = [${report.evidence.joinToString(", ") { "\"${it}\"" }}]") + }, + ) + } + override fun saveOffers(offers: OpenOffers) { val buy = directory.resolve(Settings["storage.grand.exchange.offers.buy.path"]) buy.mkdirs() diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/data/Storage.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/data/Storage.kt index 6ff749f839..57493372ed 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/data/Storage.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/data/Storage.kt @@ -56,6 +56,11 @@ interface Storage { */ fun save(accounts: List) + /** + * Saves an abuse report + */ + fun saveReport(report: AbuseReport) + /** * Checks if an account exists */ diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/data/file/FileStorage.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/data/file/FileStorage.kt index db8a2a7251..4143b47a40 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/data/file/FileStorage.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/data/file/FileStorage.kt @@ -3,6 +3,7 @@ package world.gregs.voidps.engine.data.file import com.github.michaelbull.logging.InlineLogger import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap import world.gregs.config.* +import world.gregs.voidps.engine.data.AbuseReport import world.gregs.voidps.engine.data.PlayerSave import world.gregs.voidps.engine.data.Settings import world.gregs.voidps.engine.data.Storage @@ -91,6 +92,22 @@ class FileStorage( return offers } + override fun saveReport(report: AbuseReport) { + val parent = directory.resolve(Settings["storage.reports.path", "reports/"]) + parent.mkdirs() + val file = parent.resolve("${report.time}-${report.reporter}.toml") + Config.fileWriter(file) { + writePair("reporter", report.reporter) + writePair("reported", report.reported) + writePair("rule", report.rule) + writePair("rule_name", report.ruleName) + writePair("mute", report.mute) + writePair("suggestion", report.suggestion) + writePair("time", report.time) + writePair("evidence", report.evidence) + } + } + override fun saveOffers(offers: OpenOffers) { val buy = directory.resolve(Settings["storage.grand.exchange.offers.buy.path"]) if (buy.deleteRecursively()) { diff --git a/engine/src/test/kotlin/world/gregs/voidps/engine/client/PlayerAccountLoaderTest.kt b/engine/src/test/kotlin/world/gregs/voidps/engine/client/PlayerAccountLoaderTest.kt index 4f2190f77a..13fc81ab22 100644 --- a/engine/src/test/kotlin/world/gregs/voidps/engine/client/PlayerAccountLoaderTest.kt +++ b/engine/src/test/kotlin/world/gregs/voidps/engine/client/PlayerAccountLoaderTest.kt @@ -63,6 +63,9 @@ internal class PlayerAccountLoaderTest : KoinMock() { override fun savePriceHistory(history: Map) { } + override fun saveReport(report: AbuseReport) { + } + override fun exists(accountName: String): Boolean = false override fun load(accountName: String): PlayerSave? = playerSave @@ -89,6 +92,26 @@ internal class PlayerAccountLoaderTest : KoinMock() { assertNotNull(instructions) } + @Test + fun `Can't login if banned`() = runTest { + val client: Client = mockk(relaxed = true) + playerSave = PlayerSave("name", "hash", Tile.EMPTY, intArrayOf(), emptyList(), intArrayOf(), true, intArrayOf(), intArrayOf(), mapOf("banned_until" to Long.MAX_VALUE), emptyMap(), emptyMap(), emptyList(), arrayOf(), emptyList()) + + val instructions = loader.load(client, "name", "pass", 2) + assertNull(instructions) + coVerify { client.disconnect(Response.ACCOUNT_DISABLED) } + } + + @Test + fun `Can login once ban expires`() = runTest { + val client: Client = mockk(relaxed = true) + playerSave = PlayerSave("name", "hash", Tile.EMPTY, intArrayOf(), emptyList(), intArrayOf(), true, intArrayOf(), intArrayOf(), mapOf("banned_until" to 1L), emptyMap(), emptyMap(), emptyList(), arrayOf(), emptyList()) + coEvery { queue.await() } just Runs + + val instructions = loader.load(client, "name", "pass", 2) + assertNotNull(instructions) + } + @Test fun `Can't login if account is being saved`() = runTest { saveQueue.save(Player(accountName = "name")) diff --git a/engine/src/test/kotlin/world/gregs/voidps/engine/data/AccountManagerTest.kt b/engine/src/test/kotlin/world/gregs/voidps/engine/data/AccountManagerTest.kt index b356cb7d1b..ebbccee8ec 100644 --- a/engine/src/test/kotlin/world/gregs/voidps/engine/data/AccountManagerTest.kt +++ b/engine/src/test/kotlin/world/gregs/voidps/engine/data/AccountManagerTest.kt @@ -70,6 +70,9 @@ class AccountManagerTest : KoinMock() { override fun save(accounts: List) { } + override fun saveReport(report: AbuseReport) { + } + override fun exists(accountName: String): Boolean = false override fun load(accountName: String): PlayerSave? = null diff --git a/engine/src/test/kotlin/world/gregs/voidps/engine/data/SafeStorageTest.kt b/engine/src/test/kotlin/world/gregs/voidps/engine/data/SafeStorageTest.kt index 741703de77..9f615b1d07 100644 --- a/engine/src/test/kotlin/world/gregs/voidps/engine/data/SafeStorageTest.kt +++ b/engine/src/test/kotlin/world/gregs/voidps/engine/data/SafeStorageTest.kt @@ -28,4 +28,34 @@ class SafeStorageTest { val expected = File("./src/test/resources/player.toml").readText().replace("\r\n", "\n") assertEquals(expected, file.readText().replace("\r\n", "\n")) } + + @Test + fun `Store an abuse report`() { + val report = AbuseReport( + reporter = "mod_steve", + reported = "Durial321", + rule = 6, + ruleName = "Macroing", + mute = true, + suggestion = "extra info", + time = 1234567890, + evidence = listOf("[00:00:01] public: free armour trimming"), + ) + + storage.saveReport(report) + + val file = dir.resolve("reports/1234567890-mod_steve.toml") + assertTrue(file.exists()) + val expected = """ + reporter = "mod_steve" + reported = "Durial321" + rule = 6 + rule_name = "Macroing" + mute = true + suggestion = "extra info" + time = 1234567890 + evidence = ["[00:00:01] public: free armour trimming"] + """.trimIndent() + assertEquals(expected, file.readText().replace("\r\n", "\n").trim()) + } } diff --git a/engine/src/test/kotlin/world/gregs/voidps/engine/data/file/FileStorageTest.kt b/engine/src/test/kotlin/world/gregs/voidps/engine/data/file/FileStorageTest.kt index 117a35d0d4..3c4efe3da9 100644 --- a/engine/src/test/kotlin/world/gregs/voidps/engine/data/file/FileStorageTest.kt +++ b/engine/src/test/kotlin/world/gregs/voidps/engine/data/file/FileStorageTest.kt @@ -4,6 +4,7 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir +import world.gregs.voidps.engine.data.AbuseReport import world.gregs.voidps.engine.data.Storage import world.gregs.voidps.engine.data.StorageTest import java.io.File @@ -21,6 +22,35 @@ class FileStorageTest : StorageTest() { storage = FileStorage(directory) } + @Test + fun `Store an abuse report`() { + val report = AbuseReport( + reporter = "mod_steve", + reported = "Durial321", + rule = 6, + ruleName = "Macroing", + mute = true, + suggestion = "extra info", + time = 1234567890, + evidence = listOf("[00:00:01] public: free armour trimming", "[00:00:02] public: selling gf"), + ) + + storage.saveReport(report) + + val file = directory.resolve("reports/1234567890-mod_steve.toml") + assertTrue(file.exists()) + val text = file.readText() + assertTrue(text.contains("reporter = \"mod_steve\"")) + assertTrue(text.contains("reported = \"Durial321\"")) + assertTrue(text.contains("rule = 6")) + assertTrue(text.contains("rule_name = \"Macroing\"")) + assertTrue(text.contains("mute = true")) + assertTrue(text.contains("suggestion = \"extra info\"")) + assertTrue(text.contains("time = 1234567890")) + assertTrue(text.contains("free armour trimming")) + assertTrue(text.contains("selling gf")) + } + @Test fun `Invalid directory returns no names`() { val storage = FileStorage(directory.resolve("invalid")) diff --git a/game/src/main/kotlin/content/social/chat/Chat.kt b/game/src/main/kotlin/content/social/chat/Chat.kt index 8629fc684c..fd4e88197f 100644 --- a/game/src/main/kotlin/content/social/chat/Chat.kt +++ b/game/src/main/kotlin/content/social/chat/Chat.kt @@ -3,6 +3,8 @@ package content.social.chat import content.social.clan.chatType import content.social.clan.clan import content.social.ignore.ignores +import content.social.report.isMuted +import content.social.report.sendMuteMessage import net.pearx.kasechange.toTitleCase import world.gregs.voidps.cache.secure.Huffman import world.gregs.voidps.engine.Script @@ -25,13 +27,22 @@ import world.gregs.voidps.network.login.protocol.encode.publicChat class Chat(val huffman: Huffman) : Script { init { + playerDespawn { + ChatHistory.clear(accountName) + } + instruction { player -> + if (player.isMuted) { + player.sendMuteMessage() + return@instruction + } val target = Players.find(friend) if (target == null || target.ignores(player)) { player.message("Unable to send message - player unavailable.") return@instruction } AuditLog.event(player, "told", target, message) + ChatHistory.add(player, "private", message) val compressed = huffman.compress(message) player.client?.privateChatTo(target.name, compressed) target.client?.privateChatFrom(player.name, player.rights.ordinal, compressed) @@ -45,6 +56,10 @@ class Chat(val huffman: Huffman) : Script { } instruction { player -> + if (player.isMuted) { + player.sendMuteMessage() + return@instruction + } val text = if (text.all { it.isUpperCase() }) { text.toTitleCase() } else { @@ -54,6 +69,7 @@ class Chat(val huffman: Huffman) : Script { when (player.chatType) { "public" -> { AuditLog.event(player, "said", text) + ChatHistory.add(player, "public", text) val compressed = huffman.compress(text) Players.filter { it.tile.within(player.tile, VIEW_RADIUS) && !it.ignores(player) }.forEach { it.client?.publicChat(player.index, effects, player.rights.ordinal, compressed) @@ -70,6 +86,7 @@ class Chat(val huffman: Huffman) : Script { return@instruction } AuditLog.event(player, "clan_said", clan, text) + ChatHistory.add(player, "clan", text) val compressed = huffman.compress(text) clan.members.filterNot { it.ignores(player) }.forEach { member -> member.client?.clanChat(player.name, member.clan!!.name, player.rights.ordinal, compressed) diff --git a/game/src/main/kotlin/content/social/chat/ChatHistory.kt b/game/src/main/kotlin/content/social/chat/ChatHistory.kt new file mode 100644 index 0000000000..57df851302 --- /dev/null +++ b/game/src/main/kotlin/content/social/chat/ChatHistory.kt @@ -0,0 +1,47 @@ +package content.social.chat + +import world.gregs.voidps.engine.entity.character.player.Player +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.concurrent.TimeUnit + +/** + * Tracks recent chat messages per account for use as abuse report evidence + * Note: not thread safe; only use within game thread + */ +object ChatHistory { + + data class Entry(val time: Long, val type: String, val text: String) + + private const val MAX_ENTRIES = 20 + private val MAX_AGE = TimeUnit.MINUTES.toMillis(5) + private val FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneOffset.UTC) + private val history = mutableMapOf>() + + fun add(player: Player, type: String, text: String) { + val entries = history.getOrPut(player.accountName) { ArrayDeque() } + entries.addLast(Entry(System.currentTimeMillis(), type, text)) + trim(entries) + } + + /** + * Snapshot of the recent messages sent by [account], formatted for storage + */ + fun recent(account: String): List { + val entries = history[account] ?: return emptyList() + trim(entries) + return entries.map { "[${FORMAT.format(Instant.ofEpochMilli(it.time))}] ${it.type}: ${it.text}" } + } + + fun clear(account: String) { + history.remove(account) + } + + private fun trim(entries: ArrayDeque) { + val expired = System.currentTimeMillis() - MAX_AGE + while (entries.isNotEmpty() && (entries.size > MAX_ENTRIES || entries.first().time < expired)) { + entries.removeFirst() + } + } +} diff --git a/game/src/main/kotlin/content/social/report/Ban.kt b/game/src/main/kotlin/content/social/report/Ban.kt new file mode 100644 index 0000000000..f7b1f83b2a --- /dev/null +++ b/game/src/main/kotlin/content/social/report/Ban.kt @@ -0,0 +1,106 @@ +package content.social.report + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.command.intArg +import world.gregs.voidps.engine.client.command.modCommand +import world.gregs.voidps.engine.client.command.stringArg +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.AccountManager +import world.gregs.voidps.engine.data.Storage +import world.gregs.voidps.engine.data.definition.AccountDefinitions +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.event.AuditLog +import java.util.concurrent.TimeUnit + +val Player.isBanned: Boolean + get() = this["banned_until", 0L] > System.currentTimeMillis() + +fun Player.ban(hours: Int = 48) { + this["banned_until"] = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(hours.toLong()) +} + +fun Player.permBan() { + this["banned_until"] = PERMANENT +} + +fun Player.unban() { + clear("banned_until") +} + +class Ban(val accounts: AccountDefinitions, val manager: AccountManager, val storage: Storage) : Script { + + init { + modCommand("ban", stringArg("player-name", autofill = accounts.displayNames.keys), intArg("hours", optional = true), desc = "Temporarily ban a player from logging in") { args -> + val hours = args.getOrNull(1)?.toIntOrNull() ?: 48 + val until = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(hours.toLong()) + val target = Players.find(args[0]) + if (target != null) { + target.ban(hours) + AuditLog.event(this, "banned", target, hours) + manager.logout(target, false) + } else if (!setOfflineVariable(args[0], "banned_until", until)) { + message("Unable to find player '${args[0]}'.") + return@modCommand + } else { + AuditLog.event(this, "banned", args[0], hours) + } + message("${args[0]} has been banned for $hours hours.") + } + + modCommand("permban", stringArg("player-name", autofill = accounts.displayNames.keys), desc = "Permanently ban a player from logging in") { args -> + val target = Players.find(args[0]) + if (target != null) { + if (target.blackMarks < BLACK_MARK_LIMIT) { + message("${args[0]} has ${target.blackMarks} black marks; $BLACK_MARK_LIMIT are required for a permanent ban.") + return@modCommand + } + target.permBan() + AuditLog.event(this, "perm_banned", target) + manager.logout(target, false) + } else { + val save = storage.load(accounts.get(args[0])?.accountName ?: args[0]) + if (save == null) { + message("Unable to find player '${args[0]}'.") + return@modCommand + } + val marks = activeBlackMarks((save.variables["black_marks"] as? List<*>)?.filterIsInstance() ?: emptyList()) + if (marks.size < BLACK_MARK_LIMIT) { + message("${args[0]} has ${marks.size} black marks; $BLACK_MARK_LIMIT are required for a permanent ban.") + return@modCommand + } + setOfflineVariable(args[0], "banned_until", PERMANENT) + AuditLog.event(this, "perm_banned", args[0]) + } + message("${args[0]} has been permanently banned.") + } + + modCommand("unban", stringArg("player-name", autofill = accounts.displayNames.keys), desc = "Remove a player's ban") { args -> + val target = Players.find(args[0]) + if (target != null) { + target.unban() + } else if (!setOfflineVariable(args[0], "banned_until", null)) { + message("Unable to find player '${args[0]}'.") + return@modCommand + } + AuditLog.event(this, "unbanned", args[0]) + message("${args[0]} has been unbanned.") + } + } + + /** + * Updates a variable on an offline player's saved account + */ + private fun setOfflineVariable(displayName: String, key: String, value: Any?): Boolean { + val account = accounts.get(displayName)?.accountName ?: displayName + val save = storage.load(account) ?: return false + val variables = save.variables.toMutableMap() + if (value == null) { + variables.remove(key) + } else { + variables[key] = value + } + storage.save(listOf(save.copy(variables = variables))) + return true + } +} diff --git a/game/src/main/kotlin/content/social/report/BlackMarks.kt b/game/src/main/kotlin/content/social/report/BlackMarks.kt new file mode 100644 index 0000000000..601f490814 --- /dev/null +++ b/game/src/main/kotlin/content/social/report/BlackMarks.kt @@ -0,0 +1,102 @@ +package content.social.report + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.command.intArg +import world.gregs.voidps.engine.client.command.modCommand +import world.gregs.voidps.engine.client.command.stringArg +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.chat.plural +import world.gregs.voidps.engine.data.definition.AccountDefinitions +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.character.player.chat.ChatType +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.engine.event.AuditLog +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.concurrent.TimeUnit + +/** + * Black marks are a disciplinary measure used to track the offences of a player. + * Most expire after 12 months; serious offences such as real-world trading never expire. + * Once a player has accumulated [BLACK_MARK_LIMIT] active marks they can be + * permanently muted or permanently banned. + */ +const val BLACK_MARK_LIMIT = 10 + +private val EXPIRY_MONTHS = TimeUnit.DAYS.toMillis(365) +private val PERMANENT_RULES = setOf(Rule.BreakingRealWorldLaws) +private val DATE_FORMAT = DateTimeFormatter.ofPattern("d MMM yyyy").withZone(ZoneOffset.UTC) + +val Player.blackMarks: Int + get() = activeBlackMarks().size + +/** + * The player's unexpired black marks, removing any that have degraded + */ +fun Player.activeBlackMarks(): List { + val marks = this["black_marks", emptyList()] + val active = activeBlackMarks(marks) + if (active.size != marks.size) { + if (active.isEmpty()) { + clear("black_marks") + } else { + this["black_marks"] = active + } + } + return active +} + +fun activeBlackMarks(marks: List): List { + val now = System.currentTimeMillis() + return marks.filter { expiry(it) > now } +} + +fun Player.addBlackMark(rule: Rule) { + val expiry = if (rule in PERMANENT_RULES) PERMANENT else System.currentTimeMillis() + EXPIRY_MONTHS + this["black_marks"] = activeBlackMarks() + "${rule.id}:$expiry" +} + +private fun expiry(mark: String): Long = mark.substringAfter(':').toLongOrNull() ?: 0L + +private fun describe(mark: String): String { + val rule = Rule.byId(mark.substringBefore(':').toIntOrNull() ?: -1) + val expiry = expiry(mark) + val expires = if (expiry == PERMANENT) "never expires" else "expires ${DATE_FORMAT.format(Instant.ofEpochMilli(expiry))}" + return "${rule?.title ?: "Unknown offence"} - $expires" +} + +class BlackMarks(val accounts: AccountDefinitions) : Script { + + init { + modCommand("blackmark", stringArg("player-name", autofill = accounts.displayNames.keys), intArg("rule-id", desc = "id of the rule broken"), desc = "Add a black mark to a player's account") { args -> + val rule = Rule.byId(args[1].toIntOrNull() ?: -1) + if (rule == null) { + message("Invalid rule id '${args[1]}'.") + return@modCommand + } + val target = Players.find(args[0]) + if (target == null) { + message("Unable to find player '${args[0]}'.") + return@modCommand + } + target.addBlackMark(rule) + message("Black mark added to ${target.name} for ${rule.title}; they now have ${target.blackMarks}.") + AuditLog.event(this, "black_marked", target, rule.name) + } + + modCommand("blackmarks", stringArg("player-name", autofill = accounts.displayNames.keys), desc = "View a player's black marks") { args -> + val target = Players.find(args[0]) + if (target == null) { + message("Unable to find player '${args[0]}'.") + return@modCommand + } + val marks = target.activeBlackMarks() + message("${target.name} has ${marks.size} black ${"mark".plural(marks.size)}.", ChatType.Console) + for (mark in marks) { + message(describe(mark), ChatType.Console) + } + } + } +} diff --git a/game/src/main/kotlin/content/social/report/Mute.kt b/game/src/main/kotlin/content/social/report/Mute.kt new file mode 100644 index 0000000000..95a98ec8fb --- /dev/null +++ b/game/src/main/kotlin/content/social/report/Mute.kt @@ -0,0 +1,91 @@ +package content.social.report + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.command.intArg +import world.gregs.voidps.engine.client.command.modCommand +import world.gregs.voidps.engine.client.command.stringArg +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.ui.chat.plural +import world.gregs.voidps.engine.data.definition.AccountDefinitions +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.engine.event.AuditLog +import java.util.concurrent.TimeUnit + +// Max value rather than -1 as small numbers are loaded back from saves as ints +const val PERMANENT = Long.MAX_VALUE + +val Player.isMuted: Boolean + get() = this["muted_until", 0L] > System.currentTimeMillis() + +fun Player.mute(hours: Int = 48) { + this["muted_until"] = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(hours.toLong()) + message("You have been temporarily muted due to breaking a rule.") +} + +fun Player.permMute() { + this["muted_until"] = PERMANENT + message("You have been permanently muted due to breaking a rule.") +} + +fun Player.unmute() { + clear("muted_until") +} + +/** + * Informs a muted player why their chat attempt was blocked + */ +fun Player.sendMuteMessage() { + val until = this["muted_until", 0L] + if (until == PERMANENT) { + message("You are permanently muted because of breaking a rule.") + } else { + val day = TimeUnit.DAYS.toMillis(1) + val days = (until - System.currentTimeMillis() + day - 1) / day + message("You are temporarily muted because of breaking a rule. This mute will remain for a further $days ${"day".plural(days)}. To prevent further mutes please read the rules.") + } +} + +class Mute(val accounts: AccountDefinitions) : Script { + + init { + modCommand("mute", stringArg("player-name", autofill = accounts.displayNames.keys), intArg("hours", optional = true), desc = "Temporarily mute a player so they can't chat") { args -> + val target = Players.find(args[0]) + if (target == null) { + message("Unable to find player '${args[0]}'.") + return@modCommand + } + val hours = args.getOrNull(1)?.toIntOrNull() ?: 48 + target.mute(hours) + message("${target.name} has been muted for $hours hours.") + AuditLog.event(this, "muted", target, hours) + } + + modCommand("permmute", stringArg("player-name", autofill = accounts.displayNames.keys), desc = "Permanently mute a player so they can't chat") { args -> + val target = Players.find(args[0]) + if (target == null) { + message("Unable to find player '${args[0]}'.") + return@modCommand + } + if (target.blackMarks < BLACK_MARK_LIMIT) { + message("${target.name} has ${target.blackMarks} black marks; $BLACK_MARK_LIMIT are required for a permanent mute.") + return@modCommand + } + target.permMute() + message("${target.name} has been permanently muted.") + AuditLog.event(this, "perm_muted", target) + } + + modCommand("unmute", stringArg("player-name", autofill = accounts.displayNames.keys), desc = "Remove a player's mute") { args -> + val target = Players.find(args[0]) + if (target == null) { + message("Unable to find player '${args[0]}'.") + return@modCommand + } + target.unmute() + message("${target.name} has been unmuted.") + AuditLog.event(this, "unmuted", target) + } + } +} diff --git a/game/src/main/kotlin/content/social/report/ReportAbuse.kt b/game/src/main/kotlin/content/social/report/ReportAbuse.kt index 26650def55..1e47bb7167 100644 --- a/game/src/main/kotlin/content/social/report/ReportAbuse.kt +++ b/game/src/main/kotlin/content/social/report/ReportAbuse.kt @@ -1,11 +1,25 @@ package content.social.report +import content.social.chat.ChatHistory import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.instruction.instruction import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.hasMenuOpen import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.client.variable.hasClock +import world.gregs.voidps.engine.client.variable.start +import world.gregs.voidps.engine.data.AbuseReport +import world.gregs.voidps.engine.data.Reports +import world.gregs.voidps.engine.data.definition.AccountDefinitions +import world.gregs.voidps.engine.entity.character.player.PlayerRights +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.character.player.hasRights +import world.gregs.voidps.engine.entity.character.player.isMod +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.engine.event.AuditLog +import world.gregs.voidps.network.client.instruction.ReportAbuse -class ReportAbuse : Script { +class ReportAbuse(val reports: Reports, val accounts: AccountDefinitions) : Script { init { interfaceOption("Report Abuse", "filter_buttons:report") { @@ -13,7 +27,61 @@ class ReportAbuse : Script { message("Please finish what you're doing first.") return@interfaceOption } - open("report_abuse_player_security") + open("report_abuse") + } + + interfaceOpened("report_abuse") { + if (hasRights(PlayerRights.Mod)) { + interfaces.sendVisibility("report_abuse", "mute_confirm", true) + interfaces.sendVisibility("report_abuse", "mute_entry", true) + interfaces.sendVisibility("report_abuse", "mute_select", true) + } + } + + instruction { player -> + val rule = Rule.byId(type) ?: return@instruction + val name = name.trim() + if (name.isEmpty()) { + return@instruction + } + if (name.equals(player.name, true)) { + player.message("You can't report yourself.") + return@instruction + } + if (player.hasClock("report_abuse_delay")) { + player.message("You can only submit one report per minute.") + return@instruction + } + val target = Players.find(name) + val account = target?.accountName ?: accounts.get(name)?.accountName + if (account == null) { + player.message("Unable to find player '$name'.") + return@instruction + } + player.start("report_abuse_delay", 100) + val muted = mute != 0 && player.hasRights(PlayerRights.Mod) + reports.queue( + AbuseReport( + reporter = player.accountName, + reported = name, + rule = rule.id, + ruleName = rule.title, + mute = muted, + suggestion = suggestion, + time = System.currentTimeMillis(), + evidence = ChatHistory.recent(account), + ), + ) + AuditLog.event(player, "report_abuse", target ?: name, rule.name, muted) + if (muted) { + target?.mute() + } + for (mod in Players) { + if (mod.isMod()) { + mod.message("${player.name} reported $name for ${rule.title}.") + } + } + player.message("Thank-you, your abuse report has been received.") } } } diff --git a/game/src/main/kotlin/content/social/report/Rule.kt b/game/src/main/kotlin/content/social/report/Rule.kt new file mode 100644 index 0000000000..97f53b316e --- /dev/null +++ b/game/src/main/kotlin/content/social/report/Rule.kt @@ -0,0 +1,29 @@ +package content.social.report + +/** + * Rules a player can be reported for breaking, grouped in the report interface + * under Honour, Respect and Security + * @param id The identifier sent by the client's report interface + * @param title The rule name as shown on the report interface + */ +enum class Rule(val id: Int, val title: String) { + BuyingOrSellingAnAccount(6, "Buying or selling an account"), + EncouragingRuleBreaking(9, "Encouraging rule breaking"), + StaffImpersonation(5, "Staff impersonation"), + MacroingOrUseOfBots(7, "Macroing or use of bots"), + Scamming(15, "Scamming"), + ExploitingABug(4, "Exploiting a bug"), + SeriouslyOffensiveLanguage(16, "Seriously offensive language"), + Solicitation(17, "Solicitation"), + DisruptiveBehaviour(18, "Disruptive behaviour"), + OffensiveAccountName(19, "Offensive account name"), + RealLifeThreats(20, "Real-life threats"), + AskingForOrProvidingContactInformation(13, "Asking for or providing contact information"), + BreakingRealWorldLaws(21, "Breaking real-world laws"), + AdvertisingWebsites(11, "Advertising websites"), + ; + + companion object { + fun byId(id: Int): Rule? = entries.firstOrNull { it.id == id } + } +} diff --git a/game/src/main/resources/game.properties b/game/src/main/resources/game.properties index 01c86a335c..8835bf5792 100644 --- a/game/src/main/resources/game.properties +++ b/game/src/main/resources/game.properties @@ -371,6 +371,9 @@ storage.grand.exchange.offers.sell.path=grand_exchange/sell_offers/ storage.grand.exchange.offers.claim.path=grand_exchange/claimable_offers.toml storage.grand.exchange.history.path=grand_exchange/price_history +# Directory where abuse reports are stored +storage.reports.path=reports/ + # Database configuration (uncomment to enable database storage) #storage.database.username=postgres #storage.database.password=password diff --git a/game/src/test/kotlin/content/social/report/PunishmentsTest.kt b/game/src/test/kotlin/content/social/report/PunishmentsTest.kt new file mode 100644 index 0000000000..55d35b40c0 --- /dev/null +++ b/game/src/test/kotlin/content/social/report/PunishmentsTest.kt @@ -0,0 +1,55 @@ +package content.social.report + +import WorldTest +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class PunishmentsTest : WorldTest() { + + @Test + fun `Black marks accumulate up to the permanent punishment limit`() = runTest { + val player = createPlayer(name = "offender") + + repeat(BLACK_MARK_LIMIT) { + player.addBlackMark(Rule.MacroingOrUseOfBots) + } + + assertEquals(BLACK_MARK_LIMIT, player.blackMarks) + } + + @Test + fun `Expired black marks degrade`() = runTest { + val player = createPlayer(name = "offender") + player["black_marks"] = listOf("7:1", "15:${Long.MAX_VALUE}") + + assertEquals(1, player.blackMarks) + assertEquals(listOf("15:${Long.MAX_VALUE}"), player.activeBlackMarks()) + } + + @Test + fun `Real world trading marks never expire`() = runTest { + val player = createPlayer(name = "offender") + + player.addBlackMark(Rule.BreakingRealWorldLaws) + + assertTrue(player.activeBlackMarks().single().endsWith(":${Long.MAX_VALUE}")) + } + + @Test + fun `Banned player flag`() = runTest { + val player = createPlayer(name = "offender") + assertFalse(player.isBanned) + + player.ban(48) + assertTrue(player.isBanned) + + player.unban() + assertFalse(player.isBanned) + + player.permBan() + assertTrue(player.isBanned) + } +} diff --git a/game/src/test/kotlin/content/social/report/ReportAbuseTest.kt b/game/src/test/kotlin/content/social/report/ReportAbuseTest.kt new file mode 100644 index 0000000000..c386fa41f2 --- /dev/null +++ b/game/src/test/kotlin/content/social/report/ReportAbuseTest.kt @@ -0,0 +1,213 @@ +package content.social.report + +import WorldTest +import interfaceOption +import io.mockk.mockkStatic +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import world.gregs.voidps.cache.definition.data.InterfaceDefinition +import world.gregs.voidps.engine.client.ui.hasOpen +import world.gregs.voidps.engine.entity.character.player.PlayerRights +import world.gregs.voidps.engine.entity.character.player.chat.ChatType +import world.gregs.voidps.engine.entity.character.player.rights +import world.gregs.voidps.network.client.instruction.ChatPublic +import world.gregs.voidps.network.client.instruction.ReportAbuse +import world.gregs.voidps.network.login.protocol.encode.interfaceVisibility +import world.gregs.voidps.network.login.protocol.encode.message +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class ReportAbuseTest : WorldTest() { + + @BeforeAll + fun start() { + mockkStatic("world.gregs.voidps.network.login.protocol.encode.ChatEncoderKt") + mockkStatic("world.gregs.voidps.network.login.protocol.encode.InterfaceEncodersKt") + } + + @Test + fun `Report a player`() = runTest { + val (player, client) = createClient("snitch") + createPlayer(name = "scammer") + + player.instructions.send(ReportAbuse("scammer", 15, 0, "")) + tick() + + verify { + client.message("Thank-you, your abuse report has been received.", ChatType.Game.id) + } + } + + @Test + fun `Can't report yourself`() = runTest { + val (player, client) = createClient("snitch") + + player.instructions.send(ReportAbuse("snitch", 15, 0, "")) + tick() + + verify { + client.message("You can't report yourself.", ChatType.Game.id) + } + } + + @Test + fun `Can't report unknown player`() = runTest { + val (player, client) = createClient("snitch") + + player.instructions.send(ReportAbuse("nobody", 15, 0, "")) + tick() + + verify { + client.message("Unable to find player 'nobody'.", ChatType.Game.id) + } + } + + @Test + fun `Can't report twice within a minute`() = runTest { + val (player, client) = createClient("snitch") + createPlayer(name = "scammer") + createPlayer(name = "macroer") + + player.instructions.send(ReportAbuse("scammer", 15, 0, "")) + tick() + player.instructions.send(ReportAbuse("macroer", 7, 0, "")) + tick() + + verify { + client.message("You can only submit one report per minute.", ChatType.Game.id) + } + } + + @Test + fun `Invalid rule is ignored`() = runTest { + val (player, client) = createClient("snitch") + createPlayer(name = "scammer") + + player.instructions.send(ReportAbuse("scammer", 0, 0, "")) + tick() + + verify(exactly = 0) { + client.message("Thank-you, your abuse report has been received.", ChatType.Game.id) + } + } + + @Test + fun `Moderator report with mute flag mutes the target`() = runTest { + val player = createPlayer(name = "mod_steve") + player.rights = PlayerRights.Mod + val target = createPlayer(name = "offender") + + player.instructions.send(ReportAbuse("offender", 16, 1, "")) + tick() + + assertTrue(target.isMuted) + } + + @Test + fun `Regular player report with mute flag doesn't mute`() = runTest { + val player = createPlayer(name = "snitch") + val target = createPlayer(name = "offender") + + player.instructions.send(ReportAbuse("offender", 16, 1, "")) + tick() + + assertFalse(target.isMuted) + } + + @Test + fun `Online moderators are notified of reports`() = runTest { + val player = createPlayer(name = "snitch") + createPlayer(name = "scammer") + val (mod, modClient) = createClient("mod_steve") + mod.rights = PlayerRights.Mod + + player.instructions.send(ReportAbuse("scammer", 15, 0, "")) + tick() + + verify { + modClient.message("snitch reported scammer for Scamming.", ChatType.Game.id) + } + } + + @Test + fun `Temporarily muted player can't chat`() = runTest { + val (player, client) = createClient("offender") + player.mute() + + player.instructions.send(ChatPublic("hello", 0)) + tick() + + verify { + client.message("You are temporarily muted because of breaking a rule. This mute will remain for a", ChatType.Game.id) + client.message("further 2 days. To prevent further mutes please read the rules.", ChatType.Game.id) + } + } + + @Test + fun `Permanently muted player can't chat`() = runTest { + val (player, client) = createClient("offender") + player.permMute() + + player.instructions.send(ChatPublic("hello", 0)) + tick() + + verify { + client.message("You are permanently muted because of breaking a rule.", ChatType.Game.id) + } + } + + @Test + fun `Unmuted player can chat again`() = runTest { + val (player, client) = createClient("offender") + player.permMute() + player.unmute() + + player.instructions.send(ChatPublic("hello", 0)) + tick() + + verify(exactly = 0) { + client.message("You are permanently muted because of breaking a rule.", ChatType.Game.id) + } + } + + @Test + fun `Report button opens the report abuse interface`() = runTest { + val (player, _) = createClient("player") + + player.interfaceOption("filter_buttons", "report", "Report Abuse") + tick() + + assertTrue(player.hasOpen("report_abuse")) + } + + @Test + fun `Mute toggle is revealed for moderators`() = runTest { + val (player, client) = createClient("mod_steve") + player.rights = PlayerRights.Mod + + player.interfaceOption("filter_buttons", "report", "Report Abuse") + tick() + + verify { + client.interfaceVisibility(InterfaceDefinition.pack(594, 8), false) + client.interfaceVisibility(InterfaceDefinition.pack(594, 52), false) + client.interfaceVisibility(InterfaceDefinition.pack(594, 66), false) + } + } + + @Test + fun `Mute toggle stays hidden for regular players`() = runTest { + val (player, client) = createClient("player") + + player.interfaceOption("filter_buttons", "report", "Report Abuse") + tick() + + verify(exactly = 0) { + client.interfaceVisibility(InterfaceDefinition.pack(594, 8), false) + client.interfaceVisibility(InterfaceDefinition.pack(594, 52), false) + client.interfaceVisibility(InterfaceDefinition.pack(594, 66), false) + } + } +} diff --git a/network/src/main/kotlin/world/gregs/voidps/network/client/instruction/ReportAbuse.kt b/network/src/main/kotlin/world/gregs/voidps/network/client/instruction/ReportAbuse.kt index 9a672ee215..8b9ef00591 100644 --- a/network/src/main/kotlin/world/gregs/voidps/network/client/instruction/ReportAbuse.kt +++ b/network/src/main/kotlin/world/gregs/voidps/network/client/instruction/ReportAbuse.kt @@ -6,12 +6,12 @@ import world.gregs.voidps.network.client.Instruction * Client report about another player * @param name The display name of the accused player * @param type The type of offence supposedly committed - * @param integer Unknown - * @param string Unknown + * @param mute Whether the reporter requested the accused be muted (moderators only) + * @param suggestion Additional text submitted with the report */ data class ReportAbuse( val name: String, val type: Int, - val integer: Int, - val string: String, + val mute: Int, + val suggestion: String, ) : Instruction diff --git a/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/decode/ReportAbuseDecoder.kt b/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/decode/ReportAbuseDecoder.kt index e008c278aa..02e007b29b 100644 --- a/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/decode/ReportAbuseDecoder.kt +++ b/network/src/main/kotlin/world/gregs/voidps/network/login/protocol/decode/ReportAbuseDecoder.kt @@ -11,8 +11,8 @@ class ReportAbuseDecoder : Decoder(BYTE) { override suspend fun decode(packet: Source): Instruction { val name = packet.readString() val type = packet.readByte().toInt() - val integer = packet.readByte().toInt() - val string = packet.readString() - return ReportAbuse(name, type, integer, string) + val mute = packet.readByte().toInt() + val suggestion = packet.readString() + return ReportAbuse(name, type, mute, suggestion) } } From cf6d8240fd4251a5ed6176410a232df8bad52b58 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 10 Jun 2026 09:07:04 -0700 Subject: [PATCH 02/15] chore: clean up pass --- .../player/dialogue/dialogue.ifaces.toml | 3 ++ .../modal/chat_box/chat_box.ifaces.toml | 3 ++ .../content/social/report/BlackMarks.kt | 4 +-- .../content/social/report/ReportAbuse.kt | 25 ++++++++++++++-- .../content/social/report/ReportAbuseTest.kt | 30 +++++++++++++++++++ 5 files changed, 60 insertions(+), 5 deletions(-) diff --git a/data/entity/player/dialogue/dialogue.ifaces.toml b/data/entity/player/dialogue/dialogue.ifaces.toml index f18f3dd1a4..510aca9210 100644 --- a/data/entity/player/dialogue/dialogue.ifaces.toml +++ b/data/entity/player/dialogue/dialogue.ifaces.toml @@ -258,6 +258,9 @@ id = 5 [.close_quick_chat] id = 4 +[.chat_line0] +id = "180-279" + [dialogue_select2_models] id = 140 type = "dialogue_box" diff --git a/data/entity/player/modal/chat_box/chat_box.ifaces.toml b/data/entity/player/modal/chat_box/chat_box.ifaces.toml index 7bde85ad0f..3d2d49d055 100644 --- a/data/entity/player/modal/chat_box/chat_box.ifaces.toml +++ b/data/entity/player/modal/chat_box/chat_box.ifaces.toml @@ -34,3 +34,6 @@ type = "chat_box" id = 754 type = "private_chat" +[.line1] +id = "1-5" + diff --git a/game/src/main/kotlin/content/social/report/BlackMarks.kt b/game/src/main/kotlin/content/social/report/BlackMarks.kt index 601f490814..e4e0d25bd7 100644 --- a/game/src/main/kotlin/content/social/report/BlackMarks.kt +++ b/game/src/main/kotlin/content/social/report/BlackMarks.kt @@ -25,7 +25,7 @@ import java.util.concurrent.TimeUnit */ const val BLACK_MARK_LIMIT = 10 -private val EXPIRY_MONTHS = TimeUnit.DAYS.toMillis(365) +private val TWELVE_MONTHS = TimeUnit.DAYS.toMillis(365) private val PERMANENT_RULES = setOf(Rule.BreakingRealWorldLaws) private val DATE_FORMAT = DateTimeFormatter.ofPattern("d MMM yyyy").withZone(ZoneOffset.UTC) @@ -54,7 +54,7 @@ fun activeBlackMarks(marks: List): List { } fun Player.addBlackMark(rule: Rule) { - val expiry = if (rule in PERMANENT_RULES) PERMANENT else System.currentTimeMillis() + EXPIRY_MONTHS + val expiry = if (rule in PERMANENT_RULES) PERMANENT else System.currentTimeMillis() + TWELVE_MONTHS this["black_marks"] = activeBlackMarks() + "${rule.id}:$expiry" } diff --git a/game/src/main/kotlin/content/social/report/ReportAbuse.kt b/game/src/main/kotlin/content/social/report/ReportAbuse.kt index 1e47bb7167..1ad36722fe 100644 --- a/game/src/main/kotlin/content/social/report/ReportAbuse.kt +++ b/game/src/main/kotlin/content/social/report/ReportAbuse.kt @@ -11,6 +11,7 @@ import world.gregs.voidps.engine.client.variable.start import world.gregs.voidps.engine.data.AbuseReport import world.gregs.voidps.engine.data.Reports import world.gregs.voidps.engine.data.definition.AccountDefinitions +import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.PlayerRights import world.gregs.voidps.engine.entity.character.player.Players import world.gregs.voidps.engine.entity.character.player.hasRights @@ -23,11 +24,21 @@ class ReportAbuse(val reports: Reports, val accounts: AccountDefinitions) : Scri init { interfaceOption("Report Abuse", "filter_buttons:report") { - if (hasMenuOpen()) { - message("Please finish what you're doing first.") + openReportAbuse(this) + } + + // Chat line ops are set at runtime by client scripts over placeholder strings in the cache, + // so the option resolves to "8" rather than "Report abuse"; match by index instead. + // The client keeps the reported name in a varc string for the report abuse interface. + interfaceOption("*", "chat_background:chat_line*") { + if (it.optionIndex != 7) { return@interfaceOption } - open("report_abuse") + openReportAbuse(this) + } + + interfaceOption("Report Abuse", "private_chat:line*") { + openReportAbuse(this) } interfaceOpened("report_abuse") { @@ -84,4 +95,12 @@ class ReportAbuse(val reports: Reports, val accounts: AccountDefinitions) : Scri player.message("Thank-you, your abuse report has been received.") } } + + fun openReportAbuse(player: Player) { + if (player.hasMenuOpen()) { + player.message("Please finish what you're doing first.") + return + } + player.open("report_abuse") + } } diff --git a/game/src/test/kotlin/content/social/report/ReportAbuseTest.kt b/game/src/test/kotlin/content/social/report/ReportAbuseTest.kt index c386fa41f2..2ba287ccfd 100644 --- a/game/src/test/kotlin/content/social/report/ReportAbuseTest.kt +++ b/game/src/test/kotlin/content/social/report/ReportAbuseTest.kt @@ -182,6 +182,36 @@ internal class ReportAbuseTest : WorldTest() { assertTrue(player.hasOpen("report_abuse")) } + @Test + fun `Chat line report abuse option opens the report abuse interface`() = runTest { + val (player, _) = createClient("player") + + player.interfaceOption("chat_background", "chat_line7", optionIndex = 7) + tick() + + assertTrue(player.hasOpen("report_abuse")) + } + + @Test + fun `Other chat line options don't open the report abuse interface`() = runTest { + val (player, _) = createClient("player") + + player.interfaceOption("chat_background", "chat_line1", optionIndex = 9) + tick() + + assertFalse(player.hasOpen("report_abuse")) + } + + @Test + fun `Private chat report abuse option opens the report abuse interface`() = runTest { + val (player, _) = createClient("player") + + player.interfaceOption("private_chat", "line1", "Report Abuse") + tick() + + assertTrue(player.hasOpen("report_abuse")) + } + @Test fun `Mute toggle is revealed for moderators`() = runTest { val (player, client) = createClient("mod_steve") From 675bac4a0d289a1d0f77ff3c8e6b0c2315f90bf5 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 10 Jun 2026 09:28:13 -0700 Subject: [PATCH 03/15] feat: add black marks on mute and ban Mutes add one black mark, bans two (including offline bans), so punishments accumulate toward the permanent punishment limit. Report-issued mutes record the rule broken. --- .../main/kotlin/content/social/report/Ban.kt | 21 +++++++++++++-- .../content/social/report/BlackMarks.kt | 18 +++++++++---- .../main/kotlin/content/social/report/Mute.kt | 3 ++- .../content/social/report/ReportAbuse.kt | 2 +- .../content/social/report/PunishmentsTest.kt | 27 +++++++++++++++++++ 5 files changed, 62 insertions(+), 9 deletions(-) diff --git a/game/src/main/kotlin/content/social/report/Ban.kt b/game/src/main/kotlin/content/social/report/Ban.kt index f7b1f83b2a..cdc0fb28a8 100644 --- a/game/src/main/kotlin/content/social/report/Ban.kt +++ b/game/src/main/kotlin/content/social/report/Ban.kt @@ -16,8 +16,11 @@ import java.util.concurrent.TimeUnit val Player.isBanned: Boolean get() = this["banned_until", 0L] > System.currentTimeMillis() -fun Player.ban(hours: Int = 48) { +fun Player.ban(hours: Int = 48, rule: Rule? = null) { this["banned_until"] = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(hours.toLong()) + repeat(2) { + addBlackMark(rule) + } } fun Player.permBan() { @@ -39,7 +42,7 @@ class Ban(val accounts: AccountDefinitions, val manager: AccountManager, val sto target.ban(hours) AuditLog.event(this, "banned", target, hours) manager.logout(target, false) - } else if (!setOfflineVariable(args[0], "banned_until", until)) { + } else if (!banOffline(args[0], until)) { message("Unable to find player '${args[0]}'.") return@modCommand } else { @@ -88,6 +91,20 @@ class Ban(val accounts: AccountDefinitions, val manager: AccountManager, val sto } } + /** + * Bans an offline player's saved account and adds two black marks + */ + private fun banOffline(displayName: String, until: Long): Boolean { + val account = accounts.get(displayName)?.accountName ?: displayName + val save = storage.load(account) ?: return false + val variables = save.variables.toMutableMap() + variables["banned_until"] = until + val marks = activeBlackMarks((variables["black_marks"] as? List<*>)?.filterIsInstance() ?: emptyList()) + variables["black_marks"] = marks + blackMark() + blackMark() + storage.save(listOf(save.copy(variables = variables))) + return true + } + /** * Updates a variable on an offline player's saved account */ diff --git a/game/src/main/kotlin/content/social/report/BlackMarks.kt b/game/src/main/kotlin/content/social/report/BlackMarks.kt index e4e0d25bd7..eedb35472d 100644 --- a/game/src/main/kotlin/content/social/report/BlackMarks.kt +++ b/game/src/main/kotlin/content/social/report/BlackMarks.kt @@ -53,18 +53,26 @@ fun activeBlackMarks(marks: List): List { return marks.filter { expiry(it) > now } } -fun Player.addBlackMark(rule: Rule) { - val expiry = if (rule in PERMANENT_RULES) PERMANENT else System.currentTimeMillis() + TWELVE_MONTHS - this["black_marks"] = activeBlackMarks() + "${rule.id}:$expiry" +fun Player.addBlackMark(rule: Rule? = null) { + this["black_marks"] = activeBlackMarks() + blackMark(rule) +} + +/** + * A black mark entry for breaking [rule], or at a moderator's discretion when no rule is given + */ +fun blackMark(rule: Rule? = null): String { + val expiry = if (rule != null && rule in PERMANENT_RULES) PERMANENT else System.currentTimeMillis() + TWELVE_MONTHS + return "${rule?.id ?: -1}:$expiry" } private fun expiry(mark: String): Long = mark.substringAfter(':').toLongOrNull() ?: 0L private fun describe(mark: String): String { - val rule = Rule.byId(mark.substringBefore(':').toIntOrNull() ?: -1) + val id = mark.substringBefore(':').toIntOrNull() ?: -1 + val title = if (id == -1) "Moderator discretion" else Rule.byId(id)?.title ?: "Unknown offence" val expiry = expiry(mark) val expires = if (expiry == PERMANENT) "never expires" else "expires ${DATE_FORMAT.format(Instant.ofEpochMilli(expiry))}" - return "${rule?.title ?: "Unknown offence"} - $expires" + return "$title - $expires" } class BlackMarks(val accounts: AccountDefinitions) : Script { diff --git a/game/src/main/kotlin/content/social/report/Mute.kt b/game/src/main/kotlin/content/social/report/Mute.kt index 95a98ec8fb..ba9a7ab607 100644 --- a/game/src/main/kotlin/content/social/report/Mute.kt +++ b/game/src/main/kotlin/content/social/report/Mute.kt @@ -19,8 +19,9 @@ const val PERMANENT = Long.MAX_VALUE val Player.isMuted: Boolean get() = this["muted_until", 0L] > System.currentTimeMillis() -fun Player.mute(hours: Int = 48) { +fun Player.mute(hours: Int = 48, rule: Rule? = null) { this["muted_until"] = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(hours.toLong()) + addBlackMark(rule) message("You have been temporarily muted due to breaking a rule.") } diff --git a/game/src/main/kotlin/content/social/report/ReportAbuse.kt b/game/src/main/kotlin/content/social/report/ReportAbuse.kt index 1ad36722fe..4c158089b9 100644 --- a/game/src/main/kotlin/content/social/report/ReportAbuse.kt +++ b/game/src/main/kotlin/content/social/report/ReportAbuse.kt @@ -85,7 +85,7 @@ class ReportAbuse(val reports: Reports, val accounts: AccountDefinitions) : Scri ) AuditLog.event(player, "report_abuse", target ?: name, rule.name, muted) if (muted) { - target?.mute() + target?.mute(rule = rule) } for (mod in Players) { if (mod.isMod()) { diff --git a/game/src/test/kotlin/content/social/report/PunishmentsTest.kt b/game/src/test/kotlin/content/social/report/PunishmentsTest.kt index 55d35b40c0..54f9267bea 100644 --- a/game/src/test/kotlin/content/social/report/PunishmentsTest.kt +++ b/game/src/test/kotlin/content/social/report/PunishmentsTest.kt @@ -38,6 +38,33 @@ internal class PunishmentsTest : WorldTest() { assertTrue(player.activeBlackMarks().single().endsWith(":${Long.MAX_VALUE}")) } + @Test + fun `Mute adds a black mark`() = runTest { + val player = createPlayer(name = "offender") + + player.mute() + + assertEquals(1, player.blackMarks) + } + + @Test + fun `Mute for a report records the rule broken`() = runTest { + val player = createPlayer(name = "offender") + + player.mute(rule = Rule.Scamming) + + assertTrue(player.activeBlackMarks().single().startsWith("${Rule.Scamming.id}:")) + } + + @Test + fun `Ban adds two black marks`() = runTest { + val player = createPlayer(name = "offender") + + player.ban() + + assertEquals(2, player.blackMarks) + } + @Test fun `Banned player flag`() = runTest { val player = createPlayer(name = "offender") From 07d44f41793a209ca0abded44ab78b90900ebe18 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Wed, 10 Jun 2026 09:33:54 -0700 Subject: [PATCH 04/15] fix: ban adds a single black mark Appending two identical marks in the same millisecond caused duplicate entries to be dropped. --- game/src/main/kotlin/content/social/report/Ban.kt | 8 +++----- .../test/kotlin/content/social/report/PunishmentsTest.kt | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/game/src/main/kotlin/content/social/report/Ban.kt b/game/src/main/kotlin/content/social/report/Ban.kt index cdc0fb28a8..4bdae6510e 100644 --- a/game/src/main/kotlin/content/social/report/Ban.kt +++ b/game/src/main/kotlin/content/social/report/Ban.kt @@ -18,9 +18,7 @@ val Player.isBanned: Boolean fun Player.ban(hours: Int = 48, rule: Rule? = null) { this["banned_until"] = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(hours.toLong()) - repeat(2) { - addBlackMark(rule) - } + addBlackMark(rule) } fun Player.permBan() { @@ -92,7 +90,7 @@ class Ban(val accounts: AccountDefinitions, val manager: AccountManager, val sto } /** - * Bans an offline player's saved account and adds two black marks + * Bans an offline player's saved account and adds a black mark */ private fun banOffline(displayName: String, until: Long): Boolean { val account = accounts.get(displayName)?.accountName ?: displayName @@ -100,7 +98,7 @@ class Ban(val accounts: AccountDefinitions, val manager: AccountManager, val sto val variables = save.variables.toMutableMap() variables["banned_until"] = until val marks = activeBlackMarks((variables["black_marks"] as? List<*>)?.filterIsInstance() ?: emptyList()) - variables["black_marks"] = marks + blackMark() + blackMark() + variables["black_marks"] = marks + blackMark() storage.save(listOf(save.copy(variables = variables))) return true } diff --git a/game/src/test/kotlin/content/social/report/PunishmentsTest.kt b/game/src/test/kotlin/content/social/report/PunishmentsTest.kt index 54f9267bea..40deecd27a 100644 --- a/game/src/test/kotlin/content/social/report/PunishmentsTest.kt +++ b/game/src/test/kotlin/content/social/report/PunishmentsTest.kt @@ -57,12 +57,12 @@ internal class PunishmentsTest : WorldTest() { } @Test - fun `Ban adds two black marks`() = runTest { + fun `Ban adds a black mark`() = runTest { val player = createPlayer(name = "offender") player.ban() - assertEquals(2, player.blackMarks) + assertEquals(1, player.blackMarks) } @Test From d067aa8b52e332fd612c20c5de7a32c617ddd6a2 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Mon, 22 Jun 2026 16:04:15 -0700 Subject: [PATCH 05/15] fix: register Reports in Koin module ReportAbuse script injected Reports but no single was bound, causing NoDefinitionFoundException at startup. --- game/src/main/kotlin/GameModules.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/game/src/main/kotlin/GameModules.kt b/game/src/main/kotlin/GameModules.kt index b20c936873..530d1d7e23 100644 --- a/game/src/main/kotlin/GameModules.kt +++ b/game/src/main/kotlin/GameModules.kt @@ -11,6 +11,7 @@ import org.koin.dsl.module import world.gregs.voidps.engine.client.instruction.InstructionHandlers import world.gregs.voidps.engine.client.instruction.InterfaceHandler import world.gregs.voidps.engine.data.ConfigFiles +import world.gregs.voidps.engine.data.Reports import world.gregs.voidps.engine.data.Settings import world.gregs.voidps.engine.data.Storage import world.gregs.voidps.engine.data.file.FileStorage @@ -19,6 +20,7 @@ import java.io.File fun gameModule(files: ConfigFiles) = module { single { ItemSpawns() } + single { Reports(get()) } single { BotManager().load(files) } single { loadGraph(files) } single(createdAtStart = true) { Books().load(files.list(Settings["definitions.books"])) } From aa70942fbebbb1cf83b9a95274fdebe465d2717f Mon Sep 17 00:00:00 2001 From: Harley Gilpin <75695035+HarleyGilpin@users.noreply.github.com> Date: Sun, 28 Jun 2026 05:59:09 -0700 Subject: [PATCH 06/15] Update game/src/main/kotlin/content/social/report/Mute.kt Co-authored-by: Greg --- game/src/main/kotlin/content/social/report/Mute.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/game/src/main/kotlin/content/social/report/Mute.kt b/game/src/main/kotlin/content/social/report/Mute.kt index ba9a7ab607..0d89373ce6 100644 --- a/game/src/main/kotlin/content/social/report/Mute.kt +++ b/game/src/main/kotlin/content/social/report/Mute.kt @@ -63,7 +63,7 @@ class Mute(val accounts: AccountDefinitions) : Script { AuditLog.event(this, "muted", target, hours) } - modCommand("permmute", stringArg("player-name", autofill = accounts.displayNames.keys), desc = "Permanently mute a player so they can't chat") { args -> + modCommand("perm_mute", stringArg("player-name", autofill = accounts.displayNames.keys), desc = "Permanently mute a player so they can't chat") { args -> val target = Players.find(args[0]) if (target == null) { message("Unable to find player '${args[0]}'.") From f26160485487b112644b34ea11cec7099f8982d4 Mon Sep 17 00:00:00 2001 From: Mikrofin <207991987+M1krofin@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:53:47 +0300 Subject: [PATCH 07/15] Cleanup to Groats farm (12851) (#1040) * Added object examines * Added haybales, sacks, Groats farm crates object operations. * Added Seth Groats dialogue * Added water barrel functionality and changed bucket message to the correct one. * Farmer combat, sound, animations and drop table added. Added evil turnip seed to allotment seed table * Fixed ducks and drake animation, sounds and combat def Quack! * Spotless * One fix from last commit * Combined drops --- .../taverley/taverley.npc-spawns.toml | 4 +- .../lumbridge/freds_farm/freds_farm.npcs.toml | 2 +- .../groats_farm/groats_farm.objs.toml | 95 +++++++++++++++++++ .../misthalin/lumbridge/lumbridge.anims.toml | 6 -- .../misthalin/lumbridge/lumbridge.combat.toml | 11 --- .../misthalin/lumbridge/lumbridge.drops.toml | 7 -- .../misthalin/lumbridge/lumbridge.npcs.toml | 2 + .../misthalin/lumbridge/lumbridge.objs.toml | 4 +- data/entity/npc/animal/birds/birds.anims.toml | 11 ++- .../entity/npc/animal/birds/birds.combat.toml | 21 ++++ data/entity/npc/animal/birds/birds.npcs.toml | 13 ++- .../entity/npc/animal/birds/birds.sounds.toml | 8 ++ .../npc/humanoid/farmer/farmer.anims.toml | 10 ++ .../npc/humanoid/farmer/farmer.combat.toml | 12 +++ .../npc/humanoid/farmer/farmer.drops.toml | 44 +++++++++ .../npc/humanoid/farmer/farmer.sounds.toml | 2 + data/entity/npc/misc.drops.toml | 1 + data/entity/obj/crates.objs.toml | 4 +- data/entity/obj/plants_flowers.objs.toml | 4 + data/entity/obj/trees.objs.toml | 4 + .../area/misthalin/lumbridge/Ducklings.kt | 14 +++ .../area/misthalin/lumbridge/farm/HayBales.kt | 47 +++++++++ .../area/misthalin/lumbridge/farm/Sacks.kt | 13 +++ .../misthalin/lumbridge/farm/SethGroats.kt | 14 +++ .../lumbridge/farm/SethGroatsFarm.kt | 5 + .../kotlin/content/skill/cooking/Empty.kt | 6 ++ .../kotlin/content/skill/cooking/Filling.kt | 2 +- 27 files changed, 327 insertions(+), 39 deletions(-) create mode 100644 data/area/misthalin/lumbridge/groats_farm/groats_farm.objs.toml create mode 100644 data/entity/npc/animal/birds/birds.combat.toml create mode 100644 data/entity/npc/animal/birds/birds.sounds.toml create mode 100644 data/entity/npc/humanoid/farmer/farmer.anims.toml create mode 100644 data/entity/npc/humanoid/farmer/farmer.combat.toml create mode 100644 data/entity/npc/humanoid/farmer/farmer.drops.toml create mode 100644 data/entity/npc/humanoid/farmer/farmer.sounds.toml create mode 100644 game/src/main/kotlin/content/area/misthalin/lumbridge/farm/HayBales.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/lumbridge/farm/Sacks.kt create mode 100644 game/src/main/kotlin/content/area/misthalin/lumbridge/farm/SethGroats.kt diff --git a/data/area/asgarnia/taverley/taverley.npc-spawns.toml b/data/area/asgarnia/taverley/taverley.npc-spawns.toml index dc199ec885..176030bee5 100644 --- a/data/area/asgarnia/taverley/taverley.npc-spawns.toml +++ b/data/area/asgarnia/taverley/taverley.npc-spawns.toml @@ -23,7 +23,7 @@ spawns = [ # Taverley { id = "druid_grey", x = 2883, y = 3431, members = true }, { id = "druid_bald_stick", x = 2884, y = 3434, members = true }, - { id = "druid_ponytail", x = 2885, y = 3418, members = true }, + { id = "druid_ponytail_sickle", x = 2885, y = 3418, members = true }, { id = "druidess", x = 2888, y = 3440, members = true }, { id = "druid_bald_stick", x = 2889, y = 3448, members = true }, { id = "druid_sickle", x = 2890, y = 3435, members = true }, @@ -42,7 +42,7 @@ spawns = [ { id = "druid_grey", x = 2898, y = 3432, level = 1, members = true }, { id = "jatix", x = 2899, y = 3429, members = true }, # Taverley, long building - { id = "druid_ponytail", x = 2906, y = 3451, level = 1, members = true }, + { id = "druid_ponytail_sickle", x = 2906, y = 3451, level = 1, members = true }, { id = "bettamax", x = 2889, y = 3443, members = true }, { id = "wilbur", x = 2888, y = 3443, members = true }, ] \ No newline at end of file diff --git a/data/area/misthalin/lumbridge/freds_farm/freds_farm.npcs.toml b/data/area/misthalin/lumbridge/freds_farm/freds_farm.npcs.toml index 688a1cb02b..6a63653a17 100644 --- a/data/area/misthalin/lumbridge/freds_farm/freds_farm.npcs.toml +++ b/data/area/misthalin/lumbridge/freds_farm/freds_farm.npcs.toml @@ -36,7 +36,7 @@ id = 5423 [farmer] id = 7 -examine = "He grows the crops in this area." +clone = "farmer_lumbridge" [hay_bales_2] id = 11625 diff --git a/data/area/misthalin/lumbridge/groats_farm/groats_farm.objs.toml b/data/area/misthalin/lumbridge/groats_farm/groats_farm.objs.toml new file mode 100644 index 0000000000..1250ab7ea2 --- /dev/null +++ b/data/area/misthalin/lumbridge/groats_farm/groats_farm.objs.toml @@ -0,0 +1,95 @@ +[groats_farm_plough] +id = 296 +examine = "It's like a land rudder." + +[groats_farm_scarecrow] +id = 297 +examine = "Disturbingly man-like." + +[groats_farm_sack] +id = 5574 +examine = "Full of animal feed." + +[groats_farm_wheelbarrow] +id = 5580 +examine = "A wooden wheelbarrow." + +[groats_farm_box_cart] +id = 15700 +examine = "A cart for carrying boxes." + +[groats_farm_trapdoor] +id = 15752 +examine = "It probably leads to some sort of cellar." + +[groats_farm_water_wheel] +id = 36867 +examine = "The river makes it spin." + +[groats_farm_water_wheel_2] +id = 36868 +examine = "The river makes it spin." + +[groats_farm_water_wheel_3] +id = 36869 +examine = "The river makes it spin." + +[groats_farm_water_wheel_4] +id = 36871 +examine = "I'd better not get my hands trapped in that." + +[hay_bale_groats_farm] +id = 36893 +examine = "I bet there's a needle in it somewhere." + +[hay_bales_groats_farm] +id = 36895 +examine = "I bet there's a needle in it somewhere." + +[hay_bales_groats_farm_2] +id = 36897 +examine = "I bet there's a needle in it somewhere." + +[sacks_groats_farm] +id = 36929 +examine = "These may have something in them." + +[groats_farm_coop] +id = 36951 +examine = "A lovely chicken coop." + +[groats_farm_coop_2] +id = 36952 +examine = "A lovely chicken coop." + +[groats_farm_coop_3] +id = 36953 +examine = "An home for chickens." + +[groats_farm_shelves] +id = 45254 +examine = "Shelves full of kitchen utensils." + +[groats_farm_shelves_2] +id = 45248 +examine = "Storage for all needs." + +[groats_farm_food_through] +id = 45325 +examine = "Animals have no table manners." + +[water_barrel_groats_farm] +id = 46015 +examine = "A barrel for collecting rainwater." + +[water_barrel_groats_farm_2] +id = 46019 +examine = "A barrel for collecting rainwater." + +[groats_farm_weather_vane] +id = 46020 +examine = "Shows which way the wind blows." + +[groats_farm_wheelbarrow_2] +id = 46021 +examine = "A wooden wheelbarrow." \ No newline at end of file diff --git a/data/area/misthalin/lumbridge/lumbridge.anims.toml b/data/area/misthalin/lumbridge/lumbridge.anims.toml index 429add5288..0dea3cd400 100644 --- a/data/area/misthalin/lumbridge/lumbridge.anims.toml +++ b/data/area/misthalin/lumbridge/lumbridge.anims.toml @@ -23,9 +23,3 @@ id = 3675 [ring_bell] id = 9880 - -[farmer_attack] -id = 433 - -[farmer_defend] -id = 434 diff --git a/data/area/misthalin/lumbridge/lumbridge.combat.toml b/data/area/misthalin/lumbridge/lumbridge.combat.toml index ff7792f4e5..c6faf7ceb5 100644 --- a/data/area/misthalin/lumbridge/lumbridge.combat.toml +++ b/data/area/misthalin/lumbridge/lumbridge.combat.toml @@ -19,14 +19,3 @@ death_sound = "human_death" range = 1 anim = "sword_lunge" target_hit = { offense = "slash", max = 16 } - -[farmer] -attack_speed = 6 -defend_anim = "farmer_defend" -death_anim = "human_death" -death_sound = "human_death" - -[farmer.melee] -range = 1 -anim = "farmer_attack" -target_hit = { offense = "stab", max = 10 } diff --git a/data/area/misthalin/lumbridge/lumbridge.drops.toml b/data/area/misthalin/lumbridge/lumbridge.drops.toml index bb5a25335f..e5cb6fea3b 100644 --- a/data/area/misthalin/lumbridge/lumbridge.drops.toml +++ b/data/area/misthalin/lumbridge/lumbridge.drops.toml @@ -1,10 +1,3 @@ -[farmer_pickpocket] -roll = 128 -drops = [ - { id = "coins", amount = 9, chance = 123 }, - { id = "potato_seed", chance = 5 }, -] - [lumbridge_guard_drop_table] type = "all" drops = [ diff --git a/data/area/misthalin/lumbridge/lumbridge.npcs.toml b/data/area/misthalin/lumbridge/lumbridge.npcs.toml index 641667e21a..cdc6b34398 100644 --- a/data/area/misthalin/lumbridge/lumbridge.npcs.toml +++ b/data/area/misthalin/lumbridge/lumbridge.npcs.toml @@ -227,6 +227,7 @@ def = 8 combat_def = "farmer" attack_bonus = 5 respawn_delay = 25 +drop_table = "farmer_members" examine = "He grows the crops in this area." [farmer_lumbridge_2] @@ -273,6 +274,7 @@ examine = "Perhaps this gardener might look after your crops for you." [farmer_lumbridge_3] id = 1758 examine = "He grows the crops in this area." +clone = "farmer_lumbridge" [sigmund_lumbridge] id = 2079 diff --git a/data/area/misthalin/lumbridge/lumbridge.objs.toml b/data/area/misthalin/lumbridge/lumbridge.objs.toml index e9be8711ae..ad6e7d9a94 100644 --- a/data/area/misthalin/lumbridge/lumbridge.objs.toml +++ b/data/area/misthalin/lumbridge/lumbridge.objs.toml @@ -122,7 +122,7 @@ examine = "A wooden defensive structure." id = 45282 examine = "A wooden barrel for storage." -[lumbridge_sacks] +[sacks_lumbridge] id = 45287 examine = "These may have something in them." @@ -310,7 +310,7 @@ examine = "A painting of the King looking royal." id = 12281 examine = "A small wooden table." -[lumbridge_sacks_2] +[sacks_lumbridge_2] id = 32049 examine = "These may have something in them." diff --git a/data/entity/npc/animal/birds/birds.anims.toml b/data/entity/npc/animal/birds/birds.anims.toml index 1cf1fbd15b..e02e8a6ed3 100644 --- a/data/entity/npc/animal/birds/birds.anims.toml +++ b/data/entity/npc/animal/birds/birds.anims.toml @@ -1,8 +1,11 @@ -[duck_attack] +[duck_attack_walk] id = 6817 -[duck_defend] -id = 3467 +[duck_defend_walk] +id = 1014 -[duck_death] +[duck_death_walk] +id = 750 + +[duck_death_swim] id = 3468 diff --git a/data/entity/npc/animal/birds/birds.combat.toml b/data/entity/npc/animal/birds/birds.combat.toml new file mode 100644 index 0000000000..22739fd321 --- /dev/null +++ b/data/entity/npc/animal/birds/birds.combat.toml @@ -0,0 +1,21 @@ +[duck_swim] +defend_sound = "duck_defend" +death_anim = "duck_death_swim" +death_sound = "duck_death" + +[duck_swim.melee] +range = 1 +target_hit = { offense = "stab", max = 1 } + +[duck_walk] +attack_speed = 4 +defend_anim = "duck_defend_walk" +defend_sound = "duck_defend" +death_anim = "duck_death_walk" +death_sound = "duck_death" + +[duck_walk.melee] +range = 1 +anim = "duck_attack_walk" +target_sound = "duck_quack" +target_hit = { offense = "stab", max = 1 } \ No newline at end of file diff --git a/data/entity/npc/animal/birds/birds.npcs.toml b/data/entity/npc/animal/birds/birds.npcs.toml index fde9644bb9..b747db16fb 100644 --- a/data/entity/npc/animal/birds/birds.npcs.toml +++ b/data/entity/npc/animal/birds/birds.npcs.toml @@ -10,6 +10,7 @@ categories = ["ducks", "birds"] respawn_delay = 25 height = 10 collision = "sea" +combat_def = "duck_swim" examine = "Quackers." [crow] @@ -51,9 +52,16 @@ clone = "crow" id = 4571 [drake] -clone = "duck_swim" id = 6114 +attack_bonus = -47 wander_range = 8 +hitpoints = 30 +slayer_xp = 3.0 +categories = ["ducks", "birds"] +respawn_delay = 25 +height = 10 +drop_table = "bones" +combat_def = "duck_walk" examine = "It quacks like a duck." [ducklings] @@ -64,9 +72,8 @@ collision = "sea" examine = "Aww, cute." [duck_grey_walk] -clone = "duck_swim" id = 6113 -wander_range = 8 +clone = "drake" examine = "Waddle waddle waddle quack." [wimpy_bird] diff --git a/data/entity/npc/animal/birds/birds.sounds.toml b/data/entity/npc/animal/birds/birds.sounds.toml new file mode 100644 index 0000000000..5a655c488a --- /dev/null +++ b/data/entity/npc/animal/birds/birds.sounds.toml @@ -0,0 +1,8 @@ +[duck_death] +id = 411 + +[duck_defend] +id = 412 + +[duck_quack] +id = 413 \ No newline at end of file diff --git a/data/entity/npc/humanoid/farmer/farmer.anims.toml b/data/entity/npc/humanoid/farmer/farmer.anims.toml new file mode 100644 index 0000000000..988256d87e --- /dev/null +++ b/data/entity/npc/humanoid/farmer/farmer.anims.toml @@ -0,0 +1,10 @@ +##based on video:https://youtu.be/BoSFtTZhQds?si=wo4l3wpUAfvv-DrD&t=183 and wiki: 4 January 2011 (Update): A farmer no longer has inappropriate attack animations. All the farmer types had the same attack in this revision. Have to change when next revision. + +[farmer_attack] +id = 7181 + +[farmer_defend] +id = 7186 + +[farmer_death] +id = 836 \ No newline at end of file diff --git a/data/entity/npc/humanoid/farmer/farmer.combat.toml b/data/entity/npc/humanoid/farmer/farmer.combat.toml new file mode 100644 index 0000000000..d6018c894b --- /dev/null +++ b/data/entity/npc/humanoid/farmer/farmer.combat.toml @@ -0,0 +1,12 @@ +[farmer] +attack_speed = 6 +defend_anim = "farmer_defend" +defend_sound = "man_defend" +death_anim = "farmer_death" +death_sound = "man_death" + +[farmer.melee] +range = 1 +anim = "farmer_attack" +target_sound = "farmer_attack" +target_hit = { offense = "stab", max = 10 } \ No newline at end of file diff --git a/data/entity/npc/humanoid/farmer/farmer.drops.toml b/data/entity/npc/humanoid/farmer/farmer.drops.toml new file mode 100644 index 0000000000..82bdfc2e69 --- /dev/null +++ b/data/entity/npc/humanoid/farmer/farmer.drops.toml @@ -0,0 +1,44 @@ +[farmer_pickpocket] +roll = 128 +drops = [ + { id = "coins", amount = 9, chance = 123 }, + { id = "potato_seed", chance = 5 }, +] + +[farmer_members_drop_table] +type = "all" +drops = [ + { table = "bones" }, + { table = "farmer_secondary" }, + { table = "easy_clue_scroll", roll = 128, members = true } +] + +[farmer_secondary] +roll = 128 +drops = [ + { id = "mind_rune", amount = 9, chance = 2 }, + { id = "fire_rune", amount = 6, chance = 2 }, + { id = "earth_rune", amount = 4, chance = 2 }, + { id = "chaos_rune", amount = 2 }, + { id = "coins", amount = 3, chance = 38 }, + { id = "coins", amount = 25 }, + { id = "gardening_boots", chance = 2, members = true }, + { id = "secateurs", members = true }, + { id = "rake", chance = 3, members = true }, + { id = "seed_dibber", chance = 2, members = true }, + { id = "watering_can_8", members = true }, + { id = "earth_talisman", chance = 2 }, + { id = "fishing_bait", chance = 5 }, + { id = "nothing", amount = 0, chance = 28, members = true }, + { table = "herb_drop_table", chance = 11, members = true }, + { table = "allotment_seed_drop_table", chance = 27, members = true }, + { id = "bronze_arrow", amount = 7, chance = 3, members = false }, + { id = "bronze_bolts", min = 2, max = 12, chance = 22, members = false }, + { id = "coins", amount = 5, chance = 9, members = false }, + { id = "coins", amount = 15, chance = 4, members = false }, + { id = "nothing", amount = 0, chance = 8, members = false }, + { id = "copper_ore", chance = 2, members = false }, + { id = "cabbage", chance = 1, members = false }, + { id = "bronze_med_helm", chance = 2, members = false }, + { id = "iron_dagger", members = false }, +] \ No newline at end of file diff --git a/data/entity/npc/humanoid/farmer/farmer.sounds.toml b/data/entity/npc/humanoid/farmer/farmer.sounds.toml new file mode 100644 index 0000000000..3420176053 --- /dev/null +++ b/data/entity/npc/humanoid/farmer/farmer.sounds.toml @@ -0,0 +1,2 @@ +[farmer_attack] +id = 2517 \ No newline at end of file diff --git a/data/entity/npc/misc.drops.toml b/data/entity/npc/misc.drops.toml index 3c9aa90066..bdeb13583e 100644 --- a/data/entity/npc/misc.drops.toml +++ b/data/entity/npc/misc.drops.toml @@ -36,6 +36,7 @@ drops = [ { id = "cabbage_seed", min = 1, max = 3, chance = 16 }, { id = "tomato_seed", min = 1, max = 2, chance = 8 }, { id = "sweetcorn_seed", min = 1, max = 2, chance = 4 }, + { id = "evil_turnip_seed", min = 1, max = 4, chance = 3}, { id = "strawberry_seed", chance = 2 }, { id = "watermelon_seed", chance = 1 } ] diff --git a/data/entity/obj/crates.objs.toml b/data/entity/obj/crates.objs.toml index 10703fed11..df94c2fbf6 100644 --- a/data/entity/obj/crates.objs.toml +++ b/data/entity/obj/crates.objs.toml @@ -489,7 +489,7 @@ examine = "Some wooden crates." id = 15695 examine = "A crate full of machine parts." -[crate_128] +[crate_groats_farm] id = 15696 examine = "An empty crate." @@ -501,7 +501,7 @@ examine = "A crate full of machine parts." id = 15698 examine = "An empty machine parts crate." -[crate_131] +[crate_groats_farm_2] id = 15704 examine = "A discarded crate with a lid." diff --git a/data/entity/obj/plants_flowers.objs.toml b/data/entity/obj/plants_flowers.objs.toml index bda7ca3d6f..f3b9bc4938 100644 --- a/data/entity/obj/plants_flowers.objs.toml +++ b/data/entity/obj/plants_flowers.objs.toml @@ -122,6 +122,10 @@ examine = "A leafy fern." id = 9606 examine = "A leafy shrub." +[bush] +id = 11912 +examine = "A dead bush." + [fairy2_plant_1] id = 16267 examine = "A leafy fern." diff --git a/data/entity/obj/trees.objs.toml b/data/entity/obj/trees.objs.toml index 7dbdf6384a..8eb7452142 100644 --- a/data/entity/obj/trees.objs.toml +++ b/data/entity/obj/trees.objs.toml @@ -33,6 +33,10 @@ examine = "A healthy young tree." id = 7400 examine = "This is what is left of a maple tree." +[tree_6_stump] +id = 9661 +examine = "This tree has been cut down." + [oak] id = 1281 examine = "A beautiful old oak." diff --git a/game/src/main/kotlin/content/area/misthalin/lumbridge/Ducklings.kt b/game/src/main/kotlin/content/area/misthalin/lumbridge/Ducklings.kt index 34e14485bc..678bc48992 100644 --- a/game/src/main/kotlin/content/area/misthalin/lumbridge/Ducklings.kt +++ b/game/src/main/kotlin/content/area/misthalin/lumbridge/Ducklings.kt @@ -1,6 +1,7 @@ package content.area.misthalin.lumbridge import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.areaSound import world.gregs.voidps.engine.entity.character.mode.EmptyMode import world.gregs.voidps.engine.entity.character.mode.Follow import world.gregs.voidps.engine.entity.character.mode.Wander @@ -21,6 +22,19 @@ class Ducklings : Script { ducklings.say("Eek!") followParent(ducklings) } + npcSpawn("duck_*,drake") { + softTimers.start("quack") + } + npcTimerStart("quack") { + // Don't have authentic data. + random.nextInt(50, 150) + } + + npcTimerTick("quack") { + say("Quack!") + areaSound("duck_quack", tile) + Timer.CONTINUE + } } fun isDuck(it: NPC) = it.id.startsWith("duck") && it.id.endsWith("swim") diff --git a/game/src/main/kotlin/content/area/misthalin/lumbridge/farm/HayBales.kt b/game/src/main/kotlin/content/area/misthalin/lumbridge/farm/HayBales.kt new file mode 100644 index 0000000000..a383841cae --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/lumbridge/farm/HayBales.kt @@ -0,0 +1,47 @@ +package content.area.misthalin.lumbridge.farm + +import content.entity.combat.hit.damage +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Sad +import content.entity.player.dialogue.type.player +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.random + +class HayBales : Script { + // https://x.com/JagexAsh/status/1056603985342275585 + + init { + objectOperate("Search", "hay_bales*,hay_bale_*") { (target) -> + anim("climb_down") + // both rs3 and osrs has these messages. + if (target.id.contains("hay_bale_")) { + message("You search the hay bale...") + } else { + message("You search the hay bales...") + } + delay(2) + val roll = random.nextInt(100) + when { + roll < 2 -> { + damage(10) + player("Ow! There's something sharp in there!") + } + roll < 12 -> { + if (inventory.isFull()) { + FloorItems.add(tile, "needle", disappearTicks = 200, owner = this) + } else { + inventory.add("needle") + } + player("Wow! A needle!
Now what are the chances of finding that?") + } + else -> { + message("You find nothing of interest.") + } + } + } + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/lumbridge/farm/Sacks.kt b/game/src/main/kotlin/content/area/misthalin/lumbridge/farm/Sacks.kt new file mode 100644 index 0000000000..4b940899e0 --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/lumbridge/farm/Sacks.kt @@ -0,0 +1,13 @@ +package content.area.misthalin.lumbridge.farm + +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message + +class Sacks : Script { + init { + // both rs3 and osrs has this same message. + objectOperate("Search", "sacks_*") { + message("There's nothing interesting in these sacks.") + } + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/lumbridge/farm/SethGroats.kt b/game/src/main/kotlin/content/area/misthalin/lumbridge/farm/SethGroats.kt new file mode 100644 index 0000000000..3863477d5a --- /dev/null +++ b/game/src/main/kotlin/content/area/misthalin/lumbridge/farm/SethGroats.kt @@ -0,0 +1,14 @@ +package content.area.misthalin.lumbridge.farm + +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import world.gregs.voidps.engine.Script + +class SethGroats : Script { + init { + npcOperate("Talk-to", "seth_groats_lumbridge") { + // both rs3 and osrs has this same dialogue. + npc("M'arnin'....going to milk me cowsies!") + } + } +} diff --git a/game/src/main/kotlin/content/area/misthalin/lumbridge/farm/SethGroatsFarm.kt b/game/src/main/kotlin/content/area/misthalin/lumbridge/farm/SethGroatsFarm.kt index 75aaad0f3d..c48883eb5d 100644 --- a/game/src/main/kotlin/content/area/misthalin/lumbridge/farm/SethGroatsFarm.kt +++ b/game/src/main/kotlin/content/area/misthalin/lumbridge/farm/SethGroatsFarm.kt @@ -33,5 +33,10 @@ class SethGroatsFarm : Script { item.id } } + + objectOperate("Search", "crate_groats_farm*") { + // both rs3 and osrs has this messages. + message("The crate is empty.") + } } } diff --git a/game/src/main/kotlin/content/skill/cooking/Empty.kt b/game/src/main/kotlin/content/skill/cooking/Empty.kt index 116fd13020..8d792596cd 100644 --- a/game/src/main/kotlin/content/skill/cooking/Empty.kt +++ b/game/src/main/kotlin/content/skill/cooking/Empty.kt @@ -19,5 +19,11 @@ class Empty : Script { inventory.replace(slot, item.id, "pie_dish") message("You remove the burnt pie from the pie dish.", ChatType.Filter) } + + itemOption("Empty", "bucket_of_*") { (item, slot) -> + val replacement: String = item.def.getOrNull("empty") ?: return@itemOption + inventory.replace(slot, item.id, replacement) + message("You empty the contents of the bucket on the floor.") + } } } diff --git a/game/src/main/kotlin/content/skill/cooking/Filling.kt b/game/src/main/kotlin/content/skill/cooking/Filling.kt index e3259bea09..0aecf69fbb 100644 --- a/game/src/main/kotlin/content/skill/cooking/Filling.kt +++ b/game/src/main/kotlin/content/skill/cooking/Filling.kt @@ -9,7 +9,7 @@ import world.gregs.voidps.engine.inv.replace class Filling : Script { init { - itemOnObjectOperate(obj = "sink*,fountain*,well*,water_trough*,pump_and_drain*") { (target, item) -> + itemOnObjectOperate(obj = "sink*,fountain*,well*,water_trough*,pump_and_drain*,water_barrel_*") { (target, item) -> if (!item.def.contains("full")) { return@itemOnObjectOperate } From 346282f41e033a0179220e7439302b15433e0c89 Mon Sep 17 00:00:00 2001 From: Harley Gilpin <75695035+HarleyGilpin@users.noreply.github.com> Date: Wed, 24 Jun 2026 03:42:43 -0700 Subject: [PATCH 08/15] Implement beast of burden storage for summoning familiars (#1042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: implement beast of burden storage for summoning familiars Re-lands the work from #1015 (by @maatheusgois-dd) with the two review comments from Greg addressed: - Drop the redundant familiar_leash timer (and the ensureFollowerNearby helper it drove). Follow mode already teleports a familiar back when it drifts >15 tiles every tick, so the timer duplicated that. The Follow teleport improvement and the callFollower mode-reset are kept. - Clarify ensureBeastOfBurdenInventory: the clear() call discards a stale undersized inventory instance so the engine recreates it at the definition size; the empty guard prevents losing stored items. Added a comment and tightened the condition. Implements Store / Take BoB for beast-of-burden familiars (e.g. pack yak) via the beast_of_burden (671) and summoning_side (722) interfaces, registers the beast_of_burden inventory (id 530, 6x5), wires examine handlers, and drops stored items to the floor on dismiss. * fix: clamp beast of burden store/withdraw to held amount Greg noted the original PR could leave gaps in the beast of burden when depositing more items than were held. Root cause: when the requested amount exceeds the source count, MoveItemLimit's undo path (removed < added) removes the first `added` matches from the target — including items already present — then re-adds only `removed`, scattering the survivors into high slots and leaving empty gaps. A later deposit then fills those gaps, appearing to "skip" slots. Clamp the requested amount to what's actually held before moveToLimit in both store and withdraw, so removed == added and the undo path never runs. Adds BeastOfBurdenStoreLimitTest covering both directions. * fix: Take BoB withdraws as many items as fit Take BoB used moveAll, an atomic transaction that rolls back entirely if the inventory can't hold every carried item — so a player with fewer free slots than the familiar carried got "You don't have enough inventory space." and withdrew nothing. Use moveAllToLimit instead so the inventory fills up to capacity and the remainder stays on the familiar (still showing the full message when items are left behind), matching the single-item Withdraw behaviour. Adds BeastOfBurdenTakeTest covering the partial and full-fit cases. * feat: compact beast of burden items to the top on open When the familiar inventory is opened, reorganise the stored items so they fill the interface from the top with any empty slots pushed to the bottom, preserving order. Done in the beast_of_burden interfaceOpened handler so it covers every open path. Adds BeastOfBurdenCompactTest. * fix: enforce beast of burden slot capacity when storing Storing only blocked a new item type once the first `capacity` slots were full, so storing more of the same non-stackable item (or stackables) could fill the whole shared 30-slot inventory — e.g. a war tortoise (18 slots) accepted up to 28, a thorny snail (3) accepted 5. Count all used slots and the remaining free slots against the familiar's capacity: items merging into an existing stack take no new slot, a new stack takes one, and each non-stackable item takes one. Clamp non-stackable stores to the free slots and tell the player when the limit is hit. The per-familiar capacities (thorny snail 3 … pack yak 30) already come from the cache NPC definitions. Adds BeastOfBurdenCapacityTest. * feat: align beast of burden with wiki mechanics Comparison against the RuneScape wiki surfaced four behaviours that were missing or diverged: - Only tradeable items can be stored. Reject untradeable/lent/dummy items in store(), mirroring the trade-screen restriction, with a message. - The familiar inventory can't be accessed in combat. Gate openBeastOfBurden() on the under_attack clock. - Dropped items are now owner-only and retrievable for five minutes before despawning (was: never despawn), and use the wiki's drop message. - A familiar slain in combat now drops its stored items and is dismissed. Tag the familiar NPC with its owner on summon and handle its death via npcDeath, reusing dismissFamiliar (without re-removing the despawning NPC). Adds BeastOfBurdenWikiTest. Out of scope (documented divergences): GE high-value rejection, abyssal essence carriers, and bank-interface access. * fix: drop beast of burden items under the familiar's tile dropBeastOfBurdenItems() dropped onto the player's tile; on dismiss or familiar death the items should fall under the familiar instead. Use the follower's tile (falling back to the player if it's already gone). Updated the drop tests to place the familiar on a separate tile and assert the items land there, not under the player. * feat: reject beast of burden items worth over 5,000,000 Wiki: a familiar can't carry an unstackable item worth more than 5m, nor a stack whose total value exceeds 5m. Check the item value (def["price", def.cost], same as the price checker) in store(): reject an unstackable item priced over the cap, and reject a stackable store whose resulting stack total (existing + stored) would exceed it. Coins are therefore capped at 5,000,000. Adds tests (red_partyhat over cap; coins at/over 5m). * feat: prevent BoB from storing essence. * feat: add essence only beasts of burden. * refactor: address beast of burden review feedback - Move the 5m carry-value cap to game.properties (summoning.beastOfBurden.maxValue); add a Long accessor to Settings. - Inline familiarDef() to follower?.def. - Allow using an item on a familiar to store it (itemOnNPCOperate), reusing store() with its existing validation. * spotlessApply run --- .../gregs/voidps/cache/definition/Params.kt | 2 + data/skill/summoning/summoning.ifaces.toml | 18 + data/skill/summoning/summoning.invs.toml | 4 + data/skill/summoning/summoning.npcs.toml | 9 + .../gregs/voidps/engine/data/Settings.kt | 2 + .../engine/entity/character/mode/Follow.kt | 10 +- .../main/kotlin/content/entity/Examines.kt | 2 + .../content/skill/summoning/BeastOfBurden.kt | 313 ++++++++++++++++++ .../content/skill/summoning/Familiars.kt | 12 - .../content/skill/summoning/Summoning.kt | 24 +- game/src/main/resources/game.properties | 5 + .../AbyssalFamiliarDefinitionTest.kt | 24 ++ .../summoning/BeastOfBurdenCapacityTest.kt | 71 ++++ .../summoning/BeastOfBurdenCompactTest.kt | 30 ++ .../summoning/BeastOfBurdenDisplayTest.kt | 23 ++ .../summoning/BeastOfBurdenStoreLimitTest.kt | 57 ++++ .../skill/summoning/BeastOfBurdenStoreTest.kt | 38 +++ .../skill/summoning/BeastOfBurdenTakeTest.kt | 53 +++ .../skill/summoning/BeastOfBurdenWikiTest.kt | 249 ++++++++++++++ .../summoning/BeastOfBurdenZeroSizeTest.kt | 35 ++ .../skill/summoning/PackYakDefinitionTest.kt | 16 + 21 files changed, 979 insertions(+), 18 deletions(-) create mode 100644 data/skill/summoning/summoning.invs.toml create mode 100644 game/src/main/kotlin/content/skill/summoning/BeastOfBurden.kt delete mode 100644 game/src/main/kotlin/content/skill/summoning/Familiars.kt create mode 100644 game/src/test/kotlin/content/skill/summoning/AbyssalFamiliarDefinitionTest.kt create mode 100644 game/src/test/kotlin/content/skill/summoning/BeastOfBurdenCapacityTest.kt create mode 100644 game/src/test/kotlin/content/skill/summoning/BeastOfBurdenCompactTest.kt create mode 100644 game/src/test/kotlin/content/skill/summoning/BeastOfBurdenDisplayTest.kt create mode 100644 game/src/test/kotlin/content/skill/summoning/BeastOfBurdenStoreLimitTest.kt create mode 100644 game/src/test/kotlin/content/skill/summoning/BeastOfBurdenStoreTest.kt create mode 100644 game/src/test/kotlin/content/skill/summoning/BeastOfBurdenTakeTest.kt create mode 100644 game/src/test/kotlin/content/skill/summoning/BeastOfBurdenWikiTest.kt create mode 100644 game/src/test/kotlin/content/skill/summoning/BeastOfBurdenZeroSizeTest.kt create mode 100644 game/src/test/kotlin/content/skill/summoning/PackYakDefinitionTest.kt diff --git a/cache/src/main/kotlin/world/gregs/voidps/cache/definition/Params.kt b/cache/src/main/kotlin/world/gregs/voidps/cache/definition/Params.kt index 5e4a24b85f..98254297e9 100644 --- a/cache/src/main/kotlin/world/gregs/voidps/cache/definition/Params.kt +++ b/cache/src/main/kotlin/world/gregs/voidps/cache/definition/Params.kt @@ -261,8 +261,10 @@ object Params { const val SKILL_CAPE_T = 5261 const val QUEST_INFO = 5262 const val VARIABLES = 5263 + const val SUMMONING_BEAST_OF_BURDEN_ESSENCE = 5264 private fun custom(name: String) = when (name) { + "summoning_beast_of_burden_essence" -> SUMMONING_BEAST_OF_BURDEN_ESSENCE "variables" -> VARIABLES "quest_info" -> QUEST_INFO "skill_cape_t" -> SKILL_CAPE_T diff --git a/data/skill/summoning/summoning.ifaces.toml b/data/skill/summoning/summoning.ifaces.toml index fa5c49e44c..a9e8f495f2 100644 --- a/data/skill/summoning/summoning.ifaces.toml +++ b/data/skill/summoning/summoning.ifaces.toml @@ -296,6 +296,18 @@ id = 18 [beast_of_burden] id = 671 +type = "main_screen" + +[.items] +id = 27 +inventory = "beast_of_burden" +options = { Withdraw-1 = 0, Withdraw-5 = 1, Withdraw-10 = 2, Withdraw-All = 3, Withdraw-X = 4, Examine = 9 } + +[.close] +id = 13 + +[.take_bob] +id = 29 [summoning_pouch_creation] id = 672 @@ -384,4 +396,10 @@ id = 21 [summoning_side] id = 722 +type = "inventory_tab" + +[.inventory] +id = 0 +inventory = "inventory" +options = { Store-1 = 0, Store-5 = 1, Store-10 = 2, Store-All = 3, Store-X = 4, Examine = 9 } diff --git a/data/skill/summoning/summoning.invs.toml b/data/skill/summoning/summoning.invs.toml new file mode 100644 index 0000000000..8f4437b90b --- /dev/null +++ b/data/skill/summoning/summoning.invs.toml @@ -0,0 +1,4 @@ +[beast_of_burden] +id = 530 +width = 6 +height = 5 diff --git a/data/skill/summoning/summoning.npcs.toml b/data/skill/summoning/summoning.npcs.toml index f02053a504..c99deee8b0 100644 --- a/data/skill/summoning/summoning.npcs.toml +++ b/data/skill/summoning/summoning.npcs.toml @@ -44,11 +44,17 @@ id = 6817 id = 6818 dialogue = "leech" interacts = false +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 7 +summoning_beast_of_burden_essence = 1 [abyssal_lurker_familiar] id = 6820 dialogue = "bird" interacts = false +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 12 +summoning_beast_of_burden_essence = 1 [unicorn_stallion_familiar] id = 6822 @@ -195,6 +201,9 @@ id = 7347 [abyssal_titan_familiar] id = 7349 +summoning_beast_of_burden = 1 +summoning_beast_of_burden_capacity = 20 +summoning_beast_of_burden_essence = 1 [void_torcher_familiar] id = 7351 diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/data/Settings.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/data/Settings.kt index eff5d93b49..b20d072116 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/data/Settings.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/data/Settings.kt @@ -35,6 +35,8 @@ open class Settings { operator fun get(name: String, default: Int): Int = getOrNull(name)?.toIntOrNull() ?: default + operator fun get(name: String, default: Long): Long = getOrNull(name)?.toLongOrNull() ?: default + operator fun get(name: String, default: Double): Double = getOrNull(name)?.toDoubleOrNull() ?: default operator fun get(name: String, default: Boolean): Boolean = getOrNull(name)?.toBooleanStrictOrNull() ?: default diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/Follow.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/Follow.kt index 7e38fba440..5dd66196d6 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/Follow.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/mode/Follow.kt @@ -31,8 +31,14 @@ class Follow( return } if (character is NPC && character.tile.distanceTo(target) > 15) { - character.tele(strategy.tile, clearMode = false) - character.clearWatch() + val followTile = strategy.tile + val destination = when { + followTile != Tile.EMPTY && followTile.level == target.tile.level && followTile.distanceTo(target) <= 15 -> followTile + else -> target.tile + } + character.tele(destination, clearMode = false) + character.watch(target) + return } character.walkTrigger() if (!smart) { diff --git a/game/src/main/kotlin/content/entity/Examines.kt b/game/src/main/kotlin/content/entity/Examines.kt index fa06687549..ce915296bd 100644 --- a/game/src/main/kotlin/content/entity/Examines.kt +++ b/game/src/main/kotlin/content/entity/Examines.kt @@ -21,6 +21,8 @@ class Examines : Script { interfaceOption("Examine", "bank:inventory", ::examineItem) interfaceOption("Examine", "bank_side:inventory", ::examineItem) interfaceOption("Examine", "price_checker:items", ::examineItem) + interfaceOption("Examine", "beast_of_burden:items", ::examineItem) + interfaceOption("Examine", "summoning_side:inventory", ::examineItem) interfaceOption("Examine", "equipment_bonuses:inventory", ::examineItem) interfaceOption("Examine", "trade_main:offer_options", ::examineItem) interfaceOption("Examine", "trade_main:offer_warning", ::examineItem) diff --git a/game/src/main/kotlin/content/skill/summoning/BeastOfBurden.kt b/game/src/main/kotlin/content/skill/summoning/BeastOfBurden.kt new file mode 100644 index 0000000000..b2b2a8edbe --- /dev/null +++ b/game/src/main/kotlin/content/skill/summoning/BeastOfBurden.kt @@ -0,0 +1,313 @@ +package content.skill.summoning + +import content.entity.combat.underAttack +import content.entity.player.dialogue.type.intEntry +import content.entity.player.inv.item.tradeable +import content.entity.player.modal.Tab +import content.entity.player.modal.tab +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.sendScript +import world.gregs.voidps.engine.client.ui.close +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.data.Settings +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.chat.inventoryFull +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.inv.beastOfBurden +import world.gregs.voidps.engine.inv.clear +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.sendInventory +import world.gregs.voidps.engine.inv.transact.TransactionError +import world.gregs.voidps.engine.inv.transact.operation.MoveItemLimit.moveAllToLimit +import world.gregs.voidps.engine.inv.transact.operation.MoveItemLimit.moveToLimit +import world.gregs.voidps.engine.timer.toTicks +import java.util.concurrent.TimeUnit + +/** The only items the abyssal essence familiars carry; every other familiar refuses them. */ +private val BEAST_OF_BURDEN_ESSENCE = setOf("rune_essence", "pure_essence") + +val Player.beastOfBurdenCapacity: Int + get() = follower?.def?.get("summoning_beast_of_burden_capacity", 0) ?: 0 + +/** Abyssal parasite/lurker/titan carry only rune and pure essence, nothing else. */ +val Player.beastOfBurdenEssenceOnly: Boolean + get() = follower?.def?.get("summoning_beast_of_burden_essence", 0) == 1 + +fun Player.hasBeastOfBurden(): Boolean = follower?.def?.get("summoning_beast_of_burden", 0) == 1 + +fun Player.ensureBeastOfBurdenInventory() { + val capacity = beastOfBurdenCapacity + if (capacity > 0 && beastOfBurden.size < capacity && beastOfBurden.isEmpty()) { + inventories.clear("beast_of_burden") + } +} + +fun Player.syncBeastOfBurdenInterface() { + interfaceOptions.send("beast_of_burden", "items") + sendInventory(beastOfBurden) + sendInventory(inventory) +} + +/** + * Reorganises the stored items so they fill the interface from the top, leaving + * any empty slots at the bottom. Order is preserved. + */ +fun Player.compactBeastOfBurden() { + beastOfBurden.transaction { + var target = 0 + for (index in inventory.indices) { + val item = inventory[index] + if (item.isEmpty()) { + continue + } + if (index != target) { + set(target, item) + set(index, null) + } + target++ + } + } +} + +fun Player.openBeastOfBurden() { + if (underAttack) { + message("You can't do that in combat.") + return + } + if (!hasBeastOfBurden()) { + message("Your follower can't carry any items.") + return + } + ensureBeastOfBurdenInventory() + interfaces.open("beast_of_burden") + open("summoning_side") + tab(Tab.Inventory) + syncBeastOfBurdenInterface() + interfaceOptions.unlockAll("beast_of_burden", "items", 0 until beastOfBurdenCapacity) + interfaceOptions.unlockAll("summoning_side", "inventory", 0 until 28) +} + +fun Player.takeAllBeastOfBurden() { + if (!hasBeastOfBurden()) { + message("Your follower can't carry any items.") + return + } + if (beastOfBurden.isEmpty()) { + message("Your familiar is not carrying any items.") + return + } + val target = inventory + beastOfBurden.transaction { + moveAllToLimit(target) + } + syncBeastOfBurdenInterface() + if (!beastOfBurden.isEmpty()) { + inventoryFull() + } +} + +fun Player.dropBeastOfBurdenItems() { + if (beastOfBurden.isEmpty()) { + return + } + // Items drop under the familiar, falling back to the player if it's already gone. + val dropTile = follower?.tile ?: tile + for (item in beastOfBurden.items) { + if (item.isEmpty()) { + continue + } + // Owner-only, retrievable for five minutes before despawning. + FloorItems.add(dropTile, item.id, item.amount, revealTicks = FloorItems.NEVER, disappearTicks = TimeUnit.MINUTES.toTicks(5), owner = this) + } + beastOfBurden.clear() + message("Your familiar has dropped all the items it was holding.") +} + +class BeastOfBurden : Script { + + init { + npcOperate("Store", "*_familiar") { (target) -> + if (target != follower) { + message("That's not your familiar.") + return@npcOperate + } + openBeastOfBurden() + } + + itemOnNPCOperate("*", "*_familiar") { (target, item) -> + if (target != follower) { + message("That's not your familiar.") + return@itemOnNPCOperate + } + if (underAttack) { + message("You can't do that in combat.") + return@itemOnNPCOperate + } + store(this, item, item.amount) + } + + npcOperate("Interact", "*_familiar") { (target) -> + if (target != follower) { + return@npcOperate + } + updateFamiliarInterface() + } + + interfaceOpened("beast_of_burden") { id -> + if (!hasBeastOfBurden()) { + interfaces.close("beast_of_burden") + return@interfaceOpened + } + ensureBeastOfBurdenInventory() + compactBeastOfBurden() + open("summoning_side") + tab(Tab.Inventory) + syncBeastOfBurdenInterface() + interfaceOptions.unlockAll(id, "items", 0 until beastOfBurdenCapacity) + } + + interfaceClosed("beast_of_burden") { + close("summoning_side") + sendScript("clear_dialogues") + open("inventory") + } + + interfaceOpened("summoning_side") { id -> + interfaceOptions.send(id, "inventory") + interfaceOptions.unlockAll(id, "inventory", 0 until 28) + sendInventory(inventory) + } + + interfaceClosed("summoning_side") { + open("inventory") + } + + interfaceOption(id = "beast_of_burden:items") { (item, _, option) -> + val amount = when (option) { + "Withdraw-1" -> 1 + "Withdraw-5" -> 5 + "Withdraw-10" -> 10 + "Withdraw-All" -> beastOfBurden.count(item.id) + "Withdraw-X" -> intEntry("Enter amount:") + else -> return@interfaceOption + } + withdraw(this, item, amount) + } + + interfaceOption(id = "summoning_side:inventory") { (item, _, option) -> + val amount = when (option) { + "Store-1" -> 1 + "Store-5" -> 5 + "Store-10" -> 10 + "Store-All" -> inventory.count(item.id) + "Store-X" -> intEntry("Enter amount:") + else -> return@interfaceOption + } + store(this, item, amount) + } + + interfaceOption("Take BoB", "beast_of_burden:take_bob") { + takeAllBeastOfBurden() + } + + interfaceOption("Take BoB", "familiar_details:take_bob_items") { + takeAllBeastOfBurden() + } + + interfaceOption("Take BoB", "summoning_orb:*take_bob") { + takeAllBeastOfBurden() + } + } + + private fun withdraw(player: Player, item: world.gregs.voidps.engine.entity.item.Item, amount: Int) { + if (amount < 1) { + return + } + val toWithdraw = minOf(amount, player.beastOfBurden.count(item.id)) + if (toWithdraw < 1) { + return + } + player.beastOfBurden.transaction { + moveToLimit(item.id, toWithdraw, player.inventory) + } + when (player.beastOfBurden.transaction.error) { + is TransactionError.Full -> player.inventoryFull() + else -> player.syncBeastOfBurdenInterface() + } + } + + private fun store(player: Player, item: world.gregs.voidps.engine.entity.item.Item, amount: Int) { + if (amount < 1) { + return + } + if (!player.hasBeastOfBurden()) { + player.message("Your follower can't carry any items.") + return + } + val capacity = player.beastOfBurdenCapacity + if (capacity <= 0) { + player.message("Your follower can't carry any items.") + return + } + // Essence familiars carry only essence; all other familiars refuse essence. + val isEssence = item.id in BEAST_OF_BURDEN_ESSENCE + if (player.beastOfBurdenEssenceOnly != isEssence) { + player.message("Your familiar can't carry that item.") + return + } + // Familiars only carry tradeable items (the same rule the trade screen uses). + val def = item.def + if (def.lendTemplateId != -1 || def.dummyItem != 0 || !item.tradeable) { + player.message("Your familiar can't carry that item.") + return + } + player.ensureBeastOfBurdenInventory() + val bob = player.beastOfBurden + val stackable = bob.stackable(item.id) + val valueEach = def["price", def.cost] + // Items (or stacks) worth more than this can't be carried by a familiar. + val maxValue = Settings["summoning.beastOfBurden.maxValue", 5_000_000L] + // An unstackable item worth more than the cap can't be carried. + if (!stackable && valueEach > maxValue) { + player.message("Your familiar can't carry items that valuable.") + return + } + val usedSlots = bob.items.count { it.isNotEmpty() } + val sharesStack = stackable && bob.indexOf(item.id) != -1 + val freeSlots = capacity - usedSlots + if (freeSlots <= 0 && !sharesStack) { + player.message("Your familiar can't carry any more items.") + return + } + val requested = minOf(amount, player.inventory.count(item.id)) + var toStore = requested + if (!stackable) { + // Each non-stackable item needs its own slot, so cap to the free slots. + toStore = minOf(toStore, freeSlots) + } + if (toStore < 1) { + return + } + // A stack whose total value would exceed the cap can't be carried. + if (stackable && (bob.count(item.id).toLong() + toStore) * valueEach > maxValue) { + player.message("Your familiar can't carry items that valuable.") + return + } + player.inventory.transaction { + val moved = moveToLimit(item.id, toStore, player.beastOfBurden) + if (moved == 0) { + error = TransactionError.Full() + } + } + when (player.inventory.transaction.error) { + is TransactionError.Full -> player.message("Your familiar can't carry any more items.") + else -> { + if (toStore < requested) { + player.message("Your familiar can't carry any more items.") + } + player.syncBeastOfBurdenInterface() + } + } + } +} diff --git a/game/src/main/kotlin/content/skill/summoning/Familiars.kt b/game/src/main/kotlin/content/skill/summoning/Familiars.kt deleted file mode 100644 index f213579bc0..0000000000 --- a/game/src/main/kotlin/content/skill/summoning/Familiars.kt +++ /dev/null @@ -1,12 +0,0 @@ -package content.skill.summoning - -import world.gregs.voidps.engine.Script -import world.gregs.voidps.engine.client.message - -class Familiars : Script { - init { - npcOperate("Store", "*_familiar") { - message("Not currently implemented.") - } - } -} diff --git a/game/src/main/kotlin/content/skill/summoning/Summoning.kt b/game/src/main/kotlin/content/skill/summoning/Summoning.kt index 17ceef0803..5d17496f68 100644 --- a/game/src/main/kotlin/content/skill/summoning/Summoning.kt +++ b/game/src/main/kotlin/content/skill/summoning/Summoning.kt @@ -20,6 +20,7 @@ import world.gregs.voidps.engine.entity.character.move.tele import world.gregs.voidps.engine.entity.character.npc.NPC import world.gregs.voidps.engine.entity.character.npc.NPCs import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.character.player.skill.exp.exp import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has @@ -67,6 +68,7 @@ fun Player.summonFamiliar(familiar: NPCDefinition, restart: Boolean) { familiarNpc.mode = Follow(familiarNpc, this) queue("summon_familiar", 2) { follower = familiarNpc + familiarNpc["owner_index"] = index familiarNpc.gfx("summon_familiar_size_${familiarNpc.size}") updateFamiliarInterface() if (!restart) { @@ -79,10 +81,14 @@ fun Player.summonFamiliar(familiar: NPCDefinition, restart: Boolean) { * Dismisses the familiar following the player and resets the summoning orb and varbits back to their default * states. Also stops the familiar timer. */ -fun Player.dismissFamiliar() { - NPCs.remove(follower) +fun Player.dismissFamiliar(removeNpc: Boolean = true) { + dropBeastOfBurdenItems() + if (removeNpc) { + NPCs.remove(follower) + } follower = null interfaces.close("familiar_details") + interfaces.close("beast_of_burden") sendScript("reset_summoning_orb") // Need to wait for the above sendScript to reach the client before resetting @@ -150,6 +156,9 @@ fun Player.callFollower() { follower.tele(target, clearMode = false) follower.watch(this) follower.gfx("summon_familiar_size_${follower.size}") + if (follower.mode !is Follow) { + follower.mode = Follow(follower, this) + } } /** @@ -325,8 +334,15 @@ class Summoning : Script { summonFamiliar(familiarDef, true) } - interfaceOption("Take BoB", "familiar_details:take_bob_items") { - message("Not currently implemented.") + npcDeath("*_familiar") { death -> + death.respawn = false + death.dropItems = false + val owner = Players.indexed(this["owner_index", -1]) ?: return@npcDeath + if (owner.follower?.index == index) { + // Familiar slain in combat: drop its stored items and dismiss it. + // The death flow despawns the NPC, so don't remove it again here. + owner.dismissFamiliar(removeNpc = false) + } } } } diff --git a/game/src/main/resources/game.properties b/game/src/main/resources/game.properties index 8835bf5792..69a1d9b1a7 100644 --- a/game/src/main/resources/game.properties +++ b/game/src/main/resources/game.properties @@ -179,6 +179,11 @@ magic.alchemy.warningLimit=25000 # Fighting strykewyrms requires a slayer task slayer.strykewyrmReqTask=true +#------- Summoning ------- + +# Max value of an item (or stack) a beast of burden familiar can carry +summoning.beastOfBurden.maxValue=5000000 + #=================================== # Content & Events #=================================== diff --git a/game/src/test/kotlin/content/skill/summoning/AbyssalFamiliarDefinitionTest.kt b/game/src/test/kotlin/content/skill/summoning/AbyssalFamiliarDefinitionTest.kt new file mode 100644 index 0000000000..17309de0a1 --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/AbyssalFamiliarDefinitionTest.kt @@ -0,0 +1,24 @@ +package content.skill.summoning + +import WorldTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.NPCDefinitions + +class AbyssalFamiliarDefinitionTest : WorldTest() { + + @Test + fun `abyssal essence familiars are essence-only beasts of burden after config load`() { + val cases = mapOf( + "abyssal_parasite_familiar" to 7, + "abyssal_lurker_familiar" to 12, + "abyssal_titan_familiar" to 20, + ) + for ((name, capacity) in cases) { + val def = NPCDefinitions.get(name) + assertEquals(1, def.get("summoning_beast_of_burden", 0), name) + assertEquals(capacity, def.get("summoning_beast_of_burden_capacity", 0), name) + assertEquals(1, def.get("summoning_beast_of_burden_essence", 0), name) + } + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenCapacityTest.kt b/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenCapacityTest.kt new file mode 100644 index 0000000000..f52bc05b51 --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenCapacityTest.kt @@ -0,0 +1,71 @@ +package content.skill.summoning + +import WorldTest +import containsMessage +import interfaceOption +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.beastOfBurden +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile + +class BeastOfBurdenCapacityTest : WorldTest() { + + /** + * Regression: a familiar must never store more items than its slot capacity, + * even when storing many of the same non-stackable item. + */ + @Test + fun `cannot store more non-stackable items than capacity`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("thorny_snail_familiar"), false) + tick(3) + assertEquals(3, player.beastOfBurdenCapacity) + + player.inventory.add("bronze_sword", 5) + player.openBeastOfBurden() + player.interfaceOption("summoning_side", "inventory", "Store-All", item = Item("bronze_sword"), slot = 0) + + assertEquals(3, player.beastOfBurden.count("bronze_sword")) + assertEquals(2, player.inventory.count("bronze_sword")) + assertTrue(player.containsMessage("Your familiar can't carry any more items.")) + } + + @Test + fun `war tortoise stores up to eighteen slots`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("war_tortoise_familiar"), false) + tick(3) + assertEquals(18, player.beastOfBurdenCapacity) + + player.inventory.add("bronze_sword", 28) + player.openBeastOfBurden() + player.interfaceOption("summoning_side", "inventory", "Store-All", item = Item("bronze_sword"), slot = 0) + + assertEquals(18, player.beastOfBurden.count("bronze_sword")) + assertEquals(10, player.inventory.count("bronze_sword")) + } + + @Test + fun `stackable items only occupy a single slot`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("thorny_snail_familiar"), false) + tick(3) + + // Fill two of three slots with distinct non-stackable items. + player.beastOfBurden.add("bronze_dagger", 1) + player.beastOfBurden.add("iron_dagger", 1) + player.inventory.add("coins", 1000) + player.openBeastOfBurden() + + player.interfaceOption("summoning_side", "inventory", "Store-All", item = Item("coins"), slot = 0) + + // Coins fit in the single remaining slot regardless of amount. + assertEquals(1000, player.beastOfBurden.count("coins")) + assertEquals(0, player.inventory.count("coins")) + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenCompactTest.kt b/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenCompactTest.kt new file mode 100644 index 0000000000..6f11bf15b8 --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenCompactTest.kt @@ -0,0 +1,30 @@ +package content.skill.summoning + +import WorldTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.inv.beastOfBurden +import world.gregs.voidps.type.Tile + +class BeastOfBurdenCompactTest : WorldTest() { + + @Test + fun `opening familiar inventory reorganises items to the top`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + + // Stored items scattered with gaps between them. + player.beastOfBurden.set(3, "bronze_sword", 1) + player.beastOfBurden.set(10, "coins", 500) + + player.openBeastOfBurden() + + // Compacted to the top, original order preserved, gaps pushed to the bottom. + assertEquals("bronze_sword", player.beastOfBurden.items[0].id) + assertEquals("coins", player.beastOfBurden.items[1].id) + assertEquals(500, player.beastOfBurden.items[1].amount) + assertEquals(2, player.beastOfBurden.items.indexOfFirst { it.isEmpty() }) + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenDisplayTest.kt b/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenDisplayTest.kt new file mode 100644 index 0000000000..7b64cf78af --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenDisplayTest.kt @@ -0,0 +1,23 @@ +package content.skill.summoning + +import WorldTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.InterfaceDefinitions +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.type.Tile + +class BeastOfBurdenDisplayTest : WorldTest() { + + @Test + fun `beast of burden interface binds items component`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + player.openBeastOfBurden() + val component = InterfaceDefinitions.getComponent("beast_of_burden", "items") + requireNotNull(component) + assertEquals("beast_of_burden", component["inventory", ""]) + assertEquals("items", component.stringId) + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenStoreLimitTest.kt b/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenStoreLimitTest.kt new file mode 100644 index 0000000000..0588d5bd0a --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenStoreLimitTest.kt @@ -0,0 +1,57 @@ +package content.skill.summoning + +import WorldTest +import interfaceOption +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.beastOfBurden +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile + +class BeastOfBurdenStoreLimitTest : WorldTest() { + + /** + * Regression: storing more than is carried must not trigger moveToLimit's + * undo path, which previously re-shuffled the already-stored items and left + * gaps in the beast of burden (the next deposit then "skipped" slots). + */ + @Test + fun `storing more than held does not leave gaps`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + player.openBeastOfBurden() + + // Three swords already stored (non-stackable -> three slots). + player.beastOfBurden.add("bronze_sword", 3) + // Five swords held, but request ten. + player.inventory.add("bronze_sword", 5) + + player.interfaceOption("summoning_side", "inventory", "Store-10", item = Item("bronze_sword"), slot = 0) + + assertEquals(8, player.beastOfBurden.count("bronze_sword")) + assertEquals(0, player.inventory.count("bronze_sword")) + // All eight swords sit in contiguous slots 0..7 with no gaps. + assertEquals(8, player.beastOfBurden.items.indexOfFirst { it.isEmpty() }) + } + + @Test + fun `withdrawing more than carried does not leave gaps`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + player.openBeastOfBurden() + + player.inventory.add("bronze_sword", 3) + player.beastOfBurden.add("bronze_sword", 5) + + player.interfaceOption("beast_of_burden", "items", "Withdraw-10", item = Item("bronze_sword"), slot = 0) + + assertEquals(8, player.inventory.count("bronze_sword")) + assertEquals(0, player.beastOfBurden.count("bronze_sword")) + assertEquals(8, player.inventory.items.indexOfFirst { it.isEmpty() }) + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenStoreTest.kt b/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenStoreTest.kt new file mode 100644 index 0000000000..22995c68e3 --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenStoreTest.kt @@ -0,0 +1,38 @@ +package content.skill.summoning + +import WorldTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.InventoryDefinitions +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.inv.beastOfBurden +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.transact.TransactionError +import world.gregs.voidps.engine.inv.transact.operation.MoveItemLimit.moveToLimit +import world.gregs.voidps.type.Tile + +class BeastOfBurdenStoreTest : WorldTest() { + + @Test + fun `store item into empty beast of burden`() { + val player = createPlayer(Tile(3200, 3200)) + player.inventory.set(0, "coins", 100) + + val familiar = NPCDefinitions.get("pack_yak_familiar") + player.summonFamiliar(familiar, false) + tick(3) + + assertEquals(30, player.beastOfBurdenCapacity) + assertTrue(player.hasBeastOfBurden()) + assertEquals(30, InventoryDefinitions.get("beast_of_burden").length) + assertEquals(30, player.beastOfBurden.size) + + player.inventory.transaction { + val moved = moveToLimit("coins", 10, player.beastOfBurden) + assertEquals(10, moved) + } + assertTrue(player.inventory.transaction.error !is TransactionError.Full) + assertEquals(10, player.beastOfBurden.count("coins")) + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenTakeTest.kt b/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenTakeTest.kt new file mode 100644 index 0000000000..1f8ad05fec --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenTakeTest.kt @@ -0,0 +1,53 @@ +package content.skill.summoning + +import WorldTest +import containsMessage +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.beastOfBurden +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile + +class BeastOfBurdenTakeTest : WorldTest() { + + /** + * Regression: Take BoB must withdraw as many items as fit when the inventory + * has fewer free slots than the familiar is carrying, rather than failing + * all-or-nothing and withdrawing nothing. + */ + @Test + fun `take bob fills inventory and leaves the remainder`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + + // Two free inventory slots, five swords carried. + player.inventory.add("bronze_sword", 26) + player.beastOfBurden.add("bronze_sword", 5) + + player.takeAllBeastOfBurden() + + assertEquals(28, player.inventory.count("bronze_sword")) + assertEquals(3, player.beastOfBurden.count("bronze_sword")) + assertTrue(player.containsMessage("You don't have enough inventory space.")) + } + + @Test + fun `take bob withdraws everything when there is room`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + + player.beastOfBurden.add("bronze_sword", 5) + + player.takeAllBeastOfBurden() + + assertEquals(5, player.inventory.count("bronze_sword")) + assertEquals(0, player.beastOfBurden.count("bronze_sword")) + assertFalse(player.containsMessage("You don't have enough inventory space.")) + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenWikiTest.kt b/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenWikiTest.kt new file mode 100644 index 0000000000..a78c12a9e9 --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenWikiTest.kt @@ -0,0 +1,249 @@ +package content.skill.summoning + +import WorldTest +import containsMessage +import interfaceOption +import itemOnNpc +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.client.ui.hasOpen +import world.gregs.voidps.engine.client.variable.start +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.beastOfBurden +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile + +class BeastOfBurdenWikiTest : WorldTest() { + + @Test + fun `untradeable items cannot be stored`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + player.inventory.add("attack_cape", 1) // untradeable + player.openBeastOfBurden() + + player.interfaceOption("summoning_side", "inventory", "Store-All", item = Item("attack_cape"), slot = 0) + + assertEquals(0, player.beastOfBurden.count("attack_cape")) + assertEquals(1, player.inventory.count("attack_cape")) + assertTrue(player.containsMessage("Your familiar can't carry that item.")) + } + + @Test + fun `rune essence cannot be stored`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + player.inventory.add("rune_essence", 10) + player.openBeastOfBurden() + + player.interfaceOption("summoning_side", "inventory", "Store-All", item = Item("rune_essence"), slot = 0) + + assertEquals(0, player.beastOfBurden.count("rune_essence")) + assertEquals(10, player.inventory.count("rune_essence")) + assertTrue(player.containsMessage("Your familiar can't carry that item.")) + } + + @Test + fun `pure essence cannot be stored`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + player.inventory.add("pure_essence", 10) + player.openBeastOfBurden() + + player.interfaceOption("summoning_side", "inventory", "Store-All", item = Item("pure_essence"), slot = 0) + + assertEquals(0, player.beastOfBurden.count("pure_essence")) + assertEquals(10, player.inventory.count("pure_essence")) + assertTrue(player.containsMessage("Your familiar can't carry that item.")) + } + + @Test + fun `using an item on a familiar stores it`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + player.inventory.add("coins", 100) + + player.itemOnNpc(player.follower!!, 0) + tick() + + assertEquals(100, player.beastOfBurden.count("coins")) + assertEquals(0, player.inventory.count("coins")) + } + + @Test + fun `using rune essence on a standard familiar is rejected`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + player.inventory.add("rune_essence", 10) + + player.itemOnNpc(player.follower!!, 0) + tick() + + assertEquals(0, player.beastOfBurden.count("rune_essence")) + assertEquals(10, player.inventory.count("rune_essence")) + assertTrue(player.containsMessage("Your familiar can't carry that item.")) + } + + @Test + fun `essence familiar stores rune essence`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("abyssal_parasite_familiar"), false) + tick(3) + player.inventory.add("rune_essence", 5) + player.openBeastOfBurden() + + player.interfaceOption("summoning_side", "inventory", "Store-All", item = Item("rune_essence"), slot = 0) + + assertEquals(5, player.beastOfBurden.count("rune_essence")) + assertEquals(0, player.inventory.count("rune_essence")) + } + + @Test + fun `essence familiar stores pure essence`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("abyssal_lurker_familiar"), false) + tick(3) + player.inventory.add("pure_essence", 5) + player.openBeastOfBurden() + + player.interfaceOption("summoning_side", "inventory", "Store-All", item = Item("pure_essence"), slot = 0) + + assertEquals(5, player.beastOfBurden.count("pure_essence")) + assertEquals(0, player.inventory.count("pure_essence")) + } + + @Test + fun `essence familiar refuses non-essence items`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("abyssal_titan_familiar"), false) + tick(3) + player.inventory.add("coins", 100) + player.openBeastOfBurden() + + player.interfaceOption("summoning_side", "inventory", "Store-All", item = Item("coins"), slot = 0) + + assertEquals(0, player.beastOfBurden.count("coins")) + assertEquals(100, player.inventory.count("coins")) + assertTrue(player.containsMessage("Your familiar can't carry that item.")) + } + + @Test + fun `tradeable items can still be stored`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + player.inventory.add("coins", 100) + player.openBeastOfBurden() + + player.interfaceOption("summoning_side", "inventory", "Store-All", item = Item("coins"), slot = 0) + + assertEquals(100, player.beastOfBurden.count("coins")) + } + + @Test + fun `unstackable item worth over 5m cannot be stored`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + player.inventory.add("red_partyhat", 1) // tradeable but worth ~1.9b + player.openBeastOfBurden() + + player.interfaceOption("summoning_side", "inventory", "Store-1", item = Item("red_partyhat"), slot = 0) + + assertEquals(0, player.beastOfBurden.count("red_partyhat")) + assertEquals(1, player.inventory.count("red_partyhat")) + assertTrue(player.containsMessage("Your familiar can't carry items that valuable.")) + } + + @Test + fun `stack worth more than 5m cannot be stored`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + player.inventory.add("coins", 5_000_001) + player.openBeastOfBurden() + + player.interfaceOption("summoning_side", "inventory", "Store-All", item = Item("coins"), slot = 0) + + assertEquals(0, player.beastOfBurden.count("coins")) + assertEquals(5_000_001, player.inventory.count("coins")) + assertTrue(player.containsMessage("Your familiar can't carry items that valuable.")) + } + + @Test + fun `stack worth exactly 5m can be stored`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + player.inventory.add("coins", 5_000_000) + player.openBeastOfBurden() + + player.interfaceOption("summoning_side", "inventory", "Store-All", item = Item("coins"), slot = 0) + + assertEquals(5_000_000, player.beastOfBurden.count("coins")) + } + + @Test + fun `cannot open familiar inventory while in combat`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + player.start("under_attack", 8) + + player.openBeastOfBurden() + + assertFalse(player.hasOpen("beast_of_burden")) + assertTrue(player.containsMessage("You can't do that in combat.")) + } + + @Test + fun `dismissing drops stored items under the familiar`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + player.beastOfBurden.add("coins", 250) + val familiarTile = Tile(3205, 3205) + player.follower!!.tele(familiarTile) + + player.dismissFamiliar() + tick(1) + + assertTrue(player.beastOfBurden.isEmpty()) + assertNotNull(FloorItems.firstOrNull(familiarTile, "coins")) + assertNull(FloorItems.firstOrNull(player.tile, "coins")) + assertTrue(player.containsMessage("Your familiar has dropped all the items it was holding.")) + } + + @Test + fun `familiar death drops stored items under the familiar and clears follower`() { + val player = createPlayer(Tile(3200, 3200)) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + player.beastOfBurden.add("coins", 250) + val familiar = player.follower!! + val familiarTile = Tile(3205, 3205) + familiar.tele(familiarTile) + + familiar.levels.set(Skill.Constitution, 0) + tick(2) + + assertNull(player.follower) + assertTrue(player.beastOfBurden.isEmpty()) + assertNotNull(FloorItems.firstOrNull(familiarTile, "coins")) + assertNull(FloorItems.firstOrNull(player.tile, "coins")) + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenZeroSizeTest.kt b/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenZeroSizeTest.kt new file mode 100644 index 0000000000..e14cf2d7b3 --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/BeastOfBurdenZeroSizeTest.kt @@ -0,0 +1,35 @@ +package content.skill.summoning + +import WorldTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.NPCDefinitions +import world.gregs.voidps.engine.inv.Inventory +import world.gregs.voidps.engine.inv.beastOfBurden +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.transact.operation.MoveItemLimit.moveToLimit +import world.gregs.voidps.type.Tile + +class BeastOfBurdenZeroSizeTest : WorldTest() { + + @Test + fun `recovers from zero size beast of burden inventory`() { + val player = createPlayer(Tile(3200, 3200)) + player.inventory.set(0, "coins", 10) + player.summonFamiliar(NPCDefinitions.get("pack_yak_familiar"), false) + tick(3) + + // Simulate stale zero-length inventory from before beast_of_burden was registered + player.inventories.instances["beast_of_burden"] = Inventory.debug(0, id = "beast_of_burden") + assertEquals(0, player.beastOfBurden.size) + + player.openBeastOfBurden() + assertEquals(30, player.beastOfBurden.size) + + player.inventory.transaction { + val moved = moveToLimit("coins", 5, player.beastOfBurden) + assertEquals(5, moved) + } + assertEquals(5, player.beastOfBurden.count("coins")) + } +} diff --git a/game/src/test/kotlin/content/skill/summoning/PackYakDefinitionTest.kt b/game/src/test/kotlin/content/skill/summoning/PackYakDefinitionTest.kt new file mode 100644 index 0000000000..4440627935 --- /dev/null +++ b/game/src/test/kotlin/content/skill/summoning/PackYakDefinitionTest.kt @@ -0,0 +1,16 @@ +package content.skill.summoning + +import WorldTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.NPCDefinitions + +class PackYakDefinitionTest : WorldTest() { + + @Test + fun `pack yak familiar has beast of burden params after config load`() { + val def = NPCDefinitions.get("pack_yak_familiar") + assertEquals(1, def.get("summoning_beast_of_burden", 0)) + assertEquals(30, def.get("summoning_beast_of_burden_capacity", 0)) + } +} From b4d6c8b7836b0005413935a8962d63170991b343 Mon Sep 17 00:00:00 2001 From: Greg Date: Wed, 24 Jun 2026 12:26:03 +0100 Subject: [PATCH 09/15] Add some string ids from rs3 beta (#1047) * Add all missing inventory string ids from rs3 beta * Update missing inv side iface ids --- .../inventory/inventory_side.ifaces.toml | 4 +- data/entity/player/misc.invs.toml | 503 +++++++++++++++++- .../character_creation.invs.toml | 2 +- .../skill/construction/construction.invs.toml | 54 +- data/skill/smithing/smithing.invs.toml | 18 +- 5 files changed, 538 insertions(+), 43 deletions(-) diff --git a/data/entity/player/inventory/inventory_side.ifaces.toml b/data/entity/player/inventory/inventory_side.ifaces.toml index a5b77d6017..799bf562e1 100644 --- a/data/entity/player/inventory/inventory_side.ifaces.toml +++ b/data/entity/player/inventory/inventory_side.ifaces.toml @@ -4,13 +4,13 @@ id = 512 [unknown_side0] id = 517 -[unknown_side1] +[pog_coin_side] id = 602 [lore_bank_side] id = 665 -[unknown_side5] +[poh_pet_house_side] id = 878 [unknown_side6] diff --git a/data/entity/player/misc.invs.toml b/data/entity/player/misc.invs.toml index 0136218e9a..2d7186df11 100644 --- a/data/entity/player/misc.invs.toml +++ b/data/entity/player/misc.invs.toml @@ -1,4 +1,58 @@ -[unknown_crafting_store] # Perhaps used for crafting stores before nature spirit? +[trawler_rewardinv] +id = 0 + +[foodshop] +id = 20 + +[fishrestaurant] +id = 44 + +[silvershop] +id = 55 + +[gemshop3] +id = 56 + +[spiceshop] +id = 58 + +[furstall] +id = 75 + +[partyroom_dropinv] +id = 91 + +[partyroom_tempinv] +id = 92 + +[crafting_make_rings] +id = 96 + +[crafting_make_necklaces] +id = 97 + +[crafting_make_amulets] +id = 98 + +[deathkeep] +id = 133 + +[dueloffer] +id = 134 + +[duelwinnings] +id = 136 + +[trail_puzzleinv] +id = 140 + +[trail_rewardinv] +id = 141 + +[duelarrows] +id = 142 + +[craftingshop_free] # Perhaps used for crafting stores before nature spirit? id = 146 defaults = [ { id = "chisel", amount = 10 }, @@ -11,7 +65,7 @@ defaults = [ { id = "tiara_mould", amount = 10 }, ] -[unknown_crafting_store_2] +[craftingshop2_free] id = 147 defaults = [ { id = "chisel", amount = 10 }, @@ -24,15 +78,456 @@ defaults = [ { id = "tiara_mould", amount = 10 }, ] -[inventory_572] +[skill_guide_firemaking] +id = 161 + +[skill_guide_agility] +id = 162 + +[skill_guide_combat_weapons] +id = 163 + +[skill_guide_combat_armours] +id = 164 + +[skill_guide_cooking_overall] +id = 165 + +[skill_guide_cooking_overall2] +id = 166 + +[skill_guide_cooking_meat] +id = 167 + +[skill_guide_cooking_bread] +id = 168 + +[skill_guide_cooking_cakes] +id = 169 + +[skill_guide_cooking_pizzas] +id = 170 + +[skill_guide_cooking_pies] +id = 171 + +[skill_guide_cooking_stews] +id = 172 + +[skill_guide_cooking_wine] +id = 173 + +[skill_guide_crafting_leather] +id = 174 + +[skill_guide_crafting_spinning] +id = 175 + +[skill_guide_crafting_pottery] +id = 176 + +[skill_guide_crafting_glass] +id = 177 + +[skill_guide_crafting_jewellery] +id = 178 + +[skill_guide_crafting_staffs] +id = 179 + +[skill_guide_fishing] +id = 180 + +[skill_guide_fletching_bows] +id = 181 + +[skill_guide_fletching_arrows] +id = 182 + +[skill_guide_fletching_darts] +id = 183 + +[skill_guide_fletching_bolts] +id = 184 + +[skill_guide_herblore_herbs] +id = 185 + +[skill_guide_herblore_potions] +id = 186 + +[skill_guide_mining_ores] +id = 187 + +[skill_guide_mining_pickaxes] +id = 188 + +[skill_guide_ranged_bows] +id = 189 + +[skill_guide_ranged_thrown] +id = 190 + +[skill_guide_ranged_armour] +id = 191 + +[skill_guide_runecrafting] +id = 192 + +[skill_guide_smithing_smelting] +id = 193 + +[skill_guide_smithing_bronze] +id = 194 + +[skill_guide_smithing_iron] +id = 195 + +[skill_guide_smithing_steel] +id = 196 + +[skill_guide_smithing_mithril] +id = 197 + +[skill_guide_smithing_adamant] +id = 198 + +[skill_guide_smithing_rune] +id = 199 + +[skill_guide_thieving_stalls] +id = 200 + +[skill_guide_thieving_pickpocket] +id = 201 + +[skill_guide_crafting_armour] +id = 202 + +[skill_guide_woodcutting] +id = 203 + +[skill_guide_magic_armour] +id = 206 + +[trail_puzzlehintinv] +id = 207 + +[boardgames_boardinv] +id = 215 + +[boardgames_sideinv] +id = 216 + +[reinitialisation_inv] +id = 221 + +[reinitialisation_inv_inactive] +id = 222 + +[skill_guide_slayer_monsters] +id = 229 + +[skill_guide_slayer_equipment] +id = 230 + +[generalshop_phasmatys] +id = 233 + +[skill_guide_cooking_hotdrinks] +id = 234 + +[castlewars_trade_tickets] +id = 246 + +[castlewars_trade_coins] +id = 247 + +[minecart_temp_inv] +id = 272 + +[skill_guide_thieving_chests] +id = 274 + +[skill_guide_prayer] +id = 275 + +[roguesden_puzzle_rotation] +id = 278 + +[skill_guide_cooking_brewing] +id = 284 + +[skill_guide_cooking_vegetables] +id = 285 + +[skill_guide_crafting_weaving] +id = 286 + +[skill_guide_farming_veg] +id = 287 + +[skill_guide_farming_hops] +id = 288 + +[skill_guide_farming_trees] +id = 289 + +[skill_guide_farming_fruit_trees] +id = 290 + +[skill_guide_farming_herbs] +id = 291 + +[skill_guide_farming_flowers] +id = 292 + +[skill_guide_farming_bushes] +id = 293 + +[skill_guide_farming_special] +id = 294 + +[skill_guide_farming_mushroom] +id = 295 + +[skill_guide_farming_cactus] +id = 296 + +[skill_guide_farming_calquat] +id = 297 + +[skill_guide_farming_spirit_tree] +id = 298 + +[skill_guide_farming_scarecrow] +id = 299 + +[skill_guide_farming_belladonna] +id = 300 + +[macro_certer] +id = 307 + +[roguetrader_toughsudukuinv] +id = 308 + +[roguetrader_alim_runedump] +id = 315 + +[blast_furnace_inv] +id = 316 + +[blast_furnace_bars_inv] +id = 317 + +[skill_guide_agility_courses] +id = 321 + +[skill_guide_agility_areas] +id = 322 + +[skill_guide_agility_shortcuts] +id = 323 + +[tzhaar_shop_general] +id = 324 + +[skill_guide_cooking_dairy] +id = 335 + +[skill_guide_woodcutting_axes] +id = 336 + +[skill_guide_woodcutting_canoes] +id = 353 + +[farming_tools_fairyversion] +id = 354 + +[wielded_weapon_inv] +id = 355 + +[hundred_quest_journal] +id = 376 + +[misc_resources_collected] +id = 390 + +[poh_furniture_menu_inv] +id = 398 + +[skill_guide_thieving_other] +id = 415 + +[skill_guide_ranged_shortcuts] +id = 420 + +[skill_guide_strength_weapons_and_armour] +id = 421 + +[skill_guide_strength_shortcuts] +id = 422 + +[skill_guide_fletching_cbows] +id = 423 + +[skill_guide_magic_bolts] +id = 424 + +[skill_guide_ranged_crossbows] +id = 425 + +[skill_guide_smithing_blurite] +id = 426 + +[skill_guide_cooking_gnome] +id = 437 + +[eyeglo_inv_in] +id = 438 + +[eyeglo_inv_out] +id = 439 + +[poh_costume_menu_inv] +id = 445 + +[skill_guide_hunting_tracking] +id = 469 + +[skill_guide_hunting_birds] +id = 470 + +[skill_guide_hunting_butterflies] +id = 471 + +[skill_guide_hunting_deadfalls] +id = 472 + +[skill_guide_hunting_boxtraps] +id = 473 + +[skill_guide_hunting_nettraps] +id = 474 + +[skill_guide_hunting_pitfalls] +id = 475 + +[skill_guide_hunting_falconry] +id = 476 + +[skill_guide_hunting_impboxes] +id = 477 + +[skill_guide_hunting_rabbits] +id = 478 + +[skill_guide_hunting_eagles] +id = 479 + +[skill_guide_hunting_traps] +id = 480 + +[skill_guide_hunting_clothing] +id = 481 + +[barbassault_egginv] +id = 490 + +[dorgesh_food_sold] +id = 510 + +[dream_bank_inventory] +id = 514 + +[dream_crate_inventory] +id = 515 + +[lotg_temp_armour_holder] +id = 516 + +[stockmarket_offer0] +id = 517 + +[stockmarket_offer1] +id = 518 + +[stockmarket_offer2] +id = 519 + +[stockmarket_offer3] +id = 520 + +[stockmarket_offer4] +id = 521 + +[stockmarket_offer5] +id = 522 + +[partyroom_chestinv] +id = 529 + +[easter08_transmog_inv] +id = 535 + +[inventory_539] +id = 539 + +[crcs_ranged_weapons] +id = 542 + +[crcs_magic_spells] +id = 543 + +[crcs_agility_juggling] +id = 544 + +[crcs_rewards_inv] +id = 545 + +[pvpw_drop_inv] +id = 546 + +[glomem_inv_beer] +id = 548 + +[mob_offer] +id = 550 + +[poh_pethouse] +id = 552 + +[evq_inv_bow] +id = 563 + +[inventory_468] +id = 568 + +[death_bootshop] +id = 569 + +[lumbcat_runeshop] id = 572 defaults = [ { id = "air_rune", amount = 300 }, ] -[inventory_578] +[rand_bound] +id = 573 + +[thigui_fence_low_nocosh] id = 578 defaults = [ { id = "tinderbox", amount = 10 }, { id = "rope", amount = 10 }, ] + +[inventory_583] +id = 583 + +[inventory_584] +id = 584 + +[apmeken_banana_shop] +id = 607 \ No newline at end of file diff --git a/data/entity/player/modal/character_creation/character_creation.invs.toml b/data/entity/player/modal/character_creation/character_creation.invs.toml index 131c0a79c0..a9ff07fd66 100644 --- a/data/entity/player/modal/character_creation/character_creation.invs.toml +++ b/data/entity/player/modal/character_creation/character_creation.invs.toml @@ -21,7 +21,7 @@ defaults = [ { id = "character_creation_12", amount = 1 }, ] -[inventory_585] +[playerkit_default] id = 585 defaults = [ { id = "character_creation_shadow", amount = 1 }, diff --git a/data/skill/construction/construction.invs.toml b/data/skill/construction/construction.invs.toml index d4cd5e34a7..70492c9878 100644 --- a/data/skill/construction/construction.invs.toml +++ b/data/skill/construction/construction.invs.toml @@ -1,4 +1,4 @@ -[inventory_399] +[skill_guide_carpentry_rooms] id = 399 defaults = [ { id = "pond", amount = 1 }, @@ -22,7 +22,7 @@ defaults = [ { id = "mahogany_chest", amount = 1 }, ] -[inventory_400] +[skill_guide_carpentry_seating] id = 400 defaults = [ { id = "crude_wooden_chair", amount = 1 }, @@ -65,7 +65,7 @@ defaults = [ { id = "demonic_throne", amount = 1 }, ] -[inventory_401] +[skill_guide_carpentry_storage] id = 401 defaults = [ { id = "wooden_bookcase", amount = 1 }, @@ -108,7 +108,7 @@ defaults = [ { id = "gilded_wardrobe", amount = 1 }, ] -[inventory_402] +[skill_guide_carpentry_skills] id = 402 defaults = [ { id = "clay_fireplace", amount = 1 }, @@ -141,7 +141,7 @@ defaults = [ { id = "bench_with_lathe", amount = 1 }, ] -[inventory_403] +[skill_guide_carpentry_decorative] id = 403 defaults = [ { id = "brown_rug", amount = 1 }, @@ -181,7 +181,7 @@ defaults = [ { id = "large_orrery", amount = 1 }, ] -[inventory_404] +[skill_guide_carpentry_games] id = 404 defaults = [ { id = "hoop_and_stick", amount = 1 }, @@ -209,7 +209,7 @@ defaults = [ { id = "balance_beam", amount = 1 }, ] -[inventory_405] +[skill_guide_carpentry_garden] id = 405 defaults = [ { id = "exit_portal", amount = 1 }, @@ -253,7 +253,7 @@ defaults = [ { id = "posh_fountain", amount = 1 }, ] -[inventory_406] +[skill_guide_carpentry_misc] id = 406 defaults = [ { id = "cat_blanket", amount = 1 }, @@ -284,7 +284,7 @@ defaults = [ { id = "marble_spiral", amount = 1 }, ] -[inventory_407] +[skill_guide_carpentry_chapel] id = 407 defaults = [ { id = "oak_altar", amount = 1 }, @@ -319,7 +319,7 @@ defaults = [ { id = "stained_glass", amount = 1 }, ] -[inventory_408] +[skill_guide_carpentry_dungeon] id = 408 defaults = [ { id = "floor_decoration", amount = 1 }, @@ -363,7 +363,7 @@ defaults = [ { id = "steel_dragon", amount = 1 }, ] -[inventory_409] +[skill_guide_carpentry_trophies] id = 409 defaults = [ { id = "oak_decoration", amount = 1 }, @@ -391,7 +391,7 @@ defaults = [ { id = "kite_shield", amount = 1 }, ] -[inventory_446] +[poh_costume_room_magic_wardrobe_inv] id = 446 defaults = [ { id = "mystic_hat_blue_2", amount = 1 }, @@ -413,7 +413,7 @@ defaults = [ { id = "lord_marshal_cap_2", amount = 1 }, ] -[inventory_448] +[poh_costume_room_ame_inv] id = 448 defaults = [ { id = "mime_mask_2", amount = 1 }, @@ -426,7 +426,7 @@ defaults = [ { id = "swanky_boots_2", amount = 1 }, ] -[inventory_449] +[poh_costume_room_treasure_trail_1_inv] id = 449 defaults = [ { id = "black_platebody_h1_2", amount = 1 }, @@ -461,7 +461,7 @@ defaults = [ { id = "more", amount = 1 }, ] -[inventory_450] +[poh_costume_room_treasure_trail_2_inv] id = 450 defaults = [ { id = "red_boater_2", amount = 1 }, @@ -496,7 +496,7 @@ defaults = [ { id = "more", amount = 1 }, ] -[inventory_451] +[poh_costume_room_treasure_trail_3_inv] id = 451 defaults = [ { id = "rune_platebody_h1_2", amount = 1 }, @@ -566,7 +566,7 @@ defaults = [ { id = "more", amount = 1 }, ] -[inventory_454] +[skill_guide_carpentry_costume_room] id = 454 defaults = [ { id = "oak_magic_wardrobe", amount = 1 }, @@ -596,7 +596,7 @@ defaults = [ { id = "magical_cape_rack", amount = 1 }, ] -[inventory_485] +[poh_costume_room_treasure_trail_1a_inv] id = 485 defaults = [ { id = "black_cane_2", amount = 1 }, @@ -604,7 +604,7 @@ defaults = [ { id = "back", amount = 1 }, ] -[inventory_487] +[poh_costume_room_treasure_trail_3a_inv] id = 487 defaults = [ { id = "fox_mask_2", amount = 1 }, @@ -628,7 +628,7 @@ defaults = [ { id = "back", amount = 1 }, ] -[inventory_496] +[poh_costume_room_treasure_trail_1_inv_check] id = 496 defaults = [ { id = "black_platebody_h1", amount = 1 }, @@ -663,7 +663,7 @@ defaults = [ { id = "more", amount = 1 }, ] -[inventory_497] +[poh_costume_room_treasure_trail_1a_inv_check] id = 497 defaults = [ { id = "black_cane", amount = 1 }, @@ -671,7 +671,7 @@ defaults = [ { id = "back", amount = 1 }, ] -[inventory_498] +[poh_costume_room_treasure_trail_2_inv_check] id = 498 defaults = [ { id = "red_boater", amount = 1 }, @@ -706,7 +706,7 @@ defaults = [ { id = "more", amount = 1 }, ] -[inventory_500] +[poh_costume_room_treasure_trail_3_inv_check] id = 500 defaults = [ { id = "rune_platebody_h1", amount = 1 }, @@ -741,7 +741,7 @@ defaults = [ { id = "more", amount = 1 }, ] -[inventory_501] +[poh_costume_room_treasure_trail_3a_inv_check] id = 501 defaults = [ { id = "fox_mask", amount = 1 }, @@ -753,7 +753,7 @@ defaults = [ { id = "back", amount = 1 }, ] -[inventory_537] +[poh_costume_room_armour_inv_page2] id = 537 defaults = [ { id = "void_knight_deflector_2", amount = 1 }, @@ -774,7 +774,7 @@ defaults = [ { id = "back", amount = 1 }, ] -[inventory_581] +[poh_costume_room_treasure_trail_4_inv] id = 581 defaults = [ { id = "black_dragon_mask_2", amount = 1 }, @@ -797,7 +797,7 @@ defaults = [ { id = "ancient_dragonhide", amount = 1 }, ] -[inventory_582] +[poh_costume_room_treasure_trail_4_inv_check] id = 582 defaults = [ { id = "black_dragon_mask", amount = 1 }, diff --git a/data/skill/smithing/smithing.invs.toml b/data/skill/smithing/smithing.invs.toml index fd9a9865a0..f5dcaa1ab4 100644 --- a/data/skill/smithing/smithing.invs.toml +++ b/data/skill/smithing/smithing.invs.toml @@ -325,56 +325,56 @@ defaults = [ { id = "bullseye_lantern_frame", amount = 1 }, ] -[inventory_337] +[silvercast_holysymbol] id = 337 defaults = [ { id = "unstrung_symbol", amount = 1 }, ] -[inventory_338] +[silvercast_unholysymbol] id = 338 defaults = [ { id = "unstrung_emblem", amount = 1 }, ] -[inventory_339] +[silvercast_sickle] id = 339 defaults = [ { id = "silver_sickle", amount = 1 }, ] -[inventory_340] +[silvercast_lightning] id = 340 defaults = [ { id = "conductor", amount = 1 }, ] -[inventory_341] +[silvercast_tiara] id = 341 defaults = [ { id = "tiara", amount = 1 }, ] -[inventory_342] +[silvercast_agrith] id = 342 defaults = [ { id = "demonic_sigil", amount = 1 }, ] -[inventory_382] +[silvercast_commandrod] id = 382 defaults = [ { id = "silvthrill_rod", amount = 1 }, ] -[inventory_386] +[wine_merchant_free] id = 386 defaults = [ { id = "jug_of_wine", amount = 10 }, { id = "jug", amount = 10 }, ] -[inventory_429] +[silvercast_xbows] id = 429 defaults = [ { id = "silver_bolts_unf", amount = 10 }, From 96d34b842a959563639f1be6195abb73300959ab Mon Sep 17 00:00:00 2001 From: Ebp90 <40249395+Ebp90@users.noreply.github.com> Date: Thu, 25 Jun 2026 09:34:04 -0400 Subject: [PATCH 10/15] Add Zogre Flesh Eaters (#1009) * Feat: Zogre Flesh Eaters * Basic fixes * Formatting * Add ogre dialogue expressions * Add more error handling safety around suspension resuming * Start adding zogre flesh eaters test * Add quest integration test * Fix disease and add disease tests * Formatting * Replace quest int variable with map strings * Tidy * Formatting * Fix monster examine unit test * Fix stat spy test --------- Co-authored-by: GregHib --- .../feldip_hills/feldip_hills.npc-spawns.toml | 1 - .../feldip_hills/feldip_hills.npcs.toml | 7 +- .../jiggig/dungeon/jiggig_dungeon.areas.toml | 5 + .../jiggig/dungeon/jiggig_dungeon.combat.toml | 31 + .../feldip_hills/jiggig/jiggig.combat.toml | 75 ++ .../jiggig/jiggig.npc-spawns.toml | 2 +- .../feldip_hills/jiggig/jiggig.npcs.toml | 17 +- data/area/kandarin/yanille/yanille.objs.toml | 4 + .../braindeath_island.combat.toml | 2 +- .../npc/humanoid/jogre/jogre.combat.toml | 2 +- data/entity/player/human.anims.toml | 3 + .../big_chompy_bird_hunting.anims.toml | 39 +- .../big_chompy_bird_hunting.gfx.toml | 6 + .../big_chompy_bird_hunting.npcs.toml | 26 +- .../big_chompy_bird_hunting.sounds.toml | 15 + .../zogre_flesh_eaters.anims.toml | 59 ++ .../zogre_flesh_eaters.combat.toml | 27 + .../zogre_flesh_eaters.drops.toml | 12 + .../zogre_flesh_eaters.gfx.toml | 12 + .../zogre_flesh_eaters.items.toml | 10 +- .../zogre_flesh_eaters.npcs.toml | 22 + .../zogre_flesh_eaters.objs.toml | 62 ++ .../zogre_flesh_eaters.sounds.toml | 47 ++ .../zogre_flesh_eaters.varbits.toml | 92 ++- .../zogre_human_brentle_vahn.combat.toml | 13 + data/quest/quests.toml | 19 + data/skill/fletching/arrowtip.recipes.toml | 16 +- data/skill/magic/spells.tables.toml | 2 - .../data/config/ItemOnItemDefinition.kt | 1 + .../data/definition/ItemOnItemDefinitions.kt | 3 + .../engine/entity/character/npc/NPCs.kt | 8 +- .../engine/entity/character/player/Player.kt | 4 + .../gregs/voidps/engine/suspend/Suspend.kt | 15 +- .../gregs/voidps/engine/suspend/Suspension.kt | 44 +- .../area/kandarin/feldip_hills/Grish.kt | 485 ++++++++++++ .../area/kandarin/feldip_hills/OgreGuard.kt | 64 ++ .../area/kandarin/feldip_hills/UglugNar.kt | 106 +++ .../kandarin/yanille/BartenderDragonInn.kt | 77 ++ .../area/kandarin/yanille/SithikInts.kt | 518 +++++++++++++ .../area/kandarin/yanille/ZavisticRarve.kt | 704 ++++++++++++++++++ .../content/area/karamja/ape_atroll/Daga.kt | 68 +- .../area/misthalin/paterdomus/Drezel.kt | 4 +- .../content/area/misthalin/varrock/Benny.kt | 9 +- .../content/area/misthalin/varrock/Dealga.kt | 8 +- .../misthalin/varrock/palace/KingRoald.kt | 4 +- .../mort_myre_swamp/FillimanTarlock.kt | 5 +- .../content/entity/effect/toxin/Disease.kt | 98 ++- .../content/entity/effect/toxin/Poison.kt | 37 +- .../content/entity/npc/combat/Attack.kt | 7 +- .../content/entity/player/combat/Attack.kt | 21 +- .../entity/player/inv/item/ItemOnItems.kt | 4 +- game/src/main/kotlin/content/quest/Quest.kt | 1 + .../quest/member/ogre/ZogreFleshEaters.kt | 699 +++++++++++++++++ .../content/skill/firemaking/LightSource.kt | 8 +- .../skill/magic/book/lunar/MonsterExamine.kt | 3 +- .../content/skill/magic/book/lunar/StatSpy.kt | 3 +- .../skill/summoning/pet/PetShopOwner.kt | 20 +- game/src/test/kotlin/InstructionCalls.kt | 5 + .../entity/effect/toxin/DiseaseTest.kt | 91 +++ .../ZogreFleshEatersTest.kt | 251 +++++++ 60 files changed, 3819 insertions(+), 184 deletions(-) create mode 100644 data/area/kandarin/feldip_hills/jiggig/dungeon/jiggig_dungeon.areas.toml create mode 100644 data/area/kandarin/feldip_hills/jiggig/dungeon/jiggig_dungeon.combat.toml create mode 100644 data/area/kandarin/feldip_hills/jiggig/jiggig.combat.toml create mode 100644 data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.anims.toml create mode 100644 data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.combat.toml create mode 100644 data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.gfx.toml create mode 100644 data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.objs.toml create mode 100644 data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.sounds.toml create mode 100644 data/quest/members/zogre_flesh_eaters/zogre_human_brentle_vahn.combat.toml create mode 100644 game/src/main/kotlin/content/area/kandarin/feldip_hills/Grish.kt create mode 100644 game/src/main/kotlin/content/area/kandarin/feldip_hills/OgreGuard.kt create mode 100644 game/src/main/kotlin/content/area/kandarin/feldip_hills/UglugNar.kt create mode 100644 game/src/main/kotlin/content/area/kandarin/yanille/SithikInts.kt create mode 100644 game/src/main/kotlin/content/area/kandarin/yanille/ZavisticRarve.kt create mode 100644 game/src/main/kotlin/content/quest/member/ogre/ZogreFleshEaters.kt create mode 100644 game/src/test/kotlin/content/entity/effect/toxin/DiseaseTest.kt create mode 100644 game/src/test/kotlin/content/quest/member/zogre_flesh_eaters/ZogreFleshEatersTest.kt diff --git a/data/area/kandarin/feldip_hills/feldip_hills.npc-spawns.toml b/data/area/kandarin/feldip_hills/feldip_hills.npc-spawns.toml index 2d6145d6cc..c61c73e54b 100644 --- a/data/area/kandarin/feldip_hills/feldip_hills.npc-spawns.toml +++ b/data/area/kandarin/feldip_hills/feldip_hills.npc-spawns.toml @@ -228,5 +228,4 @@ spawns = [ { id = "mound_feldip_hills", x = 2465, y = 2911, members = true }, { id = "mound_feldip_hills", x = 2466, y = 2921, members = true }, { id = "wolf", x = 2470, y = 2865 }, - { id = "rantz", x = 2630, y = 2981 }, ] \ No newline at end of file diff --git a/data/area/kandarin/feldip_hills/feldip_hills.npcs.toml b/data/area/kandarin/feldip_hills/feldip_hills.npcs.toml index cb1a0a4ad2..f4704e5e15 100644 --- a/data/area/kandarin/feldip_hills/feldip_hills.npcs.toml +++ b/data/area/kandarin/feldip_hills/feldip_hills.npcs.toml @@ -80,6 +80,7 @@ examine = "A large and contentious lady ogre." [rantz_feldip_hills_2] id = 8659 +dialogue = "ogre" [mound_feldip_hills] id = 9466 @@ -115,11 +116,6 @@ id = 13174 id = 5073 examine = "This bird obviously doesn't believe in subtlety." -[rantz] -id = 3587 -wander_range = 4 -examine = "A large dim looking humanoid." - [ogre_boat_feldip_hills_2] id = 3472 @@ -128,6 +124,7 @@ id = 3467 [rantz_feldip_hills_2_2] id = 1010 +dialogue = "ogre" [fishing_spot_big_net_harpoon_feldip_hills] id = 7044 diff --git a/data/area/kandarin/feldip_hills/jiggig/dungeon/jiggig_dungeon.areas.toml b/data/area/kandarin/feldip_hills/jiggig/dungeon/jiggig_dungeon.areas.toml new file mode 100644 index 0000000000..658f27ce99 --- /dev/null +++ b/data/area/kandarin/feldip_hills/jiggig/dungeon/jiggig_dungeon.areas.toml @@ -0,0 +1,5 @@ +[zogre_blackened_area] +x = [2447, 2448] +y = [9459, 9467] +level = 2 +tags = ["dark"] \ No newline at end of file diff --git a/data/area/kandarin/feldip_hills/jiggig/dungeon/jiggig_dungeon.combat.toml b/data/area/kandarin/feldip_hills/jiggig/dungeon/jiggig_dungeon.combat.toml new file mode 100644 index 0000000000..fab80f06c7 --- /dev/null +++ b/data/area/kandarin/feldip_hills/jiggig/dungeon/jiggig_dungeon.combat.toml @@ -0,0 +1,31 @@ +[zogre_jiggig_dungeon] +attack_speed = 6 +defend_anim = "ogre_defend" +defend_sound = "zogre_hit" +death_anim = "ogre_death" +death_sound = "zogre_death" + +[zogre_jiggig_dungeon.melee] +range = 1 +anim = "ogre_attack" +target_sound = "zogre_attack" +target_hit = { offense = "crush", max = 50 } +impact_disease = 10 + +[zogre_jiggig_dungeon_2] +clone = "zogre_jiggig_dungeon" + +[zogre_jiggig_dungeon_2.melee] +clone = "zogre_jiggig_dungeon.melee" + +[zogre_jiggig_dungeon_3] +clone = "zogre_jiggig_dungeon" + +[zogre_jiggig_dungeon_3.melee] +clone = "zogre_jiggig_dungeon.melee" + +[zogre_jiggig_dungeon_4] +clone = "zogre_jiggig_dungeon" + +[zogre_jiggig_dungeon_4.melee] +clone = "zogre_jiggig_dungeon.melee" \ No newline at end of file diff --git a/data/area/kandarin/feldip_hills/jiggig/jiggig.combat.toml b/data/area/kandarin/feldip_hills/jiggig/jiggig.combat.toml new file mode 100644 index 0000000000..259d3140dc --- /dev/null +++ b/data/area/kandarin/feldip_hills/jiggig/jiggig.combat.toml @@ -0,0 +1,75 @@ +[zogre_jiggig] +attack_speed = 6 +defend_anim = "ogre_defend" +defend_sound = "zogre_hit" +death_anim = "ogre_death" +death_sound = "zogre_death" + +[zogre_jiggig.melee] +range = 1 +anim = "ogre_attack" +target_sound = "zogre_attack" +target_hit = { offense = "crush", max = 50 } +impact_disease = 10 + +[zogre_jiggig_2] +clone = "zogre_jiggig" + +[zogre_jiggig_2.melee] +clone = "zogre_jiggig.melee" + +[zogre_jiggig_3] +clone = "zogre_jiggig" + +[zogre_jiggig_3.melee] +clone = "zogre_jiggig.melee" + +[zogre_jiggig_4] +clone = "zogre_jiggig" + +[zogre_jiggig_4.melee] +clone = "zogre_jiggig.melee" + +[zogre_jiggig_5] +clone = "zogre_jiggig" + +[zogre_jiggig_5.melee] +clone = "zogre_jiggig.melee" + +[zogre_jiggig_6] +clone = "zogre_jiggig" + +[zogre_jiggig_6.melee] +clone = "zogre_jiggig.melee" + +[zogre_jiggig_7] +clone = "zogre_jiggig" + +[zogre_jiggig_7.melee] +clone = "zogre_jiggig.melee" + +[skogre_jiggig] +attack_speed = 6 +defend_anim = "ogre_defend" +defend_sound = "skelly_hit" +death_anim = "ogre_death" +death_sound = "zogre_death" + +[skogre_jiggig.melee] +range = 1 +anim = "ogre_attack" +target_sound = "giant_attack" +target_hit = { offense = "crush", max = 50 } +impact_disease = 10 + +[skogre_jiggig_2] +clone = "skogre_jiggig" + +[skogre_jiggig_2.melee] +clone = "skogre_jiggig.melee" + +[skogre_jiggig_3] +clone = "skogre_jiggig" + +[skogre_jiggig_3.melee] +clone = "skogre_jiggig.melee" \ No newline at end of file diff --git a/data/area/kandarin/feldip_hills/jiggig/jiggig.npc-spawns.toml b/data/area/kandarin/feldip_hills/jiggig/jiggig.npc-spawns.toml index 60cf2469d9..2c799f055f 100644 --- a/data/area/kandarin/feldip_hills/jiggig/jiggig.npc-spawns.toml +++ b/data/area/kandarin/feldip_hills/jiggig/jiggig.npc-spawns.toml @@ -3,7 +3,7 @@ spawns = [ { id = "uglug_nar", x = 2442, y = 3049, members = true }, { id = "pilg", x = 2443, y = 3046, members = true }, { id = "grug", x = 2446, y = 3049, members = true }, - { id = "ogre_guard_jiggig", x = 2454, y = 3047, members = true }, + { id = "zogre_ogre_guard", x = 2454, y = 3047, members = true }, { id = "ogre_guard_jiggig_2", x = 2443, y = 3038, members = true }, { id = "ogre_guard_jiggig_2", x = 2452, y = 3030, members = true }, { id = "zogre_jiggig", x = 2486, y = 3048, members = true }, diff --git a/data/area/kandarin/feldip_hills/jiggig/jiggig.npcs.toml b/data/area/kandarin/feldip_hills/jiggig/jiggig.npcs.toml index 6cf4186246..4833dd0efd 100644 --- a/data/area/kandarin/feldip_hills/jiggig/jiggig.npcs.toml +++ b/data/area/kandarin/feldip_hills/jiggig/jiggig.npcs.toml @@ -1,24 +1,30 @@ [grish] id = 2038 +dialogue = "ogre" examine = "An ogre shaman" [uglug_nar] id = 2039 +dialogue = "ogre" examine = "An ogre shaman" [pilg] id = 2040 +dialogue = "ogre" examine = "They're done for!" [grug] id = 2041 +dialogue = "ogre" examine = "They're done for!" -[ogre_guard_jiggig] +[zogre_ogre_guard] id = 2042 +dialogue = "ogre" [ogre_guard_jiggig_2] id = 2043 +dialogue = "ogre" [zogre_jiggig] id = 2044 @@ -58,6 +64,7 @@ examine = "An undead skeletal ogre." [skogre_jiggig_2] id = 2056 +retaliates = false clone = "skogre_jiggig" [skogre_jiggig_3] @@ -66,20 +73,24 @@ clone = "skogre_jiggig" [zogre_jiggig_3] id = 2051 +clone = "zogre_jiggig" [zogre_jiggig_4] id = 2052 +clone = "zogre_jiggig" [zogre_jiggig_5] id = 2053 +clone = "zogre_jiggig" [zogre_jiggig_6] id = 2054 +clone = "zogre_jiggig" [zogre_jiggig_7] id = 2055 -categories = ["ogres"] -examine = "A partially decomposing zombie ogre." +retaliates = false +clone = "zogre_jiggig" [ogre_cook_jiggig] id = 791 diff --git a/data/area/kandarin/yanille/yanille.objs.toml b/data/area/kandarin/yanille/yanille.objs.toml index 4beec11fcc..81ac7bf730 100644 --- a/data/area/kandarin/yanille/yanille.objs.toml +++ b/data/area/kandarin/yanille/yanille.objs.toml @@ -2,6 +2,10 @@ id = 9302 examine = "A rather strategically placed hole, which appears to be in the ground." +[zogre_outdoor_bell] +id = 6847 +examine = "A bell to attract the attention of the secretary." + [yanille_underwall_tunnel_castle_wall] id = 9301 examine = "A well constructed castle wall." diff --git a/data/area/morytania/braindeath_island/braindeath_island.combat.toml b/data/area/morytania/braindeath_island/braindeath_island.combat.toml index 0d4afb1a3b..000df10208 100644 --- a/data/area/morytania/braindeath_island/braindeath_island.combat.toml +++ b/data/area/morytania/braindeath_island/braindeath_island.combat.toml @@ -19,7 +19,7 @@ range = 1 anim = "spider_large_attack" target_sound = "insect_attack" impact_regardless = true -impact_disease = 80 +impact_disease = 8 [zombie_pirate] attack_speed = 4 diff --git a/data/entity/npc/humanoid/jogre/jogre.combat.toml b/data/entity/npc/humanoid/jogre/jogre.combat.toml index d8614a0b55..fde38f19b7 100644 --- a/data/entity/npc/humanoid/jogre/jogre.combat.toml +++ b/data/entity/npc/humanoid/jogre/jogre.combat.toml @@ -10,5 +10,5 @@ death_sound = "giant_death" chance = 75 range = 1 anim = "ogre_attack" -target_sound = "giant_attack" +target_sound = "moss_giant_attack" target_hit = { offense = "crush", max = 80 } diff --git a/data/entity/player/human.anims.toml b/data/entity/player/human.anims.toml index d77e236f9e..abf3bbcf2b 100644 --- a/data/entity/player/human.anims.toml +++ b/data/entity/player/human.anims.toml @@ -22,6 +22,9 @@ id = 422 [unarmed_kick] id = 423 +[human_dancing] +id = 818 + [unarmed_block] id = 422 diff --git a/data/quest/members/big_chompy_bird_hunting/big_chompy_bird_hunting.anims.toml b/data/quest/members/big_chompy_bird_hunting/big_chompy_bird_hunting.anims.toml index e8911fba42..310ce63418 100644 --- a/data/quest/members/big_chompy_bird_hunting/big_chompy_bird_hunting.anims.toml +++ b/data/quest/members/big_chompy_bird_hunting/big_chompy_bird_hunting.anims.toml @@ -5,4 +5,41 @@ id = 6800 id = 6802 [chompy_bird_death] -id = 6823 \ No newline at end of file +id = 6823 + +# Cache-canonical names from D:/Names. Some collide on id with existing aliases; that's allowed. +[chompy_update_attack] +id = 6761 + +[chompy_update_death] +id = 6762 + +[chompy_update_fly_down] +id = 6766 + +[jubbly_update_attack] +id = 6800 + +[jubbly_update_death] +id = 6801 + +[jubbly_update_fly_down] +id = 6805 + +[human_cooking] +id = 896 + +[chompy_toad_inflate] +id = 1019 + +[human_chompybird_ogrebellows] +id = 1026 + +[human_ogre_fletching] +id = 4433 + +[human_castcurse_walkmerge] +id = 11428 + +[human_openchest] +id = 536 \ No newline at end of file diff --git a/data/quest/members/big_chompy_bird_hunting/big_chompy_bird_hunting.gfx.toml b/data/quest/members/big_chompy_bird_hunting/big_chompy_bird_hunting.gfx.toml index 380ecd427d..4657cbcfbb 100644 --- a/data/quest/members/big_chompy_bird_hunting/big_chompy_bird_hunting.gfx.toml +++ b/data/quest/members/big_chompy_bird_hunting/big_chompy_bird_hunting.gfx.toml @@ -1,2 +1,8 @@ [ogre_bellows] id = 241 + +[chompy_toad_exploding] +id = 240 + +[ogre_arrow_travel] +id = 242 \ No newline at end of file diff --git a/data/quest/members/big_chompy_bird_hunting/big_chompy_bird_hunting.npcs.toml b/data/quest/members/big_chompy_bird_hunting/big_chompy_bird_hunting.npcs.toml index 5918753b27..aa5e387717 100644 --- a/data/quest/members/big_chompy_bird_hunting/big_chompy_bird_hunting.npcs.toml +++ b/data/quest/members/big_chompy_bird_hunting/big_chompy_bird_hunting.npcs.toml @@ -8,5 +8,29 @@ combat_def = "chompy_bird" slayer_xp = 10.0 categories = ["birds"] immune_poison = true -drop_table = "chompy_bird" +# No drop_table — Java intercepts death and transforms into a pluckable carcass instead. examine = "A large boisterous bird, a delicacy for ogres." + +[jubbly_bird_chompy] +id = 3476 +hitpoints = 100 +att = 5 +str = 5 +def = 3 +combat_def = "chompy_bird" +slayer_xp = 10.0 +categories = ["birds"] +immune_poison = true +examine = "A large boisterous bird, a delicacy for ogres." + +[plucked_chompy] +id = 1016 +examine = "A large boisterous bird, a delicacy for ogres." + +[plucked_jubbly] +id = 3477 +examine = "A large boisterous bird, a delicacy for ogres." + +[bloated_toad_placed] +id = 1014 +examine = "A bloated toad." diff --git a/data/quest/members/big_chompy_bird_hunting/big_chompy_bird_hunting.sounds.toml b/data/quest/members/big_chompy_bird_hunting/big_chompy_bird_hunting.sounds.toml index c660b71454..f5ab4a1dcd 100644 --- a/data/quest/members/big_chompy_bird_hunting/big_chompy_bird_hunting.sounds.toml +++ b/data/quest/members/big_chompy_bird_hunting/big_chompy_bird_hunting.sounds.toml @@ -12,3 +12,18 @@ id = 1451 [chompy_bird_squak] id = 1453 + +[ogre_bow] +id = 1452 + +[ogre_bellows_suck] +id = 1454 + +[ogre_bellows] +id = 1455 + +[spit_roast] +id = 1456 + +[toad_croak] +id = 1458 \ No newline at end of file diff --git a/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.anims.toml b/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.anims.toml new file mode 100644 index 0000000000..6c355aa9e7 --- /dev/null +++ b/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.anims.toml @@ -0,0 +1,59 @@ +[ogre_kick] +id = 2102 + +[slash_bash_melee] +id = 11458 + +[ogre_breath] +id = 2101 + +[human_reachforladder] +id = 828 + +[human_mapping] +id = 909 + +[regicide_stepover] +id = 1236 + +[zombie_update_defend_normal] +id = 5567 + +[zombie_update_attack_normal] +id = 5568 + +[zombie_update_death_normal] +id = 5569 + +[zogre_bell_ring] +id = 2103 + +[expression_ogre_neutral] +id = 8579 + +[expression_ogre_quiz] +id = 8580 + +[expression_ogre_angry] +id = 8583 + +[expression_ogre_mad] +id = 8657 + +[expression_ogre_confused] +id = 8584 + +[expression_ogre_happy] +id = 8585 + +[expression_ogre_scared] +id = 8598 + +[expression_ogre_sad] +id = 8659 + +[expression_ogre_shifty] +id = 8661 + +[expression_ogre_shock] +id = 8662 diff --git a/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.combat.toml b/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.combat.toml new file mode 100644 index 0000000000..a947dca2b8 --- /dev/null +++ b/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.combat.toml @@ -0,0 +1,27 @@ +[slash_bash] +attack_speed = 6 +retreat_range = 12 +defend_anim = "ogre_defend" +defend_sound = "zogre_hit" +death_anim = "ogre_death" +death_sound = "zogre_death" + +[slash_bash.melee] +chance = 1 +range = 1 +anim = "slash_bash_melee" +target_sound = "zogre_attack" +target_hit = { offense = "crush", max = 130 } +impact_disease = 15 +approach = false + +[slash_bash.range] +chance = 1 +range = 10 +anim = "ogre_breath" +target_sound = "ogre_bow" +projectile = "slash_bash_projectile" +projectile_origin_x = 1 +projectile_origin_y = 1 +target_hit = { offense = "range", max = 130 } +impact_disease = 15 \ No newline at end of file diff --git a/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.drops.toml b/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.drops.toml index fe84e93d68..d423cd06da 100644 --- a/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.drops.toml +++ b/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.drops.toml @@ -12,3 +12,15 @@ roll = 5000 drops = [ { id = "zombie_champions_scroll", members = true, lacks = "zombie_champions_scroll" }, ] + +[zogre_human_brentle_vahn_drop_table] +type = "all" +drops = [ + { id = "ruined_backpack" }, +] + +[zogre_human_brentle_vahn_tertiary] +roll = 5000 +drops = [ + { id = "zombie_champions_scroll", members = true, lacks = "zombie_champions_scroll" }, +] \ No newline at end of file diff --git a/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.gfx.toml b/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.gfx.toml new file mode 100644 index 0000000000..a4f18d72a2 --- /dev/null +++ b/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.gfx.toml @@ -0,0 +1,12 @@ +[smokepuff_large] +id = 188 + +[slash_bash_projectile] +id = 397 +height = 40 +end_height = 36 +delay = 41 +curve = 15 +size_offset = 11 +time_offset = 15 +multiplier = 5 \ No newline at end of file diff --git a/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.items.toml b/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.items.toml index d6f291c689..399dc5338b 100644 --- a/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.items.toml +++ b/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.items.toml @@ -61,7 +61,7 @@ weight = 0.005 examine = "A half torn necromantic page." kept = "Wilderness" -[ruined_backpack_zogre_flesh_eaters] +[ruined_backpack] id = 4810 tradeable = false weight = 0.02 @@ -86,21 +86,21 @@ examine = "A pile of Zombie Ogre bones." [zogre_bones_noted] id = 4813 -[sithik_portrait] +[zogre_sithik_portrait_good] id = 4814 tradeable = false weight = 0.02 examine = "A classic realist charcoal portrait of Sithik." kept = "Wilderness" -[sithik_portrait_bad] +[zogre_sithik_portrait_bad] id = 4815 tradeable = false weight = 0.02 examine = "A semi-nihilistic, pseudo-impressionistic, half-squarist charcoal sketch of Sithik." kept = "Wilderness" -[signed_portrait] +[zogre_sithik_portrait_signed] id = 4816 tradeable = false weight = 0.02 @@ -222,7 +222,7 @@ examine = "Ancient ogre bones from the ogre burial tomb." [ourg_bones_noted] id = 4835 -[strange_potion] +[zogre_ogre_trans_potion] id = 4836 tradeable = false weight = 0.001 diff --git a/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.npcs.toml b/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.npcs.toml index b5262456f7..4cf37a2483 100644 --- a/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.npcs.toml +++ b/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.npcs.toml @@ -14,3 +14,25 @@ slayer_xp = 100.0 categories = ["zombies"] drop_table = "slash_bash" examine = "A powerful looking Zogre." + +[zogre_sithik_man] +id = 2061 + +[zogre_sithik_ogre] +id = 2062 +dialogue = "ogre" + +[zogre_human_brentle_vahn] +id = 1826 +hitpoints = 500 +att = 30 +str = 30 +def = 30 +attack_speed = 6 +style = "crush" +max_hit_melee = 40 +hunt_mode = "cowardly" +slayer_xp = 50.0 +categories = ["zombies"] +drop_table = "zogre_human_brentle_vahn" +examine = "A human zombie." \ No newline at end of file diff --git a/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.objs.toml b/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.objs.toml new file mode 100644 index 0000000000..f4ce32011e --- /dev/null +++ b/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.objs.toml @@ -0,0 +1,62 @@ +[ogre_stairs_down] +id = 6841 + +[ogre_stairs] +id = 6842 + +[zogre_lecturn] +id = 6846 + +[zogre_coffin_base] +id = 6843 + +[zogre_coffin_special] +id = 6844 + +[zogre_coffin_special_searched] +id = 6845 + +[zogre_brentle_skeleton] +id = 6893 + +[zogre_stand] +id = 6897 + +[ogre_cavedoorr_closed] +id = 6871 + +[ogre_cavedoorl_closed] +id = 6872 + +[ogre_cavedoorr_opened] +id = 6873 + +[ogre_cavedoorl_opened] +id = 6874 + +[zogre_sithik_bed] +id = 6889 + +[zogre_sithik_bed_entity] +id = 6887 + +[ogre_bedman_loc] +id = 6888 + +[sithiks_drawers] +id = 6875 + +[sithiks_cupboard] +id = 6876 + +[sithiks_wardrobe] +id = 55412 + +[ogre_barricade_collapsed] +id = 6879 + +[ogre_barricade_collapsedr] +id = 6881 + +[ogre_barricade_collapsedl] +id = 6882 \ No newline at end of file diff --git a/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.sounds.toml b/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.sounds.toml new file mode 100644 index 0000000000..9ece1937aa --- /dev/null +++ b/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.sounds.toml @@ -0,0 +1,47 @@ +[ogre_destroy_barricade] +id = 1954 + +[drip_poison] +id = 1955 + +[down_stone_stairs] +id = 1952 + +[up_stone_stairs] +id = 1956 + +[disease_hitsplat] +id = 2388 + +[strangedoor_open] +id = 2410 + +[smokepuff] +id = 1930 + +[zogre_writing] +id = 1958 + +[bonewalk] +id = 1953 + +[zogre_death] +id = 916 + +[zogre_hit] +id = 917 + +[zogre_attack] +id = 914 + +[zogre_shield_hit] +id = 915 + +[zogre_axe_attack] +id = 913 + +[skelly_hit] +id = 779 + +[zogre_bell] +id = 1959 \ No newline at end of file diff --git a/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.varbits.toml b/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.varbits.toml index b01d9fa8a1..622e7d0352 100644 --- a/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.varbits.toml +++ b/data/quest/members/zogre_flesh_eaters/zogre_flesh_eaters.varbits.toml @@ -3,4 +3,94 @@ id = 487 persist = true format = "map" default = "unstarted" -values = { unstarted = 0, started = 1 } +values = { + unstarted = 0, + investigate = 2, + barricade = 3, + sithik = 4, + potion = 6, + permanent_spell = 8, + given_key = 10, + killed_slash_bash = 12, + completed = 14, +} + +[thzfe_prismsearch] +id = 488 +persist = true +format = "int" + +[thzfe_innkeepermugshown] +id = 489 +persist = true +format = "boolean" + +[thzfe_innkeeperportraitshown] +id = 490 +persist = true +format = "boolean" + +[thzfe_shownnecrobook] +id = 491 +persist = true +format = "boolean" + +[thzfe_shownhambook] +id = 492 +persist = true +format = "boolean" + +[thzfe_showntankard] +id = 493 +persist = true +format = "boolean" + +[thzfe_shownsignedportrait] +id = 494 +persist = true +format = "boolean" + +[thzfe_sithik_transformed] +id = 495 +persist = true +format = "int" + +[thzfe_blocking_barricade] +id = 496 +persist = true +format = "boolean" + +[thzfe_makecuredisease] +id = 498 +persist = true +format = "boolean" + +[thzfe_makebrutalarrow] +id = 499 +persist = true +format = "boolean" + +[thzfe_makecompozogrebow] +id = 500 +persist = true +format = "boolean" + +[thzfe_sold_balm] +id = 502 +persist = true +format = "boolean" + +[thzfe_brentle_skele] +id = 503 +persist = true +format = "int" + +[thzfe_cut_scene] +id = 505 +persist = true +format = "boolean" + +[thzfe_grish_warning_yes] +id = 507 +persist = true +format = "boolean" \ No newline at end of file diff --git a/data/quest/members/zogre_flesh_eaters/zogre_human_brentle_vahn.combat.toml b/data/quest/members/zogre_flesh_eaters/zogre_human_brentle_vahn.combat.toml new file mode 100644 index 0000000000..986151d480 --- /dev/null +++ b/data/quest/members/zogre_flesh_eaters/zogre_human_brentle_vahn.combat.toml @@ -0,0 +1,13 @@ +[zogre_human_brentle_vahn] +attack_speed = 6 +retreat_range = 16 +defend_anim = "zombie_update_defend_normal" +defend_sound = "zombie_defend" +death_anim = "zombie_update_death_normal" +death_sound = "zombie_death" + +[zogre_human_brentle_vahn.melee] +range = 1 +anim = "zombie_update_attack_normal" +target_sound = "zombie_attack" +target_hit = { offense = "crush", max = 40 } \ No newline at end of file diff --git a/data/quest/quests.toml b/data/quest/quests.toml index 6a012d9bf8..e67a46e28f 100644 --- a/data/quest/quests.toml +++ b/data/quest/quests.toml @@ -3859,3 +3859,22 @@ req_combat = "You will need to defeat a level 58 enemy and level 36 enemy." points = 1 reward = "3 ourg bones
2 zogre bones
Access to Jiggig
Ability to equip inoculation brace
Ability to fletch composite ogre bow and brutal arrows
Access to post-quest rewards from Zavistic Rarve or Yanni Salika, and Uglug Nar" xp = "2,000 Fletching XP
2,000 Ranged XP
2,000 Herblore XP" +variables = [ + "zogre_flesh_eaters", + "thzfe_prismsearch", + "thzfe_innkeepermugshown", + "thzfe_innkeeperportraitshown", + "thzfe_shownnecrobook", + "thzfe_shownhambook", + "thzfe_showntankard", + "thzfe_shownsignedportrait", + "thzfe_sithik_transformed", + "thzfe_blocking_barricade", + "thzfe_makecuredisease", + "thzfe_makebrutalarrow", + "thzfe_makecompozogrebow", + "thzfe_sold_balm", + "thzfe_brentle_skele", + "thzfe_cut_scene", + "thzfe_grish_warning_yes", +] \ No newline at end of file diff --git a/data/skill/fletching/arrowtip.recipes.toml b/data/skill/fletching/arrowtip.recipes.toml index 258b061241..4dc688c413 100644 --- a/data/skill/fletching/arrowtip.recipes.toml +++ b/data/skill/fletching/arrowtip.recipes.toml @@ -92,6 +92,8 @@ skill = "fletching" level = 7 xp = 8.4 ticks = 2 +requires = ["hammer"] +requires_message = "You need a hammer to force these nails into the arrow shafts." remove = [{ id = "flighted_ogre_arrow", amount = 6 }, { id = "bronze_nails", amount = 6 }] add = [{ id = "bronze_brutal", amount = 6 }] message = "You attach the nails to 6 arrow shafts." @@ -103,6 +105,8 @@ skill = "fletching" level = 18 xp = 15.6 ticks = 2 +requires = ["hammer"] +requires_message = "You need a hammer to force these nails into the arrow shafts." remove = [{ id = "flighted_ogre_arrow", amount = 6 }, { id = "iron_nails", amount = 6 }] add = [{ id = "iron_brutal", amount = 6 }] message = "You attach the nails to 6 arrow shafts." @@ -114,6 +118,8 @@ skill = "fletching" level = 33 xp = 30.6 ticks = 2 +requires = ["hammer"] +requires_message = "You need a hammer to force these nails into the arrow shafts." remove = [{ id = "flighted_ogre_arrow", amount = 6 }, { id = "steel_nails", amount = 6 }] add = [{ id = "steel_brutal", amount = 6 }] message = "You attach the nails to 6 arrow shafts." @@ -125,6 +131,8 @@ skill = "fletching" level = 38 xp = 39.0 ticks = 2 +requires = ["hammer"] +requires_message = "You need a hammer to force these nails into the arrow shafts." remove = [{ id = "flighted_ogre_arrow", amount = 6 }, { id = "black_nails", amount = 6 }] add = [{ id = "black_brutal", amount = 6 }] message = "You attach the nails to 6 arrow shafts." @@ -136,6 +144,8 @@ skill = "fletching" level = 49 xp = 45.0 ticks = 2 +requires = ["hammer"] +requires_message = "You need a hammer to force these nails into the arrow shafts." remove = [{ id = "flighted_ogre_arrow", amount = 6 }, { id = "mithril_nails", amount = 6 }] add = [{ id = "mithril_brutal", amount = 6 }] message = "You attach the nails to 6 arrow shafts." @@ -147,6 +157,8 @@ skill = "fletching" level = 62 xp = 61.2 ticks = 2 +requires = ["hammer"] +requires_message = "You need a hammer to force these nails into the arrow shafts." remove = [{ id = "flighted_ogre_arrow", amount = 6 }, { id = "adamant_nails", amount = 6 }] add = [{ id = "adamant_brutal", amount = 6 }] message = "You attach the nails to 6 arrow shafts." @@ -158,6 +170,8 @@ skill = "fletching" level = 77 xp = 75.0 ticks = 2 +requires = ["hammer"] +requires_message = "You need a hammer to force these nails into the arrow shafts." remove = [{ id = "flighted_ogre_arrow", amount = 6 }, { id = "rune_nails", amount = 6 }] add = [{ id = "rune_brutal", amount = 6 }] message = "You attach the nails to 6 arrow shafts." @@ -183,6 +197,6 @@ ticks = 3 requires = ["knife"] remove = ["achey_tree_logs", "wolf_bones"] add = ["unstrung_comp_bow"] -message = "You carefully cut the wood and add the bones." +message = "You carefully cut the wood into a composite ogre bow." question = "What would you like to fletch?" animation = "fletching_log" \ No newline at end of file diff --git a/data/skill/magic/spells.tables.toml b/data/skill/magic/spells.tables.toml index 9e3174efc7..d394a5199e 100644 --- a/data/skill/magic/spells.tables.toml +++ b/data/skill/magic/spells.tables.toml @@ -15,7 +15,6 @@ poison_damage = "int" projectiles = "list" drain_skill = "skill" drain_percent = "int" -npc_message = "string" player_message = "string" clone = "clone" @@ -516,7 +515,6 @@ xp = 600 [.monster_examine] xp = 660 -npc_message = "" [.npc_contact] xp = 630 diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/data/config/ItemOnItemDefinition.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/data/config/ItemOnItemDefinition.kt index fbf0d9d6cc..691529a56d 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/data/config/ItemOnItemDefinition.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/data/config/ItemOnItemDefinition.kt @@ -44,6 +44,7 @@ data class ItemOnItemDefinition( val sound: String = "", val message: String = "", val failure: String = "", + val requiresMessage: String = "", val question: String = "How many would you like to $type?", val maximum: Int = -1, val members: Boolean = false, diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/data/definition/ItemOnItemDefinitions.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/data/definition/ItemOnItemDefinitions.kt index 3183032256..3c81d40d41 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/data/definition/ItemOnItemDefinitions.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/data/definition/ItemOnItemDefinitions.kt @@ -48,6 +48,7 @@ class ItemOnItemDefinitions { var sound = "" var message = "" var failure = "" + var requiresMessage = "" var question: String? = null var maximum: Int = -1 var members: Boolean = false @@ -72,6 +73,7 @@ class ItemOnItemDefinitions { "sound" -> sound = string() "message" -> message = string() "failure" -> failure = string() + "requires_message" -> requiresMessage = string() "question" -> question = string() "maximum" -> maximum = int() "members" -> members = boolean() @@ -96,6 +98,7 @@ class ItemOnItemDefinitions { sound = sound, message = message, failure = failure, + requiresMessage = requiresMessage, question = question ?: "How many would you like to $type?", maximum = maximum, members = members, diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/npc/NPCs.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/npc/NPCs.kt index a66db9e592..9a3449e010 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/npc/NPCs.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/npc/NPCs.kt @@ -79,7 +79,9 @@ object NPCs : Runnable, fun add(id: String, tile: Tile, direction: Direction = Direction.SOUTH, ticks: Int, owner: Player? = null): NPC { val npc = add(id, tile, direction) - npc.despawn(ticks) + if (ticks > 0) { + npc.despawn(ticks) + } if (owner != null) { npc["owner"] = owner.accountName } @@ -94,11 +96,11 @@ object NPCs : Runnable, * NPC's full size. Returns `null` if the NPC id is unknown or no valid tile can be found * (the underlying [Area.random] retries up to 100 times before giving up). */ - fun addRandom(id: String, area: Area, direction: Direction = Direction.SOUTH): NPC? { + fun addRandom(id: String, area: Area, direction: Direction = Direction.SOUTH, ticks: Int = -1, owner: Player? = null): NPC? { val def = NPCDefinitions.getOrNull(id) ?: return null val collision = CollisionStrategyProvider.get(def) val tile = area.random(collision, def.size) ?: return null - return add(id, tile, direction) + return add(id, tile, direction, ticks, owner) } fun remove(npc: NPC?): Boolean { diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/Player.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/Player.kt index b74823bf27..da8d448c1c 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/Player.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/Player.kt @@ -124,3 +124,7 @@ class Player( private val logger = InlineLogger("Player") } } + +var Player.wearingGhostspeak: Boolean + get() = get("wearing_ghost_speak_amulet", false) + set(value) = set("wearing_ghost_speak_amulet", value) diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/suspend/Suspend.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/suspend/Suspend.kt index 76cc081b9a..9ebf97a172 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/suspend/Suspend.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/suspend/Suspend.kt @@ -7,11 +7,16 @@ import world.gregs.voidps.engine.entity.character.player.Player fun Character.resumeSuspension(): Boolean { val suspend = suspension ?: return false - if (suspend is Suspension.Delay && suspend.ready()) { - suspend.resume() - } - if (suspend is Suspension.Custom && suspend.ready()) { - suspend.resume() + try { + if (suspend is Suspension.Delay && suspend.ready()) { + suspend.resume() + } + if (suspend is Suspension.Custom && suspend.ready()) { + suspend.resume() + } + } catch (e: Exception) { + e.printStackTrace() + suspension = null } return true } diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/suspend/Suspension.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/suspend/Suspension.kt index e1fc0dace4..0274077646 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/suspend/Suspension.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/suspend/Suspension.kt @@ -1,9 +1,7 @@ package world.gregs.voidps.engine.suspend import kotlinx.coroutines.CancellableContinuation -import kotlinx.coroutines.suspendCancellableCoroutine import world.gregs.voidps.engine.GameLoop -import world.gregs.voidps.engine.entity.character.player.Player import kotlin.coroutines.resume sealed class Suspension { @@ -13,21 +11,36 @@ sealed class Suspension { * p_countdialog */ class IntEntry(private val continuation: CancellableContinuation) : Suspension() { - fun resume(int: Int) = continuation.resume(int) + fun resume(int: Int) { + if (continuation.isCancelled) { + return + } + continuation.resume(int) + } } /** * Wait for string entry dialogue */ class StringEntry(private val continuation: CancellableContinuation) : Suspension() { - fun resume(string: String) = continuation.resume(string) + fun resume(string: String) { + if (continuation.isCancelled) { + return + } + continuation.resume(string) + } } /** * Wait for name entry dialogue */ class NameEntry(private val continuation: CancellableContinuation) : Suspension() { - fun resume(string: String) = continuation.resume(string) + fun resume(string: String) { + if (continuation.isCancelled) { + return + } + continuation.resume(string) + } } /** @@ -35,7 +48,12 @@ sealed class Suspension { * p_pausebutton */ class Continue(private val continuation: CancellableContinuation) : Suspension() { - fun resume() = continuation.resume(Unit) + fun resume() { + if (continuation.isCancelled) { + return + } + continuation.resume(Unit) + } } /** @@ -47,13 +65,23 @@ sealed class Suspension { fun ready(): Boolean = GameLoop.tick >= tick - fun resume() = continuation.resume(Unit) + fun resume() { + if (continuation.isCancelled) { + return + } + continuation.resume(Unit) + } } class Custom(private val continuation: CancellableContinuation, val block: () -> Boolean) : Suspension() { fun ready(): Boolean = block.invoke() - fun resume() = continuation.resume(Unit) + fun resume() { + if (continuation.isCancelled) { + return + } + continuation.resume(Unit) + } } } diff --git a/game/src/main/kotlin/content/area/kandarin/feldip_hills/Grish.kt b/game/src/main/kotlin/content/area/kandarin/feldip_hills/Grish.kt new file mode 100644 index 0000000000..4234dd7208 --- /dev/null +++ b/game/src/main/kotlin/content/area/kandarin/feldip_hills/Grish.kt @@ -0,0 +1,485 @@ +package content.area.kandarin.feldip_hills + +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Mad +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Scared +import content.entity.player.dialogue.type.ChoiceOption +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.item +import content.entity.player.dialogue.type.items +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import content.entity.player.inv.item.addOrDrop +import content.quest.quest +import content.quest.questComplete +import content.quest.questCompleted +import content.quest.refreshQuestJournal +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.jingle +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.exp.exp +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.hasMax +import world.gregs.voidps.engine.event.AuditLog +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove + +class Grish : Script { + + init { + npcOperate("Talk-to", "grish") { (target) -> + when (val progress = quest("zogre_flesh_eaters")) { + "unstarted" -> { + if (get("thzfe_grish_warning_yes", false)) { + confirmQuestStart() + } else { + intro(target) + } + } + "given_key" -> { + if (inventory.contains("ogre_gate_key")) { + artefactReminderMenu() + } else { + lostKey() + } + } + "killed_slash_bash" -> { + if (inventory.contains("ogre_gate_key")) { + questFinishHandover() + } else { + lostKey() + } + } + "completed" -> postQuest() + else -> { + npc("Yous creature dun da fing yet? Da zogries going in da dirt full home?") + if (progress == "permanent_spell") { + foundMenu() + } else { + player("Nope, I haven't figured out why the zogres are here yet.") + } + } + } + } + + itemOnNPCOperate("black_prism", "grish") { (target) -> + item(item = "black_prism", text = "You show the black prism to Grish.") + player("Hey Grish, I found this in the tomb, do you know what it is?") + npc("Whas you's shuvvin wizzy stuff in Grish face...is a pretty one but dat's more stuff for da wizzy's dan Grish.") + } + + itemOnNPCOperate("torn_page", "grish") { (target) -> + item(item = "torn_page", text = "You show the necromantic page to Grish.") + player("This torn page was on a lectern in the tomb, do you know why?") + npc("Dat's der wizzy stuff, not Ogery stuffsies like what Grish got. Das not even big enough for empty da big blower on! No use for Grish dat creatures...you's keeps it.") + } + + itemOnNPCOperate("dragon_inn_tankard", "grish") { (target) -> + item(item = "dragon_inn_tankard", text = "You show the tankard to Grish.") + player("I found this tankard in the tomb, have you got any suggestions?") + npc("Das a good drinker for da drinkies dat un is...is a small-un for Grish so yous creature keeps it yes. Yous creature keeps da fimble drinkers for da smaller drinkies.") + } + } + + // ===== Progress 0: Initial intro ===== + + private suspend fun Player.intro(target: NPC) { + player("Hello there, what's going on here?") + npc("Hey yous creature...wha's you's doing here? Yous be cleverer to be running so da sickies from da zogres don't dead ya.") + introMenu(target) + } + + suspend fun Player.introMenu(target: NPC) { + choice { + justLookingAround(target) + whatDoYouMeanSickies(target) + whatAreZogres(target) + sorryHaveToGo() + } + } + + suspend fun Player.introMenuExpanded(target: NPC) { + choice { + justLookingAround(target) + whatDoYouMeanSickies(target) + whatAreZogres(target) + canIHelp() + sorryHaveToGo() + } + } + + fun ChoiceOption.justLookingAround(target: NPC): Unit = option("I'm just looking around thanks.") { + npc("Yous creature won'ts see muchly in dis place...just da zogries coming wiv da sickies.") + introMenuExpanded(target) + } + + fun ChoiceOption.whatDoYouMeanSickies(target: NPC): Unit = option("What do you mean sickies?") { + npc("Da zogries comin wiv da sickies...yous get bashed by da zogries and get da sickies...den you gonna be like da zogries.") + player("Sorry, I just don't understand...") + target.anim("ogre_fake_death") + npc("Da sickies is when yous creature goes like orange till green and then goes 'Urggghhhh!' ~ Grish imitates falling down with only the white of his eyes visible. ~") + introMenuExpanded(target) + } + + fun ChoiceOption.whatAreZogres(target: NPC): Unit = option("What are Zogres?") { + npc("Da Zogres are da bigun nasties wiv da sickies, deys old pals of Grish but deys jig in Jiggig when dey's full home is deep in da dirt, dey's is not da same dead'uns like was before.") + npc("Dem zogries commin from da under dirt and us is lost for da Jiggie jig place.") + introMenuExpanded(target) + } + + fun ChoiceOption.sorryHaveToGo(): Unit = option("Sorry, I have to go.") + + fun ChoiceOption.canIHelp(): Unit = option("Can I help in any way?") { + npc("Yes creatures...yous does good fings for Grish and learn why Zogries at Jiggig and den get da Zogries back in da ground.") + player("Oh, so you want me to find out why the Zogres have appeared and then find a way of burying them?") + npc("Is what Grish says! But dis is da biggy danger fing yous creatures...yous be geddin' sickies most surely...yous needs be ready..wiv da foodies un da glug-glugs.") + player("Right, so you think there's a good chance that I can get ill from this, so I need to get some food and something to drink?") + npc("Yea creatures, yous just say what Grish says...not know own wordies creature?") + startOrDeclineMenu() + } + + suspend fun Player.startOrDeclineMenu() { + choice { + tooDangerousOption() + okayCheckThings() + } + } + + fun ChoiceOption.tooDangerousOption(): Unit = option("Hmm, sorry, it sounds a bit too dangerous.") { + npc("Yous creature is not a stoopid one...stays out of dere, like clever Grish. Yous can paint circles on chest and be da Shaman too!") + player("Hmm, is it too late to reconsider?") + } + + fun ChoiceOption.okayCheckThings(): Unit = option("Ok, I'll check things out then and report back.") { + confirmQuestStart() + } + + private suspend fun Player.confirmQuestStart() { + npc("Is yous creatures really, really sure yous wanna do dis creatures..we's got no glug-glugs for da sickies? We's knows nuffin for da going of da sickies?") + set("thzfe_grish_warning_yes", true) + choice { + reallySure() + tooDangerousOption() + } + } + + fun ChoiceOption.reallySure(): Unit = option("Yes, I'm really sure!") { + if (!meetsZogreRequirements()) { + npc("Sorry, yous creatures, but yous is too green behind da ears for dis job Grish finks.") + player("No, I'm not!") + npc("Yes you are!") + player("No, I'm not!") + npc("Yes you are and that's final!") + statement("You do not meet all of the requirements to start the Zogre Flesh Eaters quest.") + return@option + } + npc("Dats da good fing yous creature...yous does Grish a good fing. But yous know dat yous get sickies and mebe get dead!") + player("If that's your idea of a pep talk, I have to say that it leaves a lot to be desired.") + npc("Yous creatures is alus says funny stuff...speaks proper like Grish!") + set("zogre_flesh_eaters", "investigate") + addOrDrop("cooked_chompy", 3) + addOrDrop("super_restore_3", 2) + items("cooked_chompy", "super_restore_3", "Grish hands you some food and two potions.") + npc("Der's yous go creatures...da best me's do for yous...and be back wivout da sickies.") + } + + val Player.chompybird: Int + get() = get("chompy_birds", 0) + + private fun Player.meetsZogreRequirements(): Boolean = hasMax(Skill.Ranged, 30) && questCompleted("jungle_potion") && chompybird == 65 // TODO + + suspend fun Player.foundMenu() { + choice { + foundResponsibleOption() + if (get("thzfe_makebrutalarrow", false)) { + if (!get("thzfe_makecompozogrebow", false)) { + killFromDistanceOption() + } else { + easierWay() + } + } + if (get("thzfe_makecuredisease", false)) { + if (!get("thzfe_sold_balm", false)) { + cureDiseaseOption() + } else { + cureDisease() + } + } + otherQuestionsOption() + sorryHaveToGo() + } + } + + fun ChoiceOption.foundResponsibleOption(): Unit = option("I found who's responsible for the Zogres being here.") { + npc("Where is da creature? Me's wants to squeeze him till he's a deadun...") + player("The person responsible is a wizard named 'Sithik Ints' and he's going to be in serious trouble. He told me that the spell which raised the zogres from the ground will last forever.") + player("I'm sorry to say, but you'll have to move the site of your ceremonial dancing somewhere else.") + npc("Dat is da bad fing creature...we's needs new Jiggig for da fallin' down jig.") + player("Yes, that's right, you'll need to create a new ceremonial dance area.") + npc("Urghhh...not good fing creature, yous gotta get da ogrish old fings for da making new jiggig special. You's creature needs da key for getting in da low bury place.") + set("zogre_flesh_eaters", "given_key") + set("thzfe_sithik_transformed", 2) + addOrDrop("ogre_gate_key") + message("Grish gives you a crudely crafted key.") + item(item = "ogre_gate_key", text = "Grish gives you a crudely crafted key.") + player("Oh, so you want me to go back in there and look for something for you?") + npc("Yeah creature, yous gotta get da ogrish old fings for da making new jiggig and proper in da special way.") + } + + fun ChoiceOption.killFromDistanceOption(): Unit = option("I've got some information on how to kill the zogres from a distance.") { + player("Sithik told me how to make Brutal arrows which means I can kill these zogres from a distance!") + teachCompositeBow() + } + + fun ChoiceOption.cureDiseaseOption(): Unit = option("I've found out how to cure the disease.") { + player("I also found out that the disease can be cured.") + npc("Dat's da good fing creature, yous do good fing to give un to Uglug...he gives bright pretties for da sickies glug glug.") + returnToProgressMenu() + } + + /** + * Routes back to whichever menu fits the current quest stage — progress 8 stays + * in the post-Sithik review menu; progress 10/12 (after Grish has handed out the + * tomb key) drops into the post-no menu instead. + */ + private suspend fun Player.returnToProgressMenu() { + if (quest("zogre_flesh_eaters") == "permanent_spell") { + foundMenu() + } else { + postNoMenu() + } + } + + fun ChoiceOption.otherQuestionsOption(): Unit = option("I have some other questions for you.") { + otherQuestionsBranch() + } + + private suspend fun Player.teachCompositeBow() { + npc("Uhggh, whas you's sayin' creature? Yous speakies too stupid for Grish...") + player("I know how to make large arrows...you know, 'big stabbers', to kill the zogres...they're bigger and apparently do a lot of damage, only thing is, the normal ogre bow I need to fire it is quite slow.") + npc("Why you's not say so creature...me's shows you how to make da bigger stabber chucker... ~ Grish gets a couple of items out of his back pack.~") // TODO makes too much of the line blue + set("thzfe_makecompozogrebow", true) + items( + "achey_tree_logs", + "wolf_bones", + "Grish shows you he has Achey tree logs and wolf bones, he starts to whittle away at them both with a knife.", + ) + item(item = "unstrung_comp_bow", text = "Grish shows you his achievement, a rather powerful looking composite bow frame...") + items( + "unstrung_comp_bow", + "bowstring", + "He shows you the bow frame and the string and after some time and a great deal of effort, he strings the composite ogre bow.", + ) + item(item = "comp_ogre_bow", text = "Grish shows you his proud achievement...") + npc("De're creature...now yous is makin' da bigga stabber chucker...") + player("Thanks! I think....") + returnToProgressMenu() + } + + // ===== Other questions branch (lore questions) ===== + + suspend fun Player.otherQuestionsBranch() { + npc("Oh yes creatures...what's other fings yous wanna know?") + if (quest("zogre_flesh_eaters") == "permanent_spell") { + otherQuestionsLimited() + } else { + otherQuestionsFull() + } + } + + suspend fun Player.otherQuestionsFull() { + choice { + shamansOption() + doYouKnowRantz() + whyDoesntRantzLive() + whyJiggig() + talkAboutQuestOption() + } + } + + suspend fun Player.otherQuestionsLimited() { + choice { + doYouKnowRantz() + whyDoesntRantzLive() + whyJiggig() + talkAboutQuestOption() + } + } + + fun ChoiceOption.shamansOption(): Unit = option("Why are you much nicer than the Shaman in Gu'Tanoth?") { + npc("Dey's is da big crazy one's! Dey's biggest angries wiv fings and wanna dead all fings...dey's gotten da biggies wizzy stuff...and dey's wanna eat yous creatures...Grish, not do dat...") + player("Oh, well that's a relief! It's good to know you don't eat humans...") + npc("Grish not say dat! Me's want's tasty looking creatures for yums...you's looks like da sickies chompy...not good for da gutsies...") + player("Gulp!") + // TODO: switch chathead to Uglug Nar + npc("Grish, you's is fright da creatures! Leave it alone!") + // TODO: switch chathead back to Grish + npc("But it's da big laffsies when it's facey goes to whiteness....ha ha ha!") + // TODO: switch chathead to Uglug Nar + npc("But it's not da big yumsies when it's gone to all frighty...") + npc("ha ha ha ha!") + player("Yeah...very funny, I'm sure.") + otherQuestionsFull() + } + + fun ChoiceOption.doYouKnowRantz(): Unit = option("Do you know Rantz?") { + npc("Me's know's about Rantz, he's da biggun chompy hunter..he finks...ha ha ha!") + player("How do you mean?") + npc("He's da bad shot chompy sticker, no good at sneaky, sneaky part, he's more gooder at da 'noisy, noisy miss da chompy', ha ha ha! ") + otherQuestions() + } + + private suspend fun Player.otherQuestions() { + if (quest("zogre_flesh_eaters") == "permanent") { + otherQuestionsLimited() + } else { + otherQuestionsFull() + } + } + + fun ChoiceOption.whyDoesntRantzLive(): Unit = option("Why doesn't Rantz live with the rest of the Ogres?") { + npc("He's been leaving Gu 'Noth 'cos dey's peoples is da big stressy dere? All da ogries is busying all da time...not doin' no good for da healfy fing. Rantz is da brave-un tho! He's got da big secret fing for leaving Gu' Noth but me's not knowin it. But maybe's he's just want's to be da better chompy sticker?") + otherQuestions() + } + + fun ChoiceOption.whyJiggig(): Unit = option("Why do you call this place Jiggig?") { + npc("It's da place where da Jiggig is done...we's jig at Jiggig...") + otherQuestions() + } + + fun ChoiceOption.talkAboutQuestOption(): Unit = option("I want to talk about the quest.") { + foundMenu() + } + + // ===== Progress 10/12: Lost key handling ===== + + private suspend fun Player.lostKey() { + npc("Yous creature got da old fings yet?") + player("I've lost the key you gave me!") + npc("Yous stupid creatures....luckily Grish has 'nother one..") + addOrDrop("ogre_gate_key") + npc("Yous creatures doesn't loosing this ones.") + } + + // ===== Progress 10/12: Have key, ask about artefacts ===== + + private suspend fun Player.artefactReminderMenu() { + npc("Yous creature got da old fings yet?") + choice("Grish asks if you have the items yet.") { + notYet() + easierWay() + cureDisease() + sorryHaveToGo() + } + } + + fun ChoiceOption.noSorry(): Unit = option("No sorry, I don't have them yet.") { + npc("Yous creatures get dem for me soon doh, yes?") + postNoMenu() + } + + fun ChoiceOption.notYet(): Unit = option("Nope, not yet.") { + npc("Yous gets 'em quick tho, cos we'ze wonna do da new Jiggig place...") + postNoMenu() + } + + fun ChoiceOption.easierWay(): Unit = option("There must be an easier way to kill these zogres!") { + npc("Yous creature jus makin da bigga stabber chucker like Grish shows you...") + postNoMenu() + } + + fun ChoiceOption.cureDisease(): Unit = option("There must be a way to cure this disease!") { + npc("Did yous creature makes da sickies glug glug and putin some wiv Uglug for bright pretties? He's goodun for makin' da glug glugs...yous maken da glug-glug, den sellin' one for Uglug, he's makin' more of da sickies glug") + npc("glug and sellin' for bright pretties to yous creature...") + postNoMenu() + } + + suspend fun Player.postNoMenu() { + choice { + if (get("thzfe_makebrutalarrow", false)) { + if (!get("thzfe_makecompozogrebow", false)) { + killFromDistanceOption() + } else { + easierWay() + } + } + if (get("thzfe_makecuredisease", false)) { + if (!get("thzfe_sold_balm", false)) { + cureDiseaseOption() + } else { + cureDisease() + } + } + otherQuestionsOption() + sorryHaveToGo() + } + } + + private suspend fun Player.questFinishHandover() { + npc("Hey, you's creature got da old fings?") + choice { + if (inventory.contains("ogre_artefact")) { + haveThemHere() + } else { + noSorry() + } + howIsItGoing() + otherQuestionsOption() + sorryHaveToGoNow() + } + } + + private suspend fun Player.postQuest() { + npc("Hey yous creatures da good un...") + postFinishMenu() + } + + fun ChoiceOption.howIsItGoing(): Unit = option("How's everything going now?") { + npc("All da zogries stayin' in da oldie Jiggig, we's gonna do da new Jiggig someways else. Yous creature da good-un for geddin' da oldie fings...") + postFinishMenu() + } + + fun ChoiceOption.sorryHaveToGoNow(): Unit = option("Sorry, I have to go now.") {} + + suspend fun Player.postFinishMenu() { + choice { + howIsItGoing() + otherQuestionsOption() + sorryHaveToGo() + } + } + + fun ChoiceOption.haveThemHere(): Unit = option("Yeah, I have them here!") { + npc("Dat is da goodly fing yous creature, now's we's can make da new Jiggig place away from zogries! Yous been da big helpy fing yous creature, Grish wishin' yous good stuff for da next fings for creature.") + npc("~ Grish seems very pleased about the return of the artefacts. ~") + player("Thanks, that's very nice of you!") + sendZogreFleshEatersReward() + } +} + +fun Player.sendZogreFleshEatersReward() { + jingle("quest_complete_1") + inventory.remove("ogre_artefact") + inventory.remove("ogre_gate_key") + exp(Skill.Ranged, 2000.0) + exp(Skill.Fletching, 2000.0) + exp(Skill.Herblore, 2000.0) + inc("quest_points", 1) + AuditLog.event(this, "quest_completed", "zogre_flesh_eaters") + set("zogre_flesh_eaters", "completed") + refreshQuestJournal() + questComplete( + "Zogre Flesh Eaters", + "1 Quest Point", + "Can now make Brutal Arrows", + "and cure disease potions.", + "2000 Ranged, Fletching and", + "Herblore XP.", + item = "ogre_artefact", + ) +} diff --git a/game/src/main/kotlin/content/area/kandarin/feldip_hills/OgreGuard.kt b/game/src/main/kotlin/content/area/kandarin/feldip_hills/OgreGuard.kt new file mode 100644 index 0000000000..4c6d1cfdac --- /dev/null +++ b/game/src/main/kotlin/content/area/kandarin/feldip_hills/OgreGuard.kt @@ -0,0 +1,64 @@ +package content.area.kandarin.feldip_hills + +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.quest.quest +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.npc.NPC +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.type.Tile + +class OgreGuard : Script { + + init { + npcOperate("Talk-to", "zogre_ogre_guard") { (target) -> + when (quest("zogre_flesh_eaters")) { + "unstarted" -> warnAway() + "investigate" -> openBarricade(target) + else -> postBarricadeWarning() + } + } + } + + // ===== Progress 0: Generic warning, player hasn't accepted quest ===== + + private suspend fun Player.warnAway() { + npc("Yous needs ta stay away from dis place...yous get da sickies and mebe yous goes to dead if yous da unlucky fing.") + } + + // ===== Progress 2: Player has accepted quest, ready to break barricade ===== + + private suspend fun Player.openBarricade(guard: NPC) { + npc("Yous needs ta stay away from dis place...yous get da sickies and mebe yous goes to dead if yous da unlucky fing.") + player("But Grish has asked me to look into this place and find out why all the undead ogres are here.") + npc("Ok, dat is da big, big scary, danger fing!
You's sure you's wants to go in?") + player("Yes, I'm sure.") + npc("Ok, I opens da stoppa's for yous creature.") + breakBarricadeCutscene(guard) + npc("Ok der' yous goes!") + } + + // ===== Progress 3+: Past the barricade, just a flavor warning ===== + + private suspend fun Player.postBarricadeWarning() { + npc("Hey yous tryin' not to get da sickies else yous be da sick-un and mebe get to be a dead-un if yous be da unlucky fing.") + player("Don't worry, I know how to take care of myself.") + } + + // ===== Helpers - replace with project-specific implementations ===== + + private suspend fun Player.breakBarricadeCutscene(guard: NPC) { + guard.clearWatch() + guard.face(Tile(2458, 3049, 0)) + delay(2) + guard.anim("ogre_kick") + sound("unarmed_kick") + delay(1) + set("zogre_flesh_eaters", "barricade") + set("thzfe_blocking_barricade", true) + sound("ogre_destroy_barricade") + delay(2) + } +} diff --git a/game/src/main/kotlin/content/area/kandarin/feldip_hills/UglugNar.kt b/game/src/main/kotlin/content/area/kandarin/feldip_hills/UglugNar.kt new file mode 100644 index 0000000000..01abdeda30 --- /dev/null +++ b/game/src/main/kotlin/content/area/kandarin/feldip_hills/UglugNar.kt @@ -0,0 +1,106 @@ +package content.area.kandarin.feldip_hills + +import content.entity.npc.shop.openShop +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.type.ChoiceOption +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.item +import content.entity.player.dialogue.type.items +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.inv.item.addOrDrop +import content.quest.quest +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove + +class UglugNar : Script { + init { + npcOperate("Talk-to", "uglug_nar") { (target) -> + when (quest("zogre_flesh_eaters")) { + "unstarted" -> firstMeetingMenu() + "investigate", "barricade", "sithik" -> repeatMeetingMenu() + else -> repeatMeetingMenu() + } + } + + npcOperate("Trade", "uglug_nar") { (target) -> + if (get("thzfe_sold_balm", false)) { + openShop("uglugs_stuffsies") + } else { + npc("Me's not got no glug-glugs to sell, yous bring me da sickies glug-glug den me's open da stufsies for ya.") + } + } + + registerSale("relicyms_balm_4", price = 1000) + registerSale("relicyms_balm_3", price = 650) + registerSale("relicyms_balm_2", price = 300) + registerSale("relicyms_balm_1", price = 100) + } + + // ===== Talk-to: First-time meeting (progress 0) ===== + + suspend fun Player.firstMeetingMenu() { + choice { + whatsGoingOn() + whatAreYouSelling() + okayThanks() + } + } + + fun ChoiceOption.whatsGoingOn(): Unit = option("Hey, what's going on here?") { + npc("Dem's dead ogre's come out of da ground...dey's makin' da rest of us into sick-uns ...and dead-uns.") + player("That doesn't sound good!") + npc("Grish want's da person go down der - see what's what!") + } + + fun ChoiceOption.whatAreYouSelling(): Unit = option("What are you selling?") { + if (get("thzfe_sold_balm", false)) { + npc("Me's showin' you da stufsies for yous creatures!") + openShop("uglugs_stuffsies") + } else { + npc("Me's not got no glug-glugs to sell, yous bring me da sickies glug-glug den me's open da stufsies for ya.") + } + } + + fun ChoiceOption.okayThanks(): Unit = option("Ok, thanks.") + + // ===== Talk-to: Repeat meeting (progress 2+) ===== + + suspend fun Player.repeatMeetingMenu() { + choice { + helloAgain() + whatAreYouSelling() + okayThanks() + } + } + + fun ChoiceOption.helloAgain(): Unit = option("Hello again.") { + if (get("thzfe_sold_balm", false)) { + npc("Hey yous creature...yous did good fings gedin that glug-glugs for da sickies! All is ogries pepels are not gettin dead cos of you.") + } else { + npc("Hey yous creature...yous still here?") + player("Yeah, I'm going to help Grish by figuring out what went on here.") + npc("If yous finds somefin for da sickies, yous brings to me...and I's gives you bright pretties, den me make more for alls pepels.") + player("Hmm, ok, I'll try to bear that in mind.") + } + } + + private fun registerSale(potion: String, price: Int) { + itemOnNPCOperate(potion, "uglug_nar") { + if (get("thzfe_sold_balm", false)) { + npc("Yous creatures is da funny ones...yous already solds me's ones now..and us can now sell un to yous!") + return@itemOnNPCOperate + } + item(item = potion, text = "You show the potion to Uglug Nar.") + player("Hey, here you go! I brought you some of the potion which should cure the disease. You said that you would buy some from me.") + npc("Yous creatures done da good fing...yous get many bright pretties for dis...!") + set("thzfe_sold_balm", true) + inventory.remove(potion) + addOrDrop("coins", price) + items(potion, "coins", "You sell the potion and get $price coins in return.") + } + } +} diff --git a/game/src/main/kotlin/content/area/kandarin/yanille/BartenderDragonInn.kt b/game/src/main/kotlin/content/area/kandarin/yanille/BartenderDragonInn.kt index e2aa81f870..46180523a1 100644 --- a/game/src/main/kotlin/content/area/kandarin/yanille/BartenderDragonInn.kt +++ b/game/src/main/kotlin/content/area/kandarin/yanille/BartenderDragonInn.kt @@ -1,12 +1,17 @@ package content.area.kandarin.yanille import content.entity.npc.shop.buy +import content.entity.player.dialogue.Happy import content.entity.player.dialogue.Neutral import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Sad +import content.entity.player.dialogue.Shifty +import content.entity.player.dialogue.Shock import content.entity.player.dialogue.type.choice import content.entity.player.dialogue.type.item import content.entity.player.dialogue.type.npc import content.entity.player.dialogue.type.player +import content.entity.player.inv.item.addOrDrop import content.quest.miniquest.alfred_grimhands_barcrawl.barCrawlDrink import content.quest.miniquest.alfred_grimhands_barcrawl.onBarCrawl import world.gregs.voidps.engine.Script @@ -14,6 +19,9 @@ import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.entity.character.npc.NPC import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove class BartenderDragonInn : Script { @@ -60,6 +68,35 @@ class BartenderDragonInn : Script { } barCrawl(target) } + + // ===== Tankard ===== + itemOnNPCOperate("dragon_inn_tankard", "bartender_dragon_inn") { + item(item = "dragon_inn_tankard", text = "You show the tankard to the Inn Keeper.") + if (get("thzfe_showntankard", false)) { + tankardRepeat() + } else { + tankardFirstTime() + } + } + + // ===== Bad portrait ===== + itemOnNPCOperate("zogre_sithik_portrait_bad", "bartender_dragon_inn") { + item(item = "zogre_sithik_portrait_bad", text = "You show the sketch to the Inn keeper.") + npc("Who's that? I mean, I guess it's a picture of a person isn't it? Sorry...you've got me? And before you ask, you're not putting it up on my wall!") + player("It's a portrait of Sithik Ints...don't you recognise him?") + npc("I'm sorry, I really am, but I just don't see it...can you make a better picture?") + player("I'll try...") + } + + // ===== Good portrait ===== + itemOnNPCOperate("zogre_sithik_portrait_good", "bartender_dragon_inn") { (target) -> + item(item = "zogre_sithik_portrait_good", text = "You show the portrait to the Inn keeper.") + if (get("thzfe_innkeepermugshown", false)) { + npc("Yeah, I recognise that Geezer, he was talking to one of my customers the other day.") + } else { + signPortrait(target) + } + } } suspend fun Player.barCrawl(target: NPC) = barCrawlDrink( @@ -69,4 +106,44 @@ class BartenderDragonInn : Script { levels.drain(Skill.Defence, 6) }, ) + + // ===== First-time tankard reveal ===== + + private suspend fun Player.tankardFirstTime() { + player("Hello there, I found this tankard in an ogre tomb cavern. It has the emblem of this Inn on it and I wondered if you knew anything about it?") + set("thzfe_showntankard", true) + npc("Oh yes, this is Brentle's mug...I'm surprised he left it just lying around down some cave. He's quite protective of it.") + player("Brentle you say? So you knew him then?") + npc("Yeah, this belongs to 'Brentle Vahn', he's quite a common customer, though I've not seen him in a while.") + npc("He was talking to some shifty looking wizard the other day. I don't know his name, but I'd recognise him if I saw him.") + player("Hmm, I'm sorry to tell you this, but Brentle Vahn is dead - I believe he was murdered.") + npc("Noooo! I'm shocked...") + npc("...but not surprised. He was a good customer...but I knew he would sell his sword arm and do many a dark deed if paid enough.") + npc("If you need help bringing the culprit to justice, you let me know.") + } + + // ===== Repeat tankard showing ===== + + private suspend fun Player.tankardRepeat() { + player("Hello again. Can you tell me what you know about this tankard again please?") + npc("Oh yes, Brentle's tankard. Yeah, you've shown me this already. It belonged to Brentle Vahn, he was quite a common customer, though I've not seen him in a while.") + npc("He was talking to some shifty looking wizard the other day. I don't know his name, but I'd recognise him if I saw him.") + } + + // ===== Good portrait + sign-it sequence ===== + + private suspend fun Player.signPortrait(npc: NPC) { + npc("Yeah, that's the guy who was talking to Brentle Vahn the other day! Look at those eyes, never a more shifty looking pair will you ever see!") + player("Hmm, you've just identified the man who I think sent Brentle Vahn to his death.") + player("I'm trying to bring him to justice with the Wizards' Guild grand secretary. Do you think you could sign this portrait to say that he was talking to Brentle Vahn.") + npc("I can and I will!") + npc.anim("human_mapping") + sound("zogre_writing") + inventory.remove("zogre_sithik_portrait_good") + addOrDrop("zogre_sithik_portrait_signed") + set("thzfe_innkeeperportraitshown", true) + item(item = "zogre_sithik_portrait_signed", text = "The Dragon Inn bartender signs the portrait.") + player("Many thanks for your help, it's really very good of you.") + npc("Not at all, just doing my part.") + } } diff --git a/game/src/main/kotlin/content/area/kandarin/yanille/SithikInts.kt b/game/src/main/kotlin/content/area/kandarin/yanille/SithikInts.kt new file mode 100644 index 0000000000..77c9761b5d --- /dev/null +++ b/game/src/main/kotlin/content/area/kandarin/yanille/SithikInts.kt @@ -0,0 +1,518 @@ +package content.area.kandarin.yanille + +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Confused +import content.entity.player.dialogue.Expression +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Sad +import content.entity.player.dialogue.Shifty +import content.entity.player.dialogue.Shock +import content.entity.player.dialogue.type.ChoiceOption +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.item +import content.entity.player.dialogue.type.items +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import content.entity.player.inv.item.addOrDrop +import content.quest.quest +import content.quest.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.type.random + +class SithikInts : Script { + init { + objectOperate("Talk-to", "zogre_sithik_bed_entity,ogre_bedman_loc") { + when (quest("zogre_flesh_eaters")) { + "unstarted", "started", "investigate" -> sleepyOldManIntro() + "barricade", "sithik" -> conversationByVarbit488() + "potion" -> postOgreReveal() + "permanent_spell" -> postQuestProgressed() + "given_key", "killed_slash_bash", "completed" -> backToGloat() + else -> sleepyOldManIntro() // catch-all preservation of other states + } + } + + objectOperate("Search", "sithiks_drawers") { + if (noMoreSnooping()) return@objectOperate + if (!hasPermission()) { + snoopWarning() + return@objectOperate + } + searchDrawer() + } + + objectOperate("Search", "sithiks_cupboard") { + if (noMoreSnooping()) return@objectOperate + if (!hasPermission()) { + snoopWarning() + return@objectOperate + } + searchCupboard() + } + + objectOperate("Search", "sithiks_wardrobe") { + if (noMoreSnooping()) return@objectOperate + if (!hasPermission()) { + snoopWarning() + return@objectOperate + } + searchWardrobe() + } + + itemOnObjectOperate("necromancy_book", "zogre_sithik_bed_entity,ogre_bedman_loc") { + player("Aha! A necromantic book! What's this doing here then?") + item(item = "necromancy_book", text = "You show the Necromantic book to Sithik.") + sithik("Oh..I'm not quite sure actually...where did you find that then?") + player("I found it in this cupboard! What do you have to say for yourself?") + sithik("Oh yes, that's right...I remember now. It's for my research, there's nothing really dangerous about it, unless it falls into the wrong hands. I'm sure it's pretty safe with me.") + player("Hmmm, likely story!") + } + + // ===== HAM book ===== + itemOnObjectOperate("book_of_ham", "zogre_sithik_bed_entity,ogre_bedman_loc") { + player("What's this then?") + item(item = "book_of_ham", text = "You show the HAM book to Sithik.") + sithik("What do you mean? It's a book by the respected HAM leader Johanhus Ulsbrecht, that man speaks for a lot of people who are unhappy with the current state of affairs.") + sithik("Can you honestly tell me that you've not had to fight for your life against the odd monster or two?") + player("Hmm, that may be true, but I don't universally hate all monsters, whereas I have a sneaking suspicion that you do...and ogres in particular!") + sithik("Hmm, that's an interesting theory, care to back it up with any facts?") + } + + // ===== Papyrus (sketching Sithik) ===== + itemOnObjectOperate("papyrus", "zogre_sithik_bed_entity,ogre_bedman_loc") { + if (questStage("zogre_flesh_eaters") >= 6) { + message("You have already created Sithik's portrait, you don't need another one.") + return@itemOnObjectOperate + } + if (!inventory.contains("charcoal")) { + statement("You have no charcoal with which to sketch this subject.") + return@itemOnObjectOperate + } + sithik("Oh lovely! You're making my portrait! Let me see it afterwards!") + statement("You begin sketching the irritable Sithik.") + anim("human_mapping") + sound("zogre_writing") + delay(2) + inventory.remove("papyrus") + val portrait = if (random.nextInt(3) == 0) "zogre_sithik_portrait_good" else "zogre_sithik_portrait_bad" // TODO use crafting level + inventory.add(portrait) + item(item = portrait, text = "You get a portrait of Sithik.") + } + + // ===== Book of portraiture ===== + itemOnObjectOperate("book_of_portraiture", "zogre_sithik_bed_entity,ogre_bedman_loc") { + player("Oh, so explain this then?") + item(item = "book_of_portraiture", text = "You show the book on portraiture to Sithik.") + sithik("It's my hobby...I'm interested in portraiture, but all art in general. It's fun, you should try it.") + player("How do I do it...") + sithik("Well...you could start by reading the book!") + } + + // ===== Bad portrait ===== + itemOnObjectOperate("zogre_sithik_portrait_bad", "zogre_sithik_bed_entity,ogre_bedman_loc") { + player("Here you go, what do you think?") + item(item = "zogre_sithik_portrait_bad", text = "You show the sketch...") + sithik("Hmmm, well it's an interesting interpretation, but not really classic realist representation is it? It's not my favourite, but I like the 'truth' of the work...well done.") + } + + // ===== Good portrait ===== + itemOnObjectOperate("zogre_sithik_portrait_good", "zogre_sithik_bed_entity,ogre_bedman_loc") { + player("Here you go, what do you think?") + item(item = "zogre_sithik_portrait_good", text = "You show the portrait to Sithik.") + sithik("Hmmm, well it's not the most flattering of portraits, but I like the 'honesty' of the work...well done.") + } + + // ===== Strange potion ===== + itemOnObjectOperate("zogre_ogre_trans_potion", "zogre_sithik_bed_entity,ogre_bedman_loc") { + player("Here, try some of this potion, it'll make you feel better!") + sithik("Err, yuck....no way am I taking any potions or medication off you...I don't trust you!") + } + + // ===== Signed portrait (the bribe scene) ===== + itemOnObjectOperate("signed_portrait", "zogre_sithik_bed_entity,ogre_bedman_loc") { + player("Hey, what do you think of this? I'm going to show it to Zavistic and you're going to be in trouble!") + item(item = "signed_portrait", text = "You show the portrait to Sithik.") + sithik("Hmmm, well, I've got quite a common looking face, I'm often mistaken for other wizards, you know, when I'm wearing my wizard's hat, robes and staff. There's a lot of us around here you know.") + player("I don't think so! This is a signed picture of you, someone recognised you, you're in deep trouble!") + sithik("Ok, I'll pay you to keep this secret - how much do you want for the picture?") + player("You can't buy me Sithik!") + sithik("Ok, let's say two million...two million to keep quiet and give me the picture.") + items("coins", "coins", "Sithik shows you a chest brimming over with coins...") + player("Oh...erm...well, that is a lot of money actually...er....") + sithik("Yes, and you deserve it, you're very clever! Now, take the money...") + bribeChoice() + } + + // ===== Dragon Inn tankard ===== + itemOnObjectOperate("dragon_inn_tankard", "zogre_sithik_bed_entity,ogre_bedman_loc") { + player("What about this then? Guess where I found this?") + item(item = "dragon_inn_tankard", text = "You show the tankard to Sithik.") + sithik("You probably found it at the local brewhouse! It doesn't take a genius to figure that one out.") + player("Aha! But I found this in an old ogre tomb! I suspect it's a clue which will lead me to the suspect.") + sithik("Hmmm, well that eliminates all the local people who don't actually drink at the 'Dragon Inn'. When do you think you'll start questioning the remaining population of Yanille?") + } + + // ===== Black prism ===== + itemOnObjectOperate("black_prism", "zogre_sithik_bed_entity,ogre_bedman_loc") { + player("Hey, what's this then, can you explain it?!") + item(item = "black_prism", text = "You show the black prism to Sithik.") + sithik("Err..it looks sort of familiar, did you steal it from me? Come to think of it, you have the appearance of a common thief!") + player("I found it in a place called Jiggig where some undead ogres happen to be wandering around.") + sithik("Oh, nothing to do with me then, never seen it in my life before!") + } + + // ===== Torn page ===== + itemOnObjectOperate("torn_page", "zogre_sithik_bed_entity,ogre_bedman_loc") { + player("Have you ever seen anything like this before?") + item(item = "torn_page", text = "You show the torn page to Sithik.") + sithik("It's probably a piece of rubbish someone threw away...what does it say, I can't read it?") + player("You should be able to read it, it's been torn from a book on necromancy and you're meant to be a specialist in the subject.") + sithik("Oh, no..., not really a specialist, just a hobby of mine really. Hardly know anything about it, but it does seem interesting...") + } + } + + // ===== Progress 0/2: Initial encounter ===== + + private suspend fun Player.sleepyOldManIntro() { + sithik("Hey...who gave you permission to come in here! Get out, get out I say.") + player("Alright, alright...keep your night cap on.") + } + + // ===== Progress 3/4: Branches by varbit 488 ===== + + private suspend fun Player.conversationByVarbit488() { + when (get("thzfe_prismsearch", 0)) { + 4 -> { + sithik("Hey...who gave you permission to come in here!") + zavisticIntro() + } + 5 -> { + sithik("What do you want now?") + noNeedToBeRude() + } + else -> sleepyOldManIntro() + } + } + + private suspend fun Player.zavisticIntro() { + player("Zavistic Rarve said that I could come and talk to you and ask you a few questions.") + sithik("Oh, Zavistic...why...why would he send you to me?") + sithikQuestionsMenu() + } + + suspend fun Player.sithikQuestionsMenu() { + choice { + askAboutUndeadOgres() + askWhatYouDo() + mindIfILookAround() + okThanks() + } + } + + fun ChoiceOption.askAboutUndeadOgres(): Unit = option("Do you know anything about the undead ogres at Jiggig?") { + sithik("Er...undead ogres...no, sorry, no idea what you're talking about there.") + player("Hmm, is that right...") + sithik("Well, yes, yes it is. If I knew something, I'd tell you.") + sithik("Anyway, dead ogres you say? How strange? That must be a strange sight?") + player("Very well, if you don't know anything about it, you won't mind if I look around then?") + provokedLookAround() + } + + fun ChoiceOption.askWhatYouDo(): Unit = option("What do you do?") { + sithik("I'm a scholarly student of the magical arts. When I was younger I used to be an adventurer, probably just like yourself. But I lost interest in the constant fighting, looting and gaining abilities.") + sithik("Instead I decided to focus my attention and time to study the purer form of the lost arts.") + player("The lost arts? What are they?") + sithik("Ignorant people call them the 'dark arts'. I'm talking about Necromancy, the power to bring the dead back to life - the power of the gods! Surely the most awesome power known to man.") + player("Hmm, well I guess I must be an ignorant person then, because bringing the dead back to life sounds very unnatural.") + sithikQuestionsMenu() + } + + fun ChoiceOption.mindIfILookAround(): Unit = option("Do you mind if I look around?") { + if (get("thzfe_prismsearch", 0) == 5) { + triedAlready() + } else { + provokedLookAround() + } + } + + fun ChoiceOption.okThanks(): Unit = option("Ok, thanks.") + + private suspend fun Player.provokedLookAround() { + set("thzfe_prismsearch", 5) + sithik("Well, err....well, actually yes I do mind...it's my place and I don't want strangers going through my things.") + player("Well, I'm going to have a look around anyway, if you're not involved in this whole thing, you won't have anything to hide.") + sithik("Why, if I was a few years younger I'd give you a good hiding!") + player("I'm sure!") + sithikQuestionsMenu() + } + + private suspend fun Player.triedAlready() { + sithik("I've already told you that I do! But you'll probably just ignore me again!") + player("Quite right!") + } + + // ===== "Snooping" reaction (varbit 488 == 5) ===== + + private suspend fun Player.noNeedToBeRude() { + player("Hey there's no need to be rude!") + sithik("What do you expect when you just go snooping around a person's place against their express permission.") + snoopMenu() + } + + suspend fun Player.snoopMenu() { + choice { + askWhatYouDo() + whyInBed() + okThanks() + } + } + + fun ChoiceOption.whyInBed(): Unit = option("Why do you spend most of your time in bed?") { + sithik("I'm actually quite old and not so very well and I'd like to get over this illness I have, then I'll return to my very serious and important studies.") + sithikQuestionsMenu() + } + + // ===== Progress 6: Player turned Sithik into an ogre ===== + + private suspend fun Player.postOgreReveal() { + if (get("thzfe_sithik_transformed", 0) >= 1) { + ogreFormConfession() + } else { + sithik("What do you want now?") + noNeedToBeRude() + } + } + + private suspend fun Player.ogreFormConfession() { + sithik("Arghhhh..what's happened to me...you beast!") + player("It's your own fault, you shouldn't have lied about your involvement with the undead Ogres at Jiggig. The potion will wear off once you've told the truth!") + sithik("Ok, ok, I admit it, I got Brentle Vahn to cast the spell to put an end to those awful Ogres...they're just disgusting creatures...") + player("Ok, that's a start...now I want some answers.") + confessionAnswersMenu() + } + + suspend fun Player.confessionAnswersMenu() { + choice { + removeSpellFromArea() + getRidOfOgres() + getRidOfDisease() + sorryHaveToGo() + } + } + + fun ChoiceOption.removeSpellFromArea(): Unit = option("How do I remove the effects of the spell from the area?") { + player("How do I remove the effects of the spell from the area? The ogres want to get their ceremonial dance area back and can't do that with undead walking all over it.") + if (questStage("zogre_flesh_eaters") >= 8) { + sithik("Haven't I told you this already? You can't remove the spell, it's permanent, it will last forever, the only option you have is to move the ceremonial area.") + } else { + sithik("Unfortunately you can't. The spell is permanent, it will last forever, the only option you have is to move the ceremonial area.") + set("zogre_flesh_eaters", "permanent_spell") + } + player("You're an evil man and I'm going to make you pay for this...you can stay like that forever as far as I'm concerned.") + sithik("No...no, let me try to make amends...please I can help you. Just don't leave me like this.") + confessionAnswersMenu() + } + + fun ChoiceOption.getRidOfOgres(): Unit = option("How do I get rid of the undead ogres?") { + if (get("thzfe_makebrutalarrow", false)) { + sithik("Haven't I already explained this to you once before?") + player("Humour me!") + explainBrutalArrows() + } else { + explainBrutalArrows() + } + } + + private suspend fun Player.explainBrutalArrows() { + sithik("Ok, similar spells have been cast before and the only way to deal with the resulting creatures is to cordon off the area and not go in there again.") + sithik("The undead creatures usually manifest some sort of disease so it's best to attack them from a distance with a ranged weapon.") + sithik("Normal missiles like arrows and darts do very little damage to them because they're designed to destroy internal organs. This is a waste of time with undead creatures like undead ogres.") + player("Yeah, clearly so what should we use?") + set("thzfe_makebrutalarrow", true) + sithik("From my research it looks like a flat ended arrow was designed called a 'Brutal arrow'. This does large amounts of crushing damage to the creature. You can make them by using larger arrows. ") + sithik("I think some Ogre hunters make them. But instead of adding an arrow tip, you hammer a large nail into the end of the shaft.") + confessionAnswersMenu() + } + + fun ChoiceOption.getRidOfDisease(): Unit = option("How do I get rid of the disease?") { + if (get("thzfe_makecuredisease", false)) { + sithik("Haven't I already explained this disease thing to you once before?") + val threat = if (get("thzfe_sithik_transformed", 0) == 2) { + "Just tell me again or else I'll turn you back into an ogre!" + } else { + "Just tell me again or else I'll never turn you back into a human!" + } + player(threat) + sithik("No...noo...please, I'll tell you.") + explainDiseaseCure() + } else { + explainDiseaseCure() + } + } + + private suspend fun Player.explainDiseaseCure() { + set("thzfe_makecuredisease", true) + sithik("My research shows that two jungle based herbs can be used, one is found near river tributaries and looks like a vine, the other is found in caves and grows on the wall.") + sithik("It's quite well camouflaged so it's unlikely that you'll find it.") + player("We'll see about that!") + confessionAnswersMenu() + } + + fun ChoiceOption.sorryHaveToGo(): Unit = option("Sorry, I have to go.") { + sithik("But...you can't just leave me here like this!") + } + + // ===== Progress 8: Returns post-confession ===== + + private suspend fun Player.postQuestProgressed() { + sithik("Arghhhh..what do you want now...you've turned me into a beast!") + player("I've got some questions for you...and you'd better answer them well or else!") + sithik("Ok, ok, I'll tell you anything, just turn me back into a human again!") + confessionAnswersMenu() + } + + // ===== Progress 10/12: Post-quest gloating ===== + + private suspend fun Player.backToGloat() { + sithik("Oh, so you're back then, come to gloat have you?") + player("Nope, I've just come to ask you a couple of questions.") + choice { + getRidOfOgres() + getRidOfDisease() + sorryHaveToGo() + } + } + + /** + * Returns true if the quest is past the snooping investigation phase. + * Once you're at progress 4+, the furniture has nothing of significance. + */ + private fun Player.noMoreSnooping(): Boolean { + if (questStage("zogre_flesh_eaters") >= 4) { + message("You search but find nothing of significance.") + return true + } + return false + } + + /** + * Permission to snoop is granted by varbit 488 reaching 5, + * which happens during Sithik's "Do you mind if I look around?" exchange + * after he's caught lying about the undead ogres. + */ + private fun Player.hasPermission(): Boolean = get("thzfe_prismsearch", 0) >= 5 + + private suspend fun Player.snoopWarning() { + sithik("Hey! What do you think you're doing?") + player("Erk! I'd better not start rifling through peoples things without permission.") + } + + // ===== Drawer (object 6875): papyrus, charcoal, book of portraiture ===== + + private suspend fun Player.searchDrawer() { + val hasPapyrus = inventory.contains("papyrus") // 970 + val hasCharcoal = inventory.contains("charcoal") // 973 + val ownsBook = inventory.contains("book_of_portraiture") // already has the book somewhere + + // Determine how much inventory space we need to take everything remaining + val spaceNeeded = when { + hasPapyrus && hasCharcoal -> if (ownsBook) 0 else 1 + hasPapyrus || hasCharcoal -> if (ownsBook) 1 else 2 + else -> if (ownsBook) 2 else 3 + } + + if (spaceNeeded > 0 && inventory.spaces < spaceNeeded) { + statement("You see some items in the drawer, but you need $spaceNeeded free inventory spaces to take them.") + return + } + + when { + // All items already collected + ownsBook && hasPapyrus && hasCharcoal -> { + message("You find nothing in the drawers.") + } + // Has both papyrus and charcoal — only book left + hasPapyrus && hasCharcoal -> { + addOrDrop("book_of_portraiture") + item(item = "book_of_portraiture", text = "You find a book on portraiture.") + } + // Has papyrus only — find charcoal (and maybe book) + hasPapyrus -> { + addOrDrop("charcoal") + item(item = "charcoal", text = "You find some charcoal.") + if (!ownsBook) findBookFollowup() + } + // Has charcoal only — find papyrus (and maybe book) + hasCharcoal -> { + addOrDrop("papyrus") + item(item = "papyrus", text = "You find some papyrus.") + if (!ownsBook) findBookFollowup() + } + // Has neither — find both at once (and maybe book) + else -> { + addOrDrop("charcoal") + addOrDrop("papyrus") + items("charcoal", "papyrus", "You find some charcoal and papyrus.") + if (!ownsBook) findBookFollowup() + } + } + } + + private suspend fun Player.findBookFollowup() { + addOrDrop("book_of_portraiture") + item(item = "book_of_portraiture", text = "You also find a book on portraiture.") + } + + // ===== Cupboard (object 6876): necromancy book ===== + + private suspend fun Player.searchCupboard() { + if (inventory.contains("necromancy_book")) { + statement("You search the cupboard but find nothing.") + return + } + addOrDrop("necromancy_book") + item(item = "necromancy_book", text = "You find a book on Necromancy.") + } + + // ===== Wardrobe (object 55412): book of HAM ===== + + private suspend fun Player.searchWardrobe() { + if (inventory.contains("book_of_ham")) { + statement("You search the wardrobe but find nothing.") + return + } + addOrDrop("book_of_ham") + item(item = "book_of_ham", "You find a book on Philosophy written by the 'Human's Against Monsters' leader, Johanhus Albrect.") + } + + suspend fun Player.bribeChoice() { + choice("Be bribed by Sithik for 2 million?") { + refuseBribe() + acceptBribe() + } + } + + fun ChoiceOption.refuseBribe(): Unit = option("No, I won't take the money, I'm going to bring you to justice!") { + sithik("Oh well, suit yourself! I wasn't going to give you the money anyway! No one will believe some crazy adventurer and an Inn keep.") + } + + fun ChoiceOption.acceptBribe(): Unit = option("Ok, I'll shut up for two million!") { + sithik("Ha! Ha! You believed me! I'm not going to give you all my money! No one will believe a crazy adventurer and a local Inn keep!") + player("You're a mean and cruel man Sithik, a mean and cruel man!") + } + + // Picks the right chathead id depending on whether the player has transformed Sithik into an ogre. + private suspend inline fun Player.sithik(text: String) { + val ogre = get("thzfe_sithik_transformed", 0) >= 1 + val npcId = if (ogre) "zogre_sithik_ogre" else "zogre_sithik_man" + npc(npcId, text) + } +} diff --git a/game/src/main/kotlin/content/area/kandarin/yanille/ZavisticRarve.kt b/game/src/main/kotlin/content/area/kandarin/yanille/ZavisticRarve.kt new file mode 100644 index 0000000000..99d6b6bc7f --- /dev/null +++ b/game/src/main/kotlin/content/area/kandarin/yanille/ZavisticRarve.kt @@ -0,0 +1,704 @@ +package content.area.kandarin.yanille + +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Confused +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Sad +import content.entity.player.dialogue.Scared +import content.entity.player.dialogue.Shifty +import content.entity.player.dialogue.Shock +import content.entity.player.dialogue.type.ChoiceOption +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.item +import content.entity.player.dialogue.type.items +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import content.entity.player.inv.item.addOrDrop +import content.quest.quest +import content.quest.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.ui.dialogue.talkWith +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.name +import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.type.Tile + +class ZavisticRarve : Script { + + init { + // ===== Plain Talk-to (or via bell) ===== + + npcOperate("Talk-to", "zavistic_rarve") { (target) -> + val sandProgress = get("hand_in_the_sand", 0) + val zogreProgress = questStage("zogre_flesh_eaters") + if (sandProgress < 40 && zogreProgress < 4) { + npc("What are you doing bothering me? Don't you think some of us have work to do?") + player("I thought you were here to help?") + npc("Well... I am, I suppose, anyway... we're very busy here, hurry up, what do you want?") + } else { + npc("What are you doing...Oh, it's you...sorry...didn't realise...what can I do for you?") + } + mainMenu() + } + + // ===== Bell-rung Talk-to ===== + + objectOperate("Ring", "zogre_outdoor_bell") { (target) -> + sound("zogre_bell") + target.anim("zogre_bell_ring") + var zavistic = NPCs.findOrNull(tile.regionLevel, "zavistic_rarve") + if (zavistic == null) { + zavistic = NPCs.add( + id = "zavistic_rarve", + tile = Tile(2598, 3087, 0), + ticks = 200, + ) + } + talkWith(zavistic) + + val sandProgress = get("hand_in_the_sand", 0) + val zogreProgress = questStage("zogre_flesh_eaters") + if (sandProgress < 40 && zogreProgress < 4) { + npc("What are you doing ringing that bell?! Don't you think some of us have work to do?") + player("But I was told to ring the bell if I wanted some attention.") + npc("Well...anyway...we're very busy here, hurry up what do you want?") + } else { + npc("What are you doing...Oh, it's you...sorry...didn't realise...what can I do for you?") + } + mainMenu() + } + + // ===== Item-on-Zavistic ===== + + itemOnNPCOperate("beer_hand", "zavistic_rarve") { + handInTheSandHandReveal() + } + + itemOnNPCOperate("magic_scroll", "zavistic_rarve") { + magicScrollReveal() + } + + itemOnNPCOperate("black_prism", "zavistic_rarve") { + player("I found this black prism at Jiggig where the undead ogre activity was happening?") + showBlackPrism() + } + + itemOnNPCOperate("torn_page", "zavistic_rarve") { + player("I think I've found a clue from the Jiggig area where the undead ogre activity is happening.") + showTornPage() + } + + itemOnNPCOperate("dragon_inn_tankard", "zavistic_rarve") { + item(item = "dragon_inn_tankard", text = "You show the dragon Inn Tankard to Zavistic.") + showTankard() + } + + itemOnNPCOperate("necromancy_book", "zavistic_rarve") { + item(item = "necromancy_book", text = "You show the Necromancy book to Zavistic.") + showNecromancyBook() + } + + itemOnNPCOperate("book_of_ham", "zavistic_rarve") { + item(item = "book_of_ham", text = "You show the HAM book to Zavistic.") + showHamBook() + } + + itemOnNPCOperate("zogre_sithik_portrait_signed", "zavistic_rarve") { + item(item = "zogre_sithik_portrait_signed", text = "You show the signed portrait of Sithik to Zavistic.") + showSignedPortrait() + } + + itemOnNPCOperate("zogre_sithik_portrait_good", "zavistic_rarve") { + item(item = "zogre_sithik_portrait_good", text = "You show the portrait of Sithik to Zavistic.") + npc("Hmm, great...but I already know what he looks like!") + } + + itemOnNPCOperate("zogre_sithik_portrait_bad", "zavistic_rarve") { + player("Look, I made a portrait of Sithik.") + item(item = "zogre_sithik_portrait_bad", text = "You show the sketch...") + npc("Who the demonikin is that? Is it meant to be a portrait of Sithik, it doesn't look anything like him!") + } + } + + // ===== Top-level menu router ===== + + private suspend fun Player.mainMenu() { + val sand = get("hand_in_the_sand", 0) >= 20 + val zogre = questStage("zogre_flesh_eaters") >= 3 + when { + zogre && sand -> { + choice { + aboutZogres() + aboutSand() + } + } + zogre -> sendZogreChat() + sand -> sendSandChat() + else -> guildMenu() + } + } + + fun ChoiceOption.aboutZogres(): Unit = option("I'm here about the sicks...err Zogres") { + sendZogreChat() + } + + fun ChoiceOption.aboutSand(): Unit = option("I have a rather sandy problem that I'd like to palm off on you.") { + sendSandChat() + } + + // ===== Wizards' Guild info menu (shared default) ===== + + suspend fun Player.guildMenu() { + choice { + whatIsThereToDo() + whatAreRequirements() + whatDoYouDo() + okThanks() + } + } + + suspend fun Player.guildMenuWithOrbHelp() { + choice { + whatIsThereToDo() + whatAreRequirements() + whatDoYouDo() + if (inventory.contains("magical_orb") || inventory.contains("magical_orb_active")) { + canYouHelpMore() + } else { + lostOrb() + } + } + } + + suspend fun Player.guildMenuWithLostOrb() { + choice { + whatIsThereToDo() + whatAreRequirements() + whatDoYouDo() + lostOrb() + } + } + + fun ChoiceOption.whatIsThereToDo(): Unit = option("What is there to do in the Wizards' Guild?") { + npc("This is the finest wizards' establishment in the land. We have magic portals to the other towers of wizardry around RuneScape. We have a particularly wide collection of runes in our rune shop. We sell some of") + npc("the finest mage robes in the land and we have a training area full of zombies for you to practice your magic on.") + guildMenu() + } + + fun ChoiceOption.whatAreRequirements(): Unit = option("What are the requirements to get in the Wizards' Guild?") { + npc("You need a magic level of 66, the high magic energy level is too dangerous for anyone below that level.") + guildMenu() + } + + fun ChoiceOption.whatDoYouDo(): Unit = option("What do you do in the Guild?") { + npc("I'm the Grand Secretary for the Wizards' Guild, I have lots of correspondence to keep up with, as well as attending to the discipline of the more problematic guild members.") + guildMenu() + } + + fun ChoiceOption.okThanks(): Unit = option("Ok, thanks.") + + fun ChoiceOption.canYouHelpMore(): Unit = option("Can you help me more?") { + helpMoreFlow() + } + + fun ChoiceOption.lostOrb(): Unit = option("I've lost my magical scrying orb!") { + replaceOrb() + } + + // ===== HAND IN THE SAND branches ===== + + private suspend fun Player.sendSandChat() { + when (get("hand_in_the_sand", 0)) { + 20 -> { + if (inventory.contains("beer_hand")) { + handInTheSandHandReveal() + } else { + statement("Maybe you should have the hand with you before speaking to Zavistic.") + } + } + 30, 40, 50 -> { + npc("Did you find out who killed Clarence yet?") + player("Not yet, but don't lose your head over it.") + } + 60 -> { + if (inventory.contains("magic_scroll")) { + magicScrollReveal() + } else { + statement("Perhaps you should have the scroll from Bert with you before you speak to Zavistic.") + } + } + 70 -> guildMenuWithOrbHelp() + 80, 90, 100 -> { + npc("Have you made the serum and talked to Sandy yet?") + if (inventory.contains("magical_orb")) { + player("Not yet, but don't bust a gut over it!") + } else { + player("I've lost my magical scrying orb!") + replaceOrb() + } + } + 110 -> guildMenuWithLostOrb() + 120 -> { + if (inventory.contains("magical_orb_active")) { + statement("You hand the magical scrying orb to the Wizard and watch as the recording is played back.") + npc("Well, well...I think this Sandy needs a lesson, please bring me 5 earth runes and a bucket of sand.") + runesAndSandRequest() + } else { + player("I got the whole story from Sandy... but I lost the orb.") + npc("It's ok, I saw the whole thing as the orb is connected via magic to me as I enchanted it.") + npc("I think this Sandy needs a lesson, please bring me 5 earth runes and a bucket of sand.") + runesAndSandRequest() + } + } + 130 -> { + if (inventory.contains("earth_rune", 5) && inventory.contains("bucket_of_sand")) { + player("I've brought what you wanted, what are you going to do?") + sandpitRefillCutscene() + } else { + npc("You really mean you forgot? Bring me 5 earth runes and 1 bucket of sand to help stop that moneygrabbing Sandy!") + } + } + 140 -> { + npc("Did you visit the Entrana sandpit yet? Ask the worker there if he's found an arm or a leg.") + player("Not yet no. I've been running around like a headless chicken, but I'll get to it!") + } + 150 -> { + if (inventory.contains("wizard_head")) { + item(item = "wizard_head", text = "You show the wizard the head.") + npc("Alas poor Clarence. I knew him, $name.") + npc("Thank you - we shall bury him today. I have sent word for the guards to arrest Sandy, so no one will ever see him again!") +// sendHandQuestReward() + } else { + statement("Perhaps you should have the wizard's head with you before speaking to Zavistic.") + } + } + 160 -> { + npc("Thank you so much for helping to lay Clarence to rest and lock up his murderer!") + guildMenu() + } + else -> guildMenu() + } + } + + // ===== Hand reveal flow (the murder is revealed) ===== + + private suspend fun Player.handInTheSandHandReveal() { + if (!inventory.contains("beer_hand")) { + statement("Maybe you should have the hand with you before speaking to Zavistic.") + return + } + player("Ummm... Do you have all your wizards?") + npc("All my.... whatever do you mean...?") + player("The Guard Captain asked me to see if you have any... missing... wizards.") + npc("That's silly! No one would kill a wizard... would they?") + player("Erm... no... ") + player("Well.. maybe, you see Bert found this hand and it might belong to.. a wizard!") + npc("Bert? Ahh yes, the sandman who seems to have been working very long hours recently. Let's see that hand...") + set("hand_in_the_sand", 30) + inventory.remove("beer_hand") + item(item = "beer_hand", text = "You hand it over.") + npc("Oh my! This is most definitely Clarence, my most able student! You must find out who did this!") + player("Do you have any input as to the matter at hand?") + npc("Well.... Ask Bert about the long hours he's been working, that sounds suspicious to me. Digging things up at all hours of the day isn't natural.") + } + + // ===== Magic scroll reveal (mind-altering spell) ===== + + private suspend fun Player.magicScrollReveal() { + if (!inventory.contains("magic_scroll")) { + statement("Perhaps you should have the scroll from Bert with you before you speak to Zavistic.") + return + } + player("I talked to Bert and found something very strange about his hours.") + npc("Oh? Did he kill Clarence?") + player("No, but he doesn't remember changing his hours, and his rota and the original that his boss Sandy had, are different! ") + player("... oh, and this scroll appeared when they changed - he gave it to me.") + npc("I recognise that type of scroll! It's used in a mind altering spell of some sort. Did you speak to this... Sandy guy? Perhaps he has a hand in this.") + player("I took a look around his office. I don't know about a hand in it, I think he has both hands and feet in it!") + npc("Even more suspicious! Here, take this magical scrying orb and get some Truth Serum from Betty in Port Sarim, she owes me a favour, just tell her I sent you if she complains.") + npc("Then you will be equipped to ask Sandy a few questions. Oh Clarence, I will find your murderer!") + set("hand_in_the_sand", 70) + inventory.remove("magic_scroll") + addOrDrop("magical_orb") + item("magical_orb", "You exchange the scroll for the magical scrying orb. Perhaps Zavistic can give you even more of a hand to find the murderer?") + } + + // ===== "Can you help me more?" / replace orb / teleport ===== + + private suspend fun Player.helpMoreFlow() { + if (get("handsand_tele", false)) { + npc("Unfortunately I've already helped you with one teleport, get some exercise - your legs won't fall off!") + return + } + npc("Bring me a vial and I'll help you a little more.") + if (!inventory.contains("vial")) return + player("I have a vial here for you.") + npc("Ok, would you like me to transport you to Port Sarim? I'm sticking my neck out a bit helping you like this and can only do it once though!") + choice { + yesTeleport() + noTeleport() + } + } + + fun ChoiceOption.yesTeleport(): Unit = option("Yes, that would be great!") { + npc("Off you go then, break a leg!") + portSarimTeleport() + } + + fun ChoiceOption.noTeleport(): Unit = option("No, I prefer using my legs, thanks all the same.") { + npc("Ok, suit yourself!") + } + + private suspend fun Player.replaceOrb() { + if (inventory.contains("magical_orb") || inventory.contains("magical_orb_active")) { + // Already has one + return + } + if (inventory.isFull()) { + npc("I'd give you another magical scrying orb if you had some space in your inventory.") + return + } + if (get("hand_in_the_sand", 0) == 110) { + addOrDrop("magical_orb_active") + npc("No matter, here, have another I've already activated it for you!") + } else { + addOrDrop("magical_orb") + npc("No matter, here, have another and please hurry, whoever killed Clarence must pay!") + } + } + + // ===== Port Sarim teleport cutscene ===== + + private suspend fun Player.portSarimTeleport() { + set("handsand_tele", true) + inventory.remove("vial") + // npc.anim("human_castentangle") TODO + delay(2) + gfx("pickaxe_summon_effect_spotanim", height = 92) + anim("human_shrink", delay = 4) + sound("teleport_all") + delay(3) + clearAnim() + tele(3014, 3259) + } + + // ===== Runes and sand request continuation ===== + + private suspend fun Player.runesAndSandRequest() { + set("hand_in_the_sand", 130) + inventory.remove("magical_orb_active") + player("Erm, why?") + npc("Don't question me or you'll end up as braindead as that legless Guard Captain!") + player("Umm.. ok, I'll get you the 5 earth runes and bucket of sand.") + } + + // ===== Sandpit refill cutscene (instanced) ===== + + private suspend fun Player.sandpitRefillCutscene() { + npc("Ahh excellent, let's have those! Watch and learn...") + // TODO: full instanced cutscene + // - Create instance at base (317, 386), 3x3 size + // - Spawn Bert NPC (id 3108) inside the instance + // - Fade out, start cutscene mode + // - Camera move to (2536, 3109) height 850, look at (2544, 3102) height 25 + // - Wizard chants — show info dialogue: + // "The Wizard chants and your attention is taken to the sandpit where Bert found the hand." + // - Bert walks to (2542, 3101), faces (2542, 3103) + // - Bert animates 2702, sandpit object animates 3037, sound 1591 + // - Bert says "My sand! My lovely sand" + // - Show info dialogue: + // "Something very strange happens to the Sandpit, it looks like it has filled itself up!" + // - Set varbit 278 to 1 (sandpit refilled flag) + // - Fade out, destroy instance, reset camera, fade in + // - Delete 5 earth runes, 1 bucket of sand + // - Set hand_in_the_sand to 140 + + statement("The Wizard chants and your attention is taken to the sandpit where Bert found the hand.") + statement("Something very strange happens to the Sandpit, it looks like it has filled itself up!") + inventory.remove("earth_rune", 5) + inventory.remove("bucket_of_sand") + set("hand_in_the_sand", 140) + npc("There, the sand pit will now magically refill. No more work for Bert! ") + npc("We must find the rest of Clarence, I've sent some wizards out to some of the sandpits, would you please check the Entrana sandpit?") + } + + // ===== ZOGRE FLESH EATERS branches ===== + + private suspend fun Player.sendZogreChat() { + val progress = quest("zogre_flesh_eaters") + val sithikIntro = get("thzfe_prismsearch", 0) + + when { + progress == "permanent_spell" || progress == "given_key" || progress == "killed_slash_bash" || progress == "completed" -> { + npc("Don't you worry about Sithik, he's not likely to be moving from his bed for a long time. When he eventually does get better, he's going to be sent before a disciplinary tribunal, then we'll sort out what's what.") + player("Thanks for your help with all of this.") + npc("Ooohh, no thanks required. It's I who should be thanking you my friend...your investigative mind has shown how vigilant we really should be for this type of evil use of the magical arts.") + guildMenu() + } + progress == "sithik" || progress == "potion" -> { + npc("Have you used that potion yet?") + if (progress == "potion") { + yesUsedPotion() + } else if (inventory.contains("zogre_ogre_trans_potion")) { + notYetUsedPotion() + } else { + lostPotion() + } + } + sithikIntro == 5 -> sithikInvestigationMenu() + sithikIntro == 4 -> sithikInvestigationMenuLimited() + inventory.contains("black_prism") && inventory.contains("torn_page") -> { + player("There's some undead ogre activity over at Jiggig, I've found some clues, I wondered if you'd have a look at them.") + showBothClues() + } + inventory.contains("black_prism") -> { + player("There's some undead ogre activity over at 'Jiggig', and the ogres have asked me to look into it. I think I've found a clue and I wonder if you could take a look at it for me?") + showBlackPrism() + } + inventory.contains("torn_page") -> { + player("There's some undead ogre activity over at Jiggig, I've found a clue that you may be able to help with.") + showTornPage() + } + else -> guildMenu() + } + } + + // ===== Sithik investigation menus ===== + + suspend fun Player.sithikInvestigationMenuLimited() { + val evidenceCount = evidenceCount() + choice { + whatDidYouSayShouldDo() + whereIsSithik() + if (hasEvidence()) showEvidenceOption(evidenceCount) + wantToAskAboutGuild() + sorryHaveToGo() + } + } + + suspend fun Player.sithikInvestigationMenu() { + val evidenceCount = evidenceCount() + choice { + whatDidYouSayShouldDo() + whereIsSithik() + if (hasEvidence()) showEvidenceOption(evidenceCount) else canYouHelp() + wantToAskAboutGuild() + sorryHaveToGo() + } + } + + fun ChoiceOption.whatDidYouSayShouldDo(): Unit = option("What did you say I should do?") { + npc("You should go and have a chat with Sithik Ints, he's in that house just to the north. He's a lodger and has a room upstairs. Just tell him that I sent you to see him. He should be fine once you've mentioned my name.") + sithikInvestigationMenu() + } + + fun ChoiceOption.whereIsSithik(): Unit = option("Where is Sithik?") { + npc("He's in that house just to the north, less than a few seconds walk away. He's a lodger and has a room upstairs...he's not very well though.") + sithikInvestigationMenu() + } + + fun ChoiceOption.showEvidenceOption(evidenceCount: Int) { + val text = if (evidenceCount == 1) { + "I have an item that I'd like you to look at." + } else { + "I have some items that I'd like you to look at." + } + return option(text) { + showAllEvidence() + } + } + + // Workaround helper since ChoiceOption doesn't expose Player directly in some patterns + private fun Player.evidenceCount(): Int { + var count = 0 + if (inventory.contains("necromancy_book")) count++ + if (inventory.contains("book_of_ham")) count++ + if (inventory.contains("dragon_inn_tankard")) count++ + if (inventory.contains("signed_portrait")) count++ + return count + } + + private fun Player.hasEvidence(): Boolean = evidenceCount() > 0 + + fun ChoiceOption.canYouHelp(): Unit = option("Can you help me?") { + npc("I'm happy to help as much as I can but you have to remember that I'm quite busy. If you find any more clues about what happened at Jiggig, I'll consider them with an open mind - that's as much as I can offer.") + sithikInvestigationMenu() + } + + fun ChoiceOption.wantToAskAboutGuild(): Unit = option("I want to ask about the Magic Guild.") { + npc("Sure, go ahead, ask away.") + guildMenu() + } + + fun ChoiceOption.sorryHaveToGo(): Unit = option("Sorry, I have to go.") + + // ===== Cycle through all evidence the player has ===== + + private suspend fun Player.showAllEvidence() { + // Order: necromancy book, HAM book, tankard, signed portrait + if (inventory.contains("necromancy_book")) { + item(item = "necromancy_book", text = "You show the Necromancy book to Zavistic.") + showNecromancyBook() + } + if (inventory.contains("book_of_ham")) { + item(item = "book_of_ham", text = "You show the HAM book to Zavistic.") + showHamBook() + } + if (inventory.contains("dragon_inn_tankard")) { + item(item = "dragon_inn_tankard", text = "You show the dragon Inn Tankard to Zavistic.") + showTankard() + } + if (inventory.contains("zogre_sithik_portrait_signed")) { + item(item = "zogre_sithik_portrait_signed", text = "You show the signed portrait of Sithik to Zavistic.") + showSignedPortrait() + } + } + + // ===== Individual evidence reveals ===== + + private suspend fun Player.showBlackPrism() { + item(item = "black_prism", text = "You show the black prism to the aged wizard.") + if (get("thzfe_prismsearch", 0) >= 4) { + npc("Yes, you've already showed me that, bring it to me when you've resolved the problems at Jiggig and I'll see what I can do.") + return + } + npc("Hmmm, well this is an uncommon spell component. On it's own it's useless, but with certain necromantic spells it can be very powerful. Did you find anything else there?") + if (inventory.contains("dragon_inn_tankard")) { + item(item = "dragon_inn_tankard", text = "You show the tankard to Zavistic.") + player("Well, I found this...") + npc("Hmmm, no, that's not really associated with this to be honest. Did you find anything else there?") + player("Not really.") + } else { + player("Not really.") + } + npc("I don't know what to say then, there isn't enough to go on with the clues you've shown me so far. I'd suggest going back to search a bit more, but you may just be wasting your time?") + npc("Hmm, but this prism does seem to have some magical protection. Once you've finished with this item, bring it back to me would you? I may have a reward for you!") + player("Sure...I mean, I'll try if I remember.") + } + + private suspend fun Player.showTornPage() { + item(item = "torn_page", text = "You show the necromantic half page to the aged wizard.") + npc("Hmm, this is a half torn spell page, it requires another spell component to be effective. Did you find anything else there?") + if (inventory.contains("black_prism")) { + showBothClues() + } else if (inventory.contains("dragon_inn_tankard")) { + item(item = "dragon_inn_tankard", text = "You show the tankard to Zavistic.") + player("Well, I found this...") + npc("Hmmm, no, that's not really associated with this to be honest. Did you find anything else there?") + player("Not really.") + npc("I don't know what to say then, there isn't enough to go on with the clues you've shown me so far. I'd suggest going back to search a bit more, but you may just be wasting your time?") + } else { + player("Not really.") + npc("I don't know what to say then, there isn't enough to go on with the clues you've shown me so far. I'd suggest going back to search a bit more, but you may just be wasting your time?") + } + } + + // The combined clue scene — sets up the Sithik investigation + private suspend fun Player.showBothClues() { + items("black_prism", "torn_page", "You show the prism and the necromantic half page to the aged wizard.") + npc("Hmmm, now this is interesting! Where did you get these from?") + player("I got them from a nearby Ogre tomb, it's recently been infested with zombie ogres and I'm trying to work out what happened there.") + npc("This is very troubling $name, very troubling indeed. While it's permitted for learned members of our order to research the 'dark arts', it's absolutely forbidden to make use of such magic.") + player("Do you have any leads on people that I might talk to regarding this?") + set("thzfe_prismsearch", 4) + npc("Well a wizard by the name of 'Sithik Ints' was doing some research in this area. He may know something about it. He's lodged at that guest house to the North, though he's ill and isn't able to leave his room.") + npc("Why not go and talk to him, poke around a bit and see if anything comes up. Let me know how you get on. However, I doubt that 'Sithik' had anything to do with it.") + npc("There's a severe penalty for using the 'dark arts'. If you find any evidence to the contrary, please bring it to me.") + npc("Hmm, that black prism seems to have some magical protection. Once you've finished with this item, bring it back to me would you. I may have a reward for you.") + } + + private suspend fun Player.showTankard() { + player("This is the tankard I found on the remains of Brentle Vahn!") + if (get("thzfe_innkeepermugshown", false)) { + npc("Yeah, you've shown me this before...if this is all the evidence you have?") + player("Please just look at it again...") + npc("Ok, let me look then.") + item(item = "dragon_inn_tankard", text = "You show the tankard to Zavistic, he looks at it again.") + } + set("thzfe_innkeepermugshown", true) + npc("That doesn't mean anything in itself, you could have gotten that from anywhere. Even from the Dragon Inn tavern! There isn't anything to link Brentle Vahn with Sithik Ints.") + } + + private suspend fun Player.showNecromancyBook() { + player("I have this necromancy book as evidence that Sithik is involved with the undead ogres at Jiggig.") + if (get("thzfe_shownnecrobook", false)) { + npc("Yeah, you've shown me this before...if this is all the evidence you have?") + player("Please just look at it again...") + npc("Ok, let me look then.") + } + npc("Ok, so he's researching necromancy...it doesn't mean anything in itself.") + player("Yes, but if you look, you can see that there is a half torn page which matches the page I found at Jiggig.") + set("thzfe_shownnecrobook", true) + npc("Hmm, yes, but someone could have stolen that from him and then gone and cast it without his permission or to try and deliberately implicate him.") + } + + private suspend fun Player.showHamBook() { + player("Look, this book proves that Sithik hates all monsters and most likely Ogres with a passion.") + if (get("thzfe_shownhambook", false)) { + npc("Yeah, you've shown me this before...if this is all the evidence you have?") + player("Please just look at it again...") + npc("Ok, let me look then.") + item(item = "book_of_ham", text = "You show the HAM book to Zavistic, he looks through it again.") + } + set("thzfe_shownhambook", true) + npc("So what, hating monsters isn't a crime in itself...although I suppose that it does give a motive if Sithik was involved. On its own, it's not enough evidence though.") + } + + private suspend fun Player.showSignedPortrait() { + player("This is a portrait of Sithik, signed by the landlord of the Dragon Inn saying that he saw Sithik and Brentle Vahn together.") + if (get("thzfe_shownsignedportrait", false)) { + npc("Yeah, you've shown me this before...if this is all the evidence you have?") + player("Please just look at it again...") + npc("Ok, let me look then.") + item(item = "signed_portrait", text = "You show the signed portrait of Sithik again to Zavistic.") + } + set("thzfe_shownsignedportrait", true) + npc("Hmmm, well that is interesting.") + if (showedAllEvidence()) { + handOverPotion() + } + } + + private fun Player.showedAllEvidence(): Boolean = get("thzfe_shownnecrobook", false) && get("thzfe_shownsignedportrait", false) + + // ===== The big payoff: receive the strange potion ===== + + private suspend fun Player.handOverPotion() { + npc("And I'm starting to think that Sithik may be involved. Here, take this potion and give some to Sithik. It'll bring on a change which should solicit some answers - tell him the effects won't revert until he's told the truth.") + set("zogre_flesh_eaters", "sithik") + inventory.remove("necromancy_book") + inventory.remove("torn_page") + inventory.remove("dragon_inn_tankard") + inventory.remove("zogre_sithik_portrait_signed") + inventory.remove("book_of_ham") + addOrDrop("zogre_ogre_trans_potion") + item("zogre_ogre_trans_potion", "Zavistic hands you a strange looking potion bottle and takes all the evidence you've accumulated so far.") + } + + private suspend fun Player.notYetUsedPotion() { + player("No, not yet, what was I supposed to do again?") + npc("Try to use the potion on Sithik somehow, he should undergo an interesting transformation, though you'll probably want to leave the house in case there are any side effects. Then go back and question Sithik and tell") + npc("him the effects won't wear off until he tells the truth. In fact, that's not exactly true, but I'm sure it'll be an extra incentive to get him to be honest.") + guildMenu() + } + + private suspend fun Player.lostPotion() { + player("Well, actually, I've lost it, could I have another one please?") + npc("Sure, but don't lose it this time.") + addOrDrop("zogre_ogre_trans_potion") + item(item = "zogre_ogre_trans_potion", text = "Zavistic hands you a bottle of strange potion.") + } + + private suspend fun Player.yesUsedPotion() { + player("Yes, I have in fact. I poured it into his tea.") + npc("Ok, that's good, that should work. Pop back in a little while to see Sithik and start questioning him.") + guildMenu() + } +} diff --git a/game/src/main/kotlin/content/area/karamja/ape_atroll/Daga.kt b/game/src/main/kotlin/content/area/karamja/ape_atroll/Daga.kt index 46354660dd..3d443b757d 100644 --- a/game/src/main/kotlin/content/area/karamja/ape_atroll/Daga.kt +++ b/game/src/main/kotlin/content/area/karamja/ape_atroll/Daga.kt @@ -22,52 +22,40 @@ class Daga : Script { init { npcOperate("Talk-to", "daga") { - val amulet = equipped(EquipSlot.Amulet) - - if (amulet.id == "monkeyspeak_amulet") { - - npc("Sorry, you don't have enough space in your inventory.") - - choice { - option("Yes, please.") { - openShop("dagas_scimitar_smithy") - } - option("No, thanks.") { - } - option("Do you have any Dragon Scimitars in stock?") { - npc("It just so happens I recently got a fresh delivery.
Do you want to buy one?") - choice { - option("Yes.") { - player("Yes, please.") - inventory.transaction { - remove("coin", 100_000) - add("dragon_scimitar") - } - when (inventory.transaction.error) { - is TransactionError.Full -> { - inventoryFull() - npc("Sorry, you don't have enough space in your inventory.") - } - - TransactionError.None -> { - npc("There you go. Pleasure doing business with you.") - } - - else -> npc( - "Sorry, you don't have enough coins.
It costs 100,000 gold coins.", - ) - } - - option("No.") { - player("No.") + if (amulet.id != "monkeyspeak_amulet") { + npc("Ook! Ah Uh Ah! Ook Ook! Ah!") + return@npcOperate + } + npc("Sorry, you don't have enough space in your inventory.") + choice { + option("Yes, please.") { + openShop("dagas_scimitar_smithy") + } + option("No, thanks.") + option("Do you have any Dragon Scimitars in stock?") { + npc("It just so happens I recently got a fresh delivery.
Do you want to buy one?") + choice { + option("Yes.") { + player("Yes, please.") + inventory.transaction { + remove("coin", 100_000) + add("dragon_scimitar") + } + when (inventory.transaction.error) { + is TransactionError.Full -> { + inventoryFull() + npc("Sorry, you don't have enough space in your inventory.") } + TransactionError.None -> npc("There you go. Pleasure doing business with you.") + else -> npc("Sorry, you don't have enough coins.
It costs 100,000 gold coins.") + } + option("No.") { + player("No.") } } } } - } else { - npc("Ook! Ah Uh Ah! Ook Ook! Ah!") } } diff --git a/game/src/main/kotlin/content/area/misthalin/paterdomus/Drezel.kt b/game/src/main/kotlin/content/area/misthalin/paterdomus/Drezel.kt index af2b9277c1..60ab4bf306 100644 --- a/game/src/main/kotlin/content/area/misthalin/paterdomus/Drezel.kt +++ b/game/src/main/kotlin/content/area/misthalin/paterdomus/Drezel.kt @@ -628,9 +628,7 @@ class Drezel : Script { when (ghastKills) { 1 -> npc("So you've got two more to kill then!") 2 -> npc("So you've got one more to kill then!") - 3 -> npc( - "So you've killed them all then! Go and tell him, I bet he'll be pleased.", - ) + 3 -> npc("So you've killed them all then! Go and tell him, I bet he'll be pleased.") } } diff --git a/game/src/main/kotlin/content/area/misthalin/varrock/Benny.kt b/game/src/main/kotlin/content/area/misthalin/varrock/Benny.kt index 0a2ad61dbd..5a17cd2a41 100644 --- a/game/src/main/kotlin/content/area/misthalin/varrock/Benny.kt +++ b/game/src/main/kotlin/content/area/misthalin/varrock/Benny.kt @@ -49,17 +49,12 @@ class Benny : Script { purchase() } option("Varrock Herald? Never heard of it.") { - npc( - "For the illiterate amongst us, I shall elucidate. The Varrock Herald is a new newspaper. It is edited, printed and published by myself, Benny Gutenberg, and each edition promises to enthrall the reader with ", - ) + npc("For the illiterate amongst us, I shall elucidate. The Varrock Herald is a new newspaper. It is edited, printed and published by myself, Benny Gutenberg, and each edition promises to enthrall the reader with") npc("captivating material! Now, can I interest you in buying one for a mere 50 coins?") purchase() } option("Anything interesting in there?") { - npc( - - "Of course there is, mate. Packed full of thought provoking insights, contentious interviews and celebrity scandalmongering! An excellent read and all for just 50 coins! Want one?", - ) + npc("Of course there is, mate. Packed full of thought provoking insights, contentious interviews and celebrity scandalmongering! An excellent read and all for just 50 coins! Want one?") purchase() } } diff --git a/game/src/main/kotlin/content/area/misthalin/varrock/Dealga.kt b/game/src/main/kotlin/content/area/misthalin/varrock/Dealga.kt index b4507bbef8..8176cbcfeb 100644 --- a/game/src/main/kotlin/content/area/misthalin/varrock/Dealga.kt +++ b/game/src/main/kotlin/content/area/misthalin/varrock/Dealga.kt @@ -36,15 +36,11 @@ class Dealga : Script { } } option("Who are you?") { - npc( - "The name's Dealga, I shipped over from Ape Atoll a while back when I heard that these 'humans' pay good money for certain wares! Thought I'd come over here, and much like the dragon scimitar... make a killing!", - ) + npc("The name's Dealga, I shipped over from Ape Atoll a while back when I heard that these 'humans' pay good money for certain wares! Thought I'd come over here, and much like the dragon scimitar... make a killing!") npc("Now, what can I do for you? A nice, keen edged dragon scimitar?") } option("What are you doing here?") { - npc( - "Like the keen edged dragon scimitar I'm slashing away the competition! If you hairless apes won't come to Ape Atoll, then I'll come to you! I'll soon be overtaking Daga in profitability!", - ) + npc("Like the keen edged dragon scimitar I'm slashing away the competition! If you hairless apes won't come to Ape Atoll, then I'll come to you! I'll soon be overtaking Daga in profitability!") npc("Now, what can I do for you? A nice, keen edged dragon scimitar?") } } diff --git a/game/src/main/kotlin/content/area/misthalin/varrock/palace/KingRoald.kt b/game/src/main/kotlin/content/area/misthalin/varrock/palace/KingRoald.kt index 58ad8f60be..56b2f0189c 100644 --- a/game/src/main/kotlin/content/area/misthalin/varrock/palace/KingRoald.kt +++ b/game/src/main/kotlin/content/area/misthalin/varrock/palace/KingRoald.kt @@ -191,9 +191,7 @@ class KingRoald : Script { } private suspend fun Player.claimReward(certificate: String) { - player( - "Your majesty, I have come to claim the reward for the return of the Shield Of Arrav.", - ) + player("Your majesty, I have come to claim the reward for the return of the Shield Of Arrav.") item("certificate", "You show the certificate to the king.") if (certificate == "certificate_full") { npc("My goodness! This claim is for the reward offered by my father many years ago!") diff --git a/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/FillimanTarlock.kt b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/FillimanTarlock.kt index d8104ea84f..cdb7cf5fbc 100644 --- a/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/FillimanTarlock.kt +++ b/game/src/main/kotlin/content/area/morytania/mort_myre_swamp/FillimanTarlock.kt @@ -121,10 +121,7 @@ class FillimanTarlock : Script { stage == 105 -> { npc("Hello again my friend, have you defeated three Ghasts as I asked you?") player("Yes, I've killed all three and their spirits have been released!") - npc( - "Many thanks my friend, you have completed your quest! I can now change " + - "this place into a holy sanctuary! And forever will it now be an Altar of Nature!", - ) + npc("Many thanks my friend, you have completed your quest! I can now change this place into a holy sanctuary! And forever will it now be an Altar of Nature!") sendNatureSpiritReward() } stage >= 110 -> { diff --git a/game/src/main/kotlin/content/entity/effect/toxin/Disease.kt b/game/src/main/kotlin/content/entity/effect/toxin/Disease.kt index bbb07317da..aad74ebe77 100644 --- a/game/src/main/kotlin/content/entity/effect/toxin/Disease.kt +++ b/game/src/main/kotlin/content/entity/effect/toxin/Disease.kt @@ -4,19 +4,26 @@ import content.entity.combat.hit.directHit import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.entity.character.Character +import world.gregs.voidps.engine.entity.character.flagHits import world.gregs.voidps.engine.entity.character.npc.NPC import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.equip.equipped +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.sound import world.gregs.voidps.engine.timer.* +import world.gregs.voidps.network.login.protocol.visual.update.HitSplat import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot +import world.gregs.voidps.type.random import java.util.concurrent.TimeUnit import kotlin.math.sign -val Character.diseased: Boolean get() = diseaseCounter > 0 +private const val DISEASE_CYCLE = 30 // 18 seconds -val Character.antiDisease: Boolean get() = diseaseCounter < 0 +val Character.diseased: Boolean get() = diseaseDamage > 0 -var Character.diseaseCounter: Int +val Character.antiDisease: Boolean get() = diseaseDamage < 0 + +var Character.diseaseDamage: Int get() = if (this is Player) get("disease", 0) else this["disease", 0] set(value) = if (this is Player) { set("disease", value) @@ -31,22 +38,20 @@ fun Character.cureDisease(): Boolean { } fun Character.disease(target: Character, damage: Int) { - if (target.antiDisease || damage < target["disease_damage", 0]) { + if (target.antiDisease || damage < target.diseaseDamage) { return } val timers = if (target is Player) target.timers else target.softTimers if (timers.contains("disease") || timers.start("disease")) { - target.diseaseCounter = TimeUnit.SECONDS.toTicks(18) / 30 - target["disease_damage"] = damage target["disease_source"] = this + target.diseaseDamage = damage } } fun Player.antiDisease(minutes: Int) = antiDisease(minutes, TimeUnit.MINUTES) fun Player.antiDisease(duration: Int, timeUnit: TimeUnit) { - diseaseCounter = -(timeUnit.toTicks(duration) / 30) - clear("disease_damage") + diseaseDamage = -((timeUnit.toTicks(duration) / DISEASE_CYCLE)) clear("disease_source") timers.startIfAbsent("disease") } @@ -55,13 +60,13 @@ class Disease : Script { init { playerSpawn { - if (diseaseCounter != 0) { + if (diseaseDamage != 0) { timers.restart("disease") } } npcSpawn { - if (diseaseCounter != 0) { + if (diseaseDamage != 0) { softTimers.restart("disease") } } @@ -78,48 +83,81 @@ class Disease : Script { if (immune(character)) { return Timer.CANCEL } - if (!restart && character.diseaseCounter == 0) { + if (!restart && character.diseaseDamage == 0) { (character as? Player)?.message("You have been diseased.") - damage(character) } - return 30 + return DISEASE_CYCLE } fun tick(character: Character): Int { val diseased = character.diseased - character.diseaseCounter -= character.diseaseCounter.sign + val damage = character.diseaseDamage + character.diseaseDamage -= character.diseaseDamage.sign when { - character.diseaseCounter == 0 -> { + character.diseaseDamage == 0 -> { if (!diseased) { (character as? Player)?.message("Your disease resistance has worn off.") } return Timer.CANCEL } - character.diseaseCounter == -1 -> (character as? Player)?.message("Your disease resistance is about to wear off.") - diseased -> damage(character) + character.diseaseDamage == -1 -> (character as? Player)?.message("Your disease resistance is about to wear off.") + diseased -> damage(character, damage) } return Timer.CONTINUE } fun stop(character: Character, logout: Boolean) { - character.diseaseCounter = 0 - character.clear("disease_damage") + character.diseaseDamage = 0 character.clear("disease_source") } - fun immune(character: Character) = character is NPC && - character.def["immune_disease", false] || - character is Player && - character.equipped(EquipSlot.Hands).id == "inoculation_brace" + fun immune(character: Character) = character is NPC && character.def["immune_disease", false] - fun damage(character: Character) { - val damage = character["disease_damage", 0] - if (damage <= 10) { - character.cureDisease() + fun damage(character: Character, damage: Int) { + val source = character["disease_source", character] + character.sound("disease_hitsplat") + if (character is Player && character.equipped(EquipSlot.Hands).id == "inoculation_brace") { return } - character["disease_damage"] = damage - 2 - val source = character["disease_source", character] - character.directHit(source, damage, "disease") + if (character is Player) { + val skill = DRAINABLE_SKILLS[random.nextInt(DRAINABLE_SKILLS.size)] + val current = character.levels.get(skill) + if (current <= 1) { + // No skill level left to drain — bite Hitpoints instead. + character.directHit(source, damage * 10, "disease") + } else { + character.levels.drain(skill, damage) + showDiseaseSplat(character, source, damage) + } + } else { + character.directHit(source, damage, "disease") + } + } + + /** + * Adds a yellow disease hitsplat showing [amount] without deducting Constitution + * (used when disease drained a stat instead of HP). + */ + private fun showDiseaseSplat(target: Character, source: Character, amount: Int) { + val hp = target.levels.get(Skill.Constitution) + val percentage = target.levels.getPercent(Skill.Constitution, hp, 255.0).toInt() + target.visuals.hits.add( + HitSplat( + amount, + HitSplat.Mark.Diseased, + percentage, + 0, + false, + if (source is NPC) -source.index else source.index, + -1, + ), + ) + target.flagHits() + } + + companion object { + private val DRAINABLE_SKILLS: Array = Skill.entries + .filter { it != Skill.Constitution && it != Skill.Prayer } + .toTypedArray() } } diff --git a/game/src/main/kotlin/content/entity/effect/toxin/Poison.kt b/game/src/main/kotlin/content/entity/effect/toxin/Poison.kt index 9822b82713..cf7d179a7d 100644 --- a/game/src/main/kotlin/content/entity/effect/toxin/Poison.kt +++ b/game/src/main/kotlin/content/entity/effect/toxin/Poison.kt @@ -83,26 +83,43 @@ class Poison : Script { npcTimerStop("poison", ::stop) interfaceOption("Use Cure", "health_orb:poison") { - for (type in listOf("antipoison", "super_antipoison", "antipoison+")) { - val index = inventory.items.indexOfFirst { it.id.startsWith(type) } + if (poisoned) { + for (type in listOf("antipoison", "super_antipoison", "antipoison+")) { + if (drink(type)) { + return@interfaceOption + } + } + val index = inventory.indexOf("prayer_book") if (index != -1) { - val option = "Drink" + val option = "Recite-prayer" val item = inventory[index] InterfaceApi.option(this, InterfaceOption(item, index, option, item.def.options.indexOf(option), "inventory:inventory")) return@interfaceOption } + message("You don't have anything to cure the poison.") } - val index = inventory.indexOf("prayer_book") - if (index != -1) { - val option = "Recite-prayer" - val item = inventory[index] - InterfaceApi.option(this, InterfaceOption(item, index, option, item.def.options.indexOf(option), "inventory:inventory")) - return@interfaceOption + if (diseased) { + for (type in listOf("relicyms_balm", "sanfew_serum")) { + if (drink(type)) { + return@interfaceOption + } + } + message("You don't have anything to cure the disease.") } - message("You don't have anything to cure the poison.") } } + private suspend fun Player.drink(type: String): Boolean { + val index = inventory.items.indexOfFirst { it.id.startsWith(type) } + if (index != -1) { + val option = "Drink" + val item = inventory[index] + InterfaceApi.option(this, InterfaceOption(item, index, option, item.def.options.indexOf(option), "inventory:inventory")) + return true + } + return false + } + fun start(character: Character, restart: Boolean): Int { if (character.poisonImmune) { return Timer.CANCEL diff --git a/game/src/main/kotlin/content/entity/npc/combat/Attack.kt b/game/src/main/kotlin/content/entity/npc/combat/Attack.kt index b481e588d4..68c893de44 100644 --- a/game/src/main/kotlin/content/entity/npc/combat/Attack.kt +++ b/game/src/main/kotlin/content/entity/npc/combat/Attack.kt @@ -47,7 +47,7 @@ class Attack( val def = def(primaryTarget) def["combat_def", get("transform_id", def.stringId)] } else { - def["combat_def", get("transform_id", id)] + get("transform_id", id) } val definition = definitions.getOrNull(defId) ?: return@npcCombatSwing if (definition.attacks.isEmpty()) { @@ -110,12 +110,11 @@ class Attack( offense = listOf("crush", "range", "magic").random(random) defence = offense } - val spell = if (offense == "magic" || defence == "magic") attack.id else "" val damage = if (hit.max == 0) { - hit(target = target, delay = delay, offensiveType = offense, defensiveType = defence, special = hit.special, spell = spell) + hit(target = target, delay = delay, offensiveType = offense, defensiveType = defence, special = hit.special, spell = attack.id) // Reuse spell for attack name } else { val damage = Damage.roll(source = this, target = target, offensiveType = offense, weapon = Item.EMPTY, special = hit.special, defensiveType = defence, range = hit.min..hit.max, skipAccuracyRoll = !hit.accuracyRoll) - hit(target = target, delay = delay, offensiveType = offense, defensiveType = defence, special = hit.special, damage = damage, spell = spell) + hit(target = target, delay = delay, offensiveType = offense, defensiveType = defence, special = hit.special, damage = damage, spell = attack.id) } if (damage > 0) { miss = false diff --git a/game/src/main/kotlin/content/entity/player/combat/Attack.kt b/game/src/main/kotlin/content/entity/player/combat/Attack.kt index 78f248c3ac..c06d7bada1 100644 --- a/game/src/main/kotlin/content/entity/player/combat/Attack.kt +++ b/game/src/main/kotlin/content/entity/player/combat/Attack.kt @@ -8,7 +8,6 @@ import content.skill.melee.weapon.fightStyle import net.pearx.kasechange.toTitleCase import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message -import world.gregs.voidps.engine.data.definition.Rows import world.gregs.voidps.engine.entity.character.Character import world.gregs.voidps.engine.entity.character.mode.EmptyMode import world.gregs.voidps.engine.entity.character.mode.combat.CombatMovement @@ -86,21 +85,12 @@ class Attack : Script { onNPCApproach("*_spellbook:*") { val (target, id) = it - val component = id.substringAfter(":") - val row = Rows.getOrNull("spells.$component") ?: return@onNPCApproach - val message = row.stringOrNull("npc_message") - if (message != null) { - if (message.isNotEmpty()) { - message(message) - } - return@onNPCApproach - } if (!has(Skill.Slayer, target.def["slayer_level", 0])) { message("You need a higher slayer level to know how to wound this monster.") return@onNPCApproach } approachRange(8, update = false) - spell = component + spell = id.substringAfter(":") if (target.id.endsWith("_dummy") && !handleCombatDummies(target)) { clear("spell") return@onNPCApproach @@ -114,15 +104,6 @@ class Attack : Script { onPlayerApproach("*_spellbook:*") { val (target, id) = it - val component = id.substringAfter(":") - val row = Rows.getOrNull("spells.$component") ?: return@onPlayerApproach - val message = row.stringOrNull("player_message") - if (message != null) { - if (message.isNotEmpty()) { - message(message) - } - return@onPlayerApproach - } approachRange(8, update = false) spell = id.substringAfter(":") set("attack_speed", 5) diff --git a/game/src/main/kotlin/content/entity/player/inv/item/ItemOnItems.kt b/game/src/main/kotlin/content/entity/player/inv/item/ItemOnItems.kt index 1521c0e3ff..b9168d8ce0 100644 --- a/game/src/main/kotlin/content/entity/player/inv/item/ItemOnItems.kt +++ b/game/src/main/kotlin/content/entity/player/inv/item/ItemOnItems.kt @@ -176,7 +176,9 @@ class ItemOnItems(val itemOnItemDefs: ItemOnItemDefinitions) : Script { for (item in def.requires) { if (!inventory.contains(item.id, item.amount)) { error = TransactionError.Deficient(item.amount) - return "You need a ${item.def.name.lowercase()} to ${def.type} this." + return def.requiresMessage.ifEmpty { + "You need a ${item.def.name.lowercase()} to ${def.type} this." + } } } for (item in def.remove) { diff --git a/game/src/main/kotlin/content/quest/Quest.kt b/game/src/main/kotlin/content/quest/Quest.kt index 30eb3bb988..37efca496b 100644 --- a/game/src/main/kotlin/content/quest/Quest.kt +++ b/game/src/main/kotlin/content/quest/Quest.kt @@ -28,6 +28,7 @@ val quests = setOf( "priest_in_peril", "lost_city", "tears_of_guthix", + "zogre_flesh_eaters", // mini-quests "enter_the_abyss", ) diff --git a/game/src/main/kotlin/content/quest/member/ogre/ZogreFleshEaters.kt b/game/src/main/kotlin/content/quest/member/ogre/ZogreFleshEaters.kt new file mode 100644 index 0000000000..2c42c44e8a --- /dev/null +++ b/game/src/main/kotlin/content/quest/member/ogre/ZogreFleshEaters.kt @@ -0,0 +1,699 @@ +package content.quest.member.ogre + +import content.entity.combat.killer +import content.entity.gfx.areaGfx +import content.entity.obj.door.enterDoor +import content.entity.player.bank.ownsItem +import content.entity.player.dialogue.Angry +import content.entity.player.dialogue.Mad +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Sad +import content.entity.player.dialogue.type.item +import content.entity.player.dialogue.type.items +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import content.entity.player.inv.item.addOrDrop +import content.quest.quest +import content.quest.questCompleted +import content.quest.questJournal +import content.quest.questStage +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.clearCamera +import world.gregs.voidps.engine.client.instruction.handle.interactPlayer +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.client.moveCamera +import world.gregs.voidps.engine.client.turnCamera +import world.gregs.voidps.engine.client.ui.open +import world.gregs.voidps.engine.data.Settings.Companion.getOrNull +import world.gregs.voidps.engine.entity.character.areaSound +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.Players +import world.gregs.voidps.engine.entity.character.player.Teleport +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level.has +import world.gregs.voidps.engine.entity.character.sound +import world.gregs.voidps.engine.entity.obj.GameObject +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.engine.queue.queue +import world.gregs.voidps.type.Direction +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.random + +class ZogreFleshEaters : Script { + + init { + + // ===== Quest journal ===== + + questJournalOpen("zogre_flesh_eaters") { + val lines = when (quest("zogre_flesh_eaters")) { + "unstarted" -> notStartedJournal() + "completed" -> completedJournal() + else -> startedJournal(questStage("zogre_flesh_eaters")) + } + questJournal("Zogre Flesh Eaters", lines) + } + + // ===== Movement-triggered cutscene ===== + // The blackened/charred area cutscene plays when the player first walks into the right tile. + entered("zogre_blackened_area") { + if (get("thzfe_cut_scene", false)) { + return@entered + } + queue("zogre_blackened_cutscene") { playBlackenedCutscene() } + } + + // ===== Climb over the smashed barricade ===== + objectOperate("Climb-over", "ogre_barricade_collapsed*") { (target) -> + val enter = tile.x < target.tile.x + val direction = if (enter) Direction.EAST else Direction.WEST + anim("regicide_stepover") + exactMoveDelay( + target = tile.copy(x = tile.x + if (enter) 2 else -2), + delay = 30, + direction = direction, + ) + sound("bonewalk") + delay(2) + } + + // ===== Sithik's tea cannot be picked up — he scolds the player ===== + takeable("cup_of_tea_zogre_flesh_eaters") { _, telegrab -> + val sithik = if (get("thzfe_sithik_transformed", false)) "zogre_sithik_ogre" else "zogre_sithik_man" + if (telegrab) { + npc(sithik, "Hey! What do you think you're doing? Don't go casting that kind of spell anywhere near my tea! Leave my tea alone you telegrabbing fiend!") + } else { + npc(sithik, "Hey! What do you think you're doing? Leave my tea alone!") + } + null + } + + // ===== Entrance stairs (down into Jiggig caves) ===== + objectOperate("Climb-down", "ogre_stairs_down") { (target) -> + message("You climb down the steps.") + sound("down_stone_stairs") + open("fade_out") + delay(3) + if (target.rotation == 1) { + tele(2442, 9418, 0) + } else { + tele(2477, 9437, 2) + } + delay(1) + open("fade_in") + delay(3) + } + + // ===== Exit stairs (back up out of Jiggig caves) ===== + objectOperate("Climb-up", "ogre_stairs") { (target) -> + message("You climb up the steps.") + sound("up_stone_stairs") + open("fade_out") + delay(3) + if (target.tile.x == 2443 && target.tile.y == 9417) { + tele(2446, 9417, 2) + } else { + tele(2485, 3045, 0) + } + delay(1) + open("fade_in") + delay(3) + } + + // ===== Lectern in the tomb (search for torn page) ===== + objectOperate("Search", "zogre_lecturn") { + if (questStage("zogre_flesh_eaters") >= 4) { + return@objectOperate message("You search the lectern, but find nothing.") + } + message("You search the broken down lectern.") + anim("human_pickupfloor") + delay(2) + if (ownsItem("torn_page")) { + message("You find nothing here this time.") + return@objectOperate + } + addOrDrop("torn_page") + sound("pick") + item(item = "torn_page", text = "You find a half torn page...it has spidery writing all over it.") + } + + // ===== Skeleton corpse (spawns a zombie, then yields the backpack) ===== + objectOperate("Search", "zogre_brentle_skeleton") { + val skeletonState = get("thzfe_brentle_skele", 0) + if (skeletonState == 2) { + if (questStage("zogre_flesh_eaters") >= 4 || + inventory.contains("ruined_backpack") || + inventory.contains("dragon_inn_tankard") + ) { + return@objectOperate message("You find nothing on the corpse.") + } + addOrDrop("ruined_backpack") + item(item = "ruined_backpack", text = "You find a backpack on the corpse.") + return@objectOperate + } + + if (NPCs.findOrNull(tile.regionLevel, "zogre_human_brentle_vahn") != null) { + return@objectOperate message("You're in mortal danger, you don't have time to search!") + } + + areaGfx("smokepuff_large", Tile(2442, 9458, 2)) + delay(1) + set("thzfe_brentle_skele", 1) + message("Something screams into life right in front of you.") + sound("disease_hitsplat") // 2388 + + val zombie = NPCs.add(id = "zogre_human_brentle_vahn", tile = Tile(2442, 9458, 2), ticks = 1000, owner = this) + zombie.interactPlayer(this, "Attack") + } + + npcDespawn("zogre_human_brentle_vahn") { + areaGfx("smokepuff_large", tile) + val name: String = getOrNull("owner") ?: return@npcDespawn + val owner = Players.find(name) ?: return@npcDespawn + owner.queue("brentle_zombie_wanders") { + statement("This mindless zombie loses interest in fighting you and wanders off.") + } + } + + // ===== Knife on coffin (force the lock) ===== + itemOnObjectOperate("knife", "zogre_coffin_special") { + if (get("thzfe_prismsearch", 0) != 1) { + return@itemOnObjectOperate message("Nothing interesting happens.") + } + set("thzfe_prismsearch", 2) + sound("unlock") + item("knife", "With some skill you manage to slide the blade along the lock edge and click into place the teeth of the primitive mechanism.") + } + + // ===== Locked ogre coffin (multi-stage search) ===== + objectOperate("Search", "zogre_coffin_special*") { + when (val value = get("thzfe_prismsearch", 0)) { + 0, 1 -> { + statement("You search the coffin and find a small geometrically shaped hole in the side. It looks as if this hole was made with a considerable amount of force, maybe the thing which made the hole is still inside?") + if (value == 0) { + set("thzfe_prismsearch", 1) + statement("The lock looks quite crude, with some skill and a slender blade, you may be able to force it.") + } + } + 2 -> liftCoffinLid() + 3 -> { + if (inventory.contains("black_prism")) { + return@objectOperate message("You find nothing inside this time.") + } + if (!inventory.add("black_prism")) { + return@objectOperate statement("You see something inside, but you have no space in your inventory to store the item.") + } + item(item = "black_prism", text = "You find a creepy looking black prism inside.") + } + } + } + + // ===== Zombie NPC (just a scream) ===== + npcOperate("Talk-to", "zogre_zombie") { (target) -> + target.say("Raaarrrggghhh") + } + + npcOperate("Talk-to", "pilg") { (target) -> + npc("Dey got me in da belly, mees gutsies feel like had a dead dead dog dinner.") + } + + npcOperate("Talk-to", "grug") { (target) -> + npc("Ukk...I's dun fer...me's don't feel legsies anymore!") + } + + // ===== Item examine: ruined backpack (open it) ===== + itemOption("Open", "ruined_backpack") { + if (inventory.spaces < 3) { + return@itemOption message("You don't have enough room in your inventory for the contents of this bag.") + } + item("ruined_backpack", "Just before you open the backpack, you notice a small leather patch with the moniker: 'B.Vahn', on it.") + inventory.remove("ruined_backpack") + addOrDrop("dragon_inn_tankard") + addOrDrop("rotten_food") + addOrDrop("knife") + message("You find a knife and some rotten food.") + message("You find an interesting looking tankard.") + item(item = "dragon_inn_tankard", text = "You find an interesting looking tankard.") + items("knife", "rotten_food", "You find a knife and some rotten food, the backpack is ripped to shreds.") + } + + // ===== Item examine handlers (read books, examine items) ===== + + itemOption("Read", "torn_page") { + statement("You don't manage to understand all of it as there is only a half page here. But it seems the spell was used to place a curse on an area and for all time raise the dead.") + statement("If you look very carefully, you see what looks like a guild emblem.") + } + + itemOption("Look-at", "black_prism") { + item("black_prism", "It looks like a smokey black gem of some sort...very creepy. Some magical force must have prevented it from being shattered when it hit the coffin.") + } + + itemOption("Look-at", "dragon_inn_tankard") { + item("dragon_inn_tankard", "A stout ceramic tankard with a Dragon Emblem on the side, the words, 'Ye Olde Dragon Inn' are inscribed in the bottom.") + } + + itemOption("Look-at", "zogre_sithik_portrait_signed") { + item("signed_portrait", "You see an image of Sithik with a message underneath 'I, the bartender of the Dragon Inn, do swear that this is a true likeness of the wizzy who was talking to Brentle Vahn, my customer the other day.'") + } + + itemOption("Read", "necromancy_book") { + item("necromancy_book", "This book uses very strange language and some incomprehensible symbols. It has a very dark feeling to it. As you're looking through the book, you notice that one of the pages has been torn and half of it is missing.") + } + + itemOption("Read", "book_of_portraiture") { + item("book_of_portraiture", "All interested artisans should really consider taking up the hobby of portraiture. To do so, one uses a piece of papyrus on the intended subject to initiate a likeness drawing activity.") + } + + itemOption("Read", "book_of_ham") { + statement("You read this book for a while, it seems to be some sort of political manifesto about how the king doesn't do enough to safeguard the citizens of the realm from the monsters that still thrive within the borders. It sends out a rallying cry to all people who would want to stop monsters, to join the HAM movement.") + player("Hmmm, Sithik must really hate monsters then, I wonder if he hates ogres in particular?") + } + + // ===== Strange potion on Sithik's tea (ground item interaction) ===== + itemOnFloorItemOperate("zogre_ogre_trans_potion", "cup_of_tea_zogre_flesh_eaters") { + arriveDelay() + if (quest("zogre_flesh_eaters") != "sithik") { + return@itemOnFloorItemOperate message("Nothing interesting happens.") + } + set("zogre_flesh_eaters", "potion") + anim("human_pickuptable") + sound("drip_poison") + inventory.remove("zogre_ogre_trans_potion") + addOrDrop("sample_bottle") + delay(2) + item("zogre_ogre_trans_potion", "You pour some of the potion into the cup. Zavistic said it may take some time to have an effect.") + } + + objTeleportTakeOff("Climb-up", "basic_ladder_bottom") { obj, _ -> + if (obj.tile == Tile(2597, 3107, 0)) { + if (quest("zogre_flesh_eaters") == "potion" && get("thzfe_sithik_transformed", 0) == 0) { + set("thzfe_sithik_transformed", 1) + } + } + + Teleport.CONTINUE + } + + // ===== Ogre tomb doors (locked, need ogre gate key) ===== + objectOperate("Open", "ogre_cavedoor*") { (target) -> + enterOgreCaveDoor(target) + } + + // ===== Plinth in the tomb (Slash Bash spawn / artefact retrieval) ===== + objectOperate("Search", "zogre_stand") { (target) -> + if (NPCs.at(tile.regionLevel).any { it.id == "slash_bash" && it["owner", ""] == accountName }) { + return@objectOperate message("You're in mortal danger, you don't have time to search!") + } + + statement("You search the plinth...") + val progress = quest("zogre_flesh_eaters") + when { + progress == "completed" || inventory.contains("ogre_artefact") -> { + message("You find nothing in particular.") + } + progress == "killed_slash_bash" -> { + areaGfx("smokepuff_large", target.tile) + areaSound("smokepuff", target.tile) + addOrDrop("ogre_artefact") + item("ogre_artefact", "An ogre artefact appears in front of you. You quickly put it into your backpack.") + } + else -> { + message("Something stirs behind you!") + areaGfx("smokepuff_large", Tile(2477, 9444, 0)) + areaSound("smokepuff", Tile(2477, 9444, 0)) + NPCs.add( + id = "slash_bash", + tile = Tile(2477, 9444, 0), + ticks = 1000, + owner = this, + ) + } + } + } + + npcDeath("slash_bash") { + val killer = killer as? Player ?: return@npcDeath + if (killer.quest("zogre_flesh_eaters") == "given_key") { + killer["zogre_flesh_eaters"] = "killed_slash_bash" + } + } + + npcDeath("zogre_human_brentle_vahn") { + val killer = killer as? Player ?: return@npcDeath + areaGfx("smokepuff_large", tile) + killer["thzfe_brentle_skele"] = 2 + } + } + + // ===== Ogre tomb double doors ===== + + private suspend fun Player.enterOgreCaveDoor(target: GameObject) { + val enter = tile.y >= target.tile.y + if (enter && !inventory.contains("ogre_gate_key")) { + message("These gates are locked, you don't seem to be able to open them.") + return + } + message(if (enter) "You use the Ogre Tomb Key to unlock the door." else "You push the gates open.") + sound("strangedoor_open") + enterDoor(target) + } + + // ===== Cutscene: blackened, charred area ===== + + private suspend fun Player.playBlackenedCutscene() { + steps.clear() + set("thzfe_cut_scene", true) + message("You enter this blackened, charred area - it looks like there's been an explosion!") + moveCamera(tile = Tile(2445, 9460), height = 400, speed = 4, acceleration = 4) + turnCamera(tile = Tile(2441, 9459), height = 25, speed = 15, acceleration = 15) + statement( + "You enter this blackened, charred area - it looks like some sort of explosion has taken place.", + clickToContinue = false, + ) + delay(3) + + moveCamera(tile = Tile(2444, 9458), height = 400, speed = 4, acceleration = 4) + turnCamera(tile = Tile(2441, 9459), height = 25, speed = 15, acceleration = 15) + delay(3) + + moveCamera(tile = Tile(2442, 9457), height = 400, speed = 4, acceleration = 4) + turnCamera(tile = Tile(2442, 9459), height = 25, speed = 5, acceleration = 5) + delay(2) + + moveCamera(tile = Tile(2440, 9458), height = 400, speed = 4, acceleration = 4) + turnCamera(tile = Tile(2442, 9459), height = 25, speed = 10, acceleration = 10) + delay(2) + + moveCamera(tile = Tile(2440, 9460), height = 400, speed = 4, acceleration = 4) + turnCamera(tile = Tile(2442, 9459), height = 25, speed = 15, acceleration = 15) + delay(2) + + moveCamera(tile = Tile(2442, 9461), height = 400, speed = 4, acceleration = 4) + turnCamera(tile = Tile(2442, 9459), height = 25, speed = 10, acceleration = 10) + delay(2) + + moveCamera(tile = Tile(2444, 9460), height = 400, speed = 4, acceleration = 4) + turnCamera(tile = Tile(2437, 9459), height = 25, speed = 10, acceleration = 10) + delay(2) + + moveCamera(tile = Tile(2444, 9458), height = 400, speed = 4, acceleration = 4) + turnCamera(tile = Tile(2437, 9459), height = 25, speed = 15, acceleration = 15) + delay(1) + + statement("You enter this blackened, charred area - it looks like some sort of explosion has taken place.") + clearCamera() + } + + // ===== Coffin lid lifting (the "Urrrgggg" sequence) ===== + + private suspend fun Player.liftCoffinLid() { + if (inventory.isFull()) { + statement("You start to lift the lid and see something inside, but you have no space in your inventory to store the item.") + return + } + statement("The lid looks heavy, but now that you've unlocked it, you may be able to lift it. You prepare yourself.") + say("Urrrgggg.") + player("Urrrgggg.", clickToContinue = false) + delay(3) + say("Aarrrgghhh!") + player("Aarrrgghhh!", clickToContinue = false) + delay(3) + if (random.nextBoolean()) { + levels.drain(Skill.Strength, 2) + statement("You struggle, but just get weakened from your experience. Perhaps you should try again after you've recovered from the effort?") + } else { + set("thzfe_prismsearch", 3) + sound("coffin_open") + say("Raarrrggggg! Yes!") + player("Raarrrggggg! Yes!", clickToContinue = false) + delay(2) + statement("You eventually manage to lift the lid.") + } + } + + // ===== Journal ===== + + private fun Player.notStartedJournal(): List { + fun req(met: Boolean, text: String) = if (met) "$text" else "$text" + return listOf( + "I can start this quest by talking to Grish at the Ugrish", + "ceremonial dance place called Jiggig.", + "To start this quest I should complete these quests:-", + req(questCompleted("jungle_potion"), "Jungle Potion."), + req(questCompleted("big_chompy_bird_hunting"), "Big Chompy Bird Hunting."), + "It would help if I had the following skill levels:-", + req(has(Skill.Ranged, 30), "Ranged level: 30"), + req(has(Skill.Fletching, 30), "Fletching level: 30"), + req(has(Skill.Smithing, 4), "Smithing level: 4"), + req(has(Skill.Herblore, 4), "Herblore level: 4"), + "Must be able to defeat a level 111 foe.", + ) + } + + private fun completedJournal(): List = listOf( + "I talked to an ogre called Grish who asked me to look into", + "the problem. After some searching around in a tomb, I", + "found some clues which pointed me to the human", + "habitation of Yannile.", + "With the help of Zavistic Rarve, the grand secretary of", + "the Wizards guild I was able to piece the clues together", + "and discover that a Wizard named 'Sithik Ints' was", + "responsible.", + "Unfortunately I couldn't remove the curse from the area,", + "however, I was able to return some important artefacts to", + "Grish, who can now set up a new ceremonial dance area for", + "the ogres of Gu' Tanoth.", + "Sithik Ints also told me how to make Brutal arrows which are", + "more effective against Zogres, and he also told me how to", + "make a disease balm.", + "", + "QUEST COMPLETE!", + ) + + private fun Player.startedJournal(stage: Int): List { + val list = mutableListOf() + val search = get("thzfe_prismsearch", 0) + + // ===== Stage 2+ — initial Grish conversation ===== + if (stage < 3) { + list += "I started this quest by talking to Grish, he asked me to" + list += "check out the underground area where some Zombie ogres" + list += "(Zogres) were coming from." + list += "" + list += "I have to find a way into the ceremonial dance area and" + list += "then underground" + } else { + list += "I started this quest by talking to Grish, he asked me to" + list += "check out the underground area where some Zombie ogres" + list += "(Zogres) were coming from." + list += "I have to find a way into the ceremonial dance area and" + list += "then underground." + } + + // ===== Stage 3+ — past the barricade ===== + if (stage >= 3) { + list += "I persuaded a guard to let me past, I only had to mention" + list += "Grish's name and the guard smashed the barricade down. I" + list += "can enter now." + + // Sub-stages tracked by the sithik_intro varbit + if (search >= 4 || stage >= 4) { + list += "The guard has smashed the barricade down. I can enter" + list += "now. I need to find out what happened here" + } else if (search >= 1) { + list += "I need to find out what happened here." + } + + if (search >= 2) { + list += "I have searched a coffin, it has a funny looking hole at the" + list += "side." + } + if (search >= 3) { + list += "I have forced the lock on a coffin, maybe I can open it" + list += "now?" + } + if (search >= 4) { + list += "I've opened the coffin and retrieved a black prism, this" + list += "may be useful." + list += "I found a half torn page from a necromantic spell book," + list += "maybe this is a clue?" + } + if (search >= 5) { + list += "I have shown the prism to the grand secretary of the" + list += "wizards guild." + } + + // Tankard sub-thread (only at quest progress 3) + if (inventory.contains("dragon_inn_tankard") && stage == 3) { + if (get("thzfe_showntankard", false)) { + list += "I killed a human zombie which dropped a backpack. The" + list += "backpack had the name 'B. Vahn' on it, inside the backpack" + list += "I found a tankard." + if (inventory.contains("signed_portrait")) { + list += "The Dragon Inn Innkeeper says the tankard belongs to one" + list += "of his locals called Brentle Vahn. He was seen talking to a" + list += "wizard the other day." + } else { + list += "The 'Dragon Inn' Innkeeper says the tankard belongs to" + list += "one of his locals called Brentle Vahn. He was seen talking" + list += "to a wizard the other day." + } + } else { + list += "I killed a human zombie which dropped a backpack. The" + list += "backpack had the name 'B. Vahn' on it, inside the backpack" + list += "I found a tankard." + } + } + } + + // Current-state hint based on varbit (overrides earlier versions for the live state) + if (stage == 3 && search < 4) { + when (search) { + 0 -> list += "I need to find out what happened here." + 1 -> { + list += "I have searched a coffin, it has a funny looking hole at the" + list += "side." + } + 2 -> { + list += "I have forced the lock on a coffin, maybe I can open it" + list += "now?" + } + 3 -> { + if (inventory.contains("black_prism")) { + list += "I've opened the coffin and retrieved a black prism, this" + list += "may be useful." + } else { + list += "I've managed to lift the lid on the coffin, it was quite" + list += "heavy! Maybe there's something inside the coffin?" + } + } + } + } + + if (stage == 3 && search == 4) { + list += "I have shown the prism and the necromantic page to" + list += "Zavistic Rarve. He's told me about a wizard named Sithik" + list += "Ints who might have some information." + } + + if (stage == 3 && search == 5) { + list += "I've spoken to Sithik, I need to see if he was involved in" + list += "some way." + when { + inventory.contains("signed_portrait") -> { + list += "I've got a signed portrait of Sithik, this may help to" + list += "convince Zavistic Rarve." + } + inventory.contains("good_portrait") || inventory.contains("bad_portrait") -> { + list += "I've made a portrait of Sithik...not sure what this will do?" + } + inventory.contains("book_of_portraiture") -> { + list += "I've found a book on portraiture...what does this prove?" + } + } + if (inventory.contains("book_of_ham")) { + list += "I've found a book on HAM philosophy...what does this" + list += "prove?" + } + if (inventory.contains("necromancy_book")) { + list += "I've found a necromantic book...what does this prove?" + } + } + + // ===== Stage 4+ — Zavistic gives the potion ===== + if (stage >= 4) { + list += "I've spoken to Sithik, I need to see if he was involved with" + list += "the Undead Ogres at 'Jiggig' in some way." + list += "I talked to Zavistic Rarve regarding the prism and the torn" + list += "page, he gave some information on a student called Sithik" + list += "Ints, he may know more about what's happening here." + + if (stage >= 6) { + list += "Zavistic has given me some sort of potion, apparently I" + list += "need to give it to Sithik." + } else if (inventory.contains("zogre_ogre_trans_potion")) { + list += "Zavistic has given me some sort of potion, apparently I" + list += "need to give it to Sithik." + } else { + list += "Zavistic gave me some sort of potion, but I don't have it" + list += "on me anymore. Apparently I need to give some to Sithik." + } + } + + // ===== Stage 6+ — potion put in tea ===== + if (stage >= 6) { + if (get("thzfe_sithik_transformed", 0) >= 1) { + list += "I have put some of the potion into Sithik's tea, the potion" + list += "will take some time to act. Perhaps I should get out of here" + list += "in case there are any side effects?" + if (stage == 6) { + list += "Perhaps I should go and check on Sithik now?" + } + } else { + list += "I have put some of the potion into Sithik's tea, the potion" + list += "will take some time to act. Perhaps I should get out of here" + list += "in case there are any side effects?" + } + } + + // ===== Stage 8 — Sithik turned into ogre ===== + if (stage == 8) { + list += "I came back into Sithik's room to find that he had been" + list += "turned into an Ogre!" + list += "Sithik has told me that there is no way I can remove the" + list += "effects of the necromantic curse spell from the Jiggig" + list += "area. I'll have to go back and let Grish know." + + if (get("thzfe_makebrutalarrow", false)) { + list += "Sithik has told me how to make 'brutal arrows', which" + list += "should be more effective against Zogres." + } + + if (get("thzfe_makecuredisease", false)) { + list += "Sithik has given me some pointers on how I can make a" + list += "cure disease potion, though I'm still not sure exactly which" + list += "herbs I should use." + } + } + + // ===== Stage 10+ — ogres want artefacts ===== + if (stage >= 10) { + list += "I came back into Sithik's room to find that he had been" + list += "turned into an Ogre!" + list += "Sithik has told me how to make 'brutal arrows', which" + list += "should be more effective against Zogres." + list += "Sithik has given me some pointers on how I can make a" + list += "cure disease potion, though I'm still not sure exactly which" + list += "herbs I should use." + list += "I've told Grish to relocate the dance area, but he needs" + list += "me to get something from the tomb to so that he can do" + list += "this." + if (stage == 10) { + list += "I need to go back into the tomb and look for some 'old'" + list += "items that Grish has asked for." + } + } + + // ===== Stage 12+ — killed Slash Bash ===== + if (stage >= 12) { + list += "I've killed a monster called Slash Bash...it was a huge" + list += "Zogre!" + if (inventory.contains("ogre_artefact")) { + list += "Slash Bash was wearing some odd artefacts, I can only" + list += "assume that these were what Grish wanted." + list += "I have some artefacts which I recovered from a huge" + list += "Zogre called Slash Bash. I should return them to Grish." + } else { + list += "Slash Bash was wearing some odd artefacts, I can only" + list += "assume that these were what Grish wanted." + } + } + + return list + } +} diff --git a/game/src/main/kotlin/content/skill/firemaking/LightSource.kt b/game/src/main/kotlin/content/skill/firemaking/LightSource.kt index 315bdf0ceb..cec2bbc5d7 100644 --- a/game/src/main/kotlin/content/skill/firemaking/LightSource.kt +++ b/game/src/main/kotlin/content/skill/firemaking/LightSource.kt @@ -59,6 +59,10 @@ class LightSource : Script { } entered("*") { + if (it.tags.contains("dark")) { + open("level_one_darkness") + return@entered + } if (!it.tags.contains("darkness")) { return@entered } @@ -71,7 +75,9 @@ class LightSource : Script { } exited("*") { - if (it.tags.contains("darkness")) { + if (it.tags.contains("dark")) { + close("level_one_darkness") + } else if (it.tags.contains("darkness")) { close("level_one_darkness") close("level_three_darkness") } diff --git a/game/src/main/kotlin/content/skill/magic/book/lunar/MonsterExamine.kt b/game/src/main/kotlin/content/skill/magic/book/lunar/MonsterExamine.kt index 9b0b488aae..eba0fc824d 100644 --- a/game/src/main/kotlin/content/skill/magic/book/lunar/MonsterExamine.kt +++ b/game/src/main/kotlin/content/skill/magic/book/lunar/MonsterExamine.kt @@ -37,6 +37,7 @@ class MonsterExamine(val combatDefinitions: CombatDefinitions) : Script { sound("stat_spy") exp(Skill.Magic, Tables.int("spells.monster_examine.xp") / 10.0) open("monster_stat_spy") + clear("spell") val maxHit = maxHit(target) interfaces.sendText("monster_stat_spy", "name", target.def.name) interfaces.sendText("monster_stat_spy", "line1", "Combat level: ${target.def.combat}") @@ -59,7 +60,7 @@ class MonsterExamine(val combatDefinitions: CombatDefinitions) : Script { return defined } // No data defined; estimate with the same melee formula combat uses - val strengthBonus = npc.get("strength", 0) + 64 + val strengthBonus = npc["strength", 0] + 64 return 5 + (npc.levels.get(Skill.Strength) * strengthBonus) / 64 } } diff --git a/game/src/main/kotlin/content/skill/magic/book/lunar/StatSpy.kt b/game/src/main/kotlin/content/skill/magic/book/lunar/StatSpy.kt index 9f70f62e2e..03ef521338 100644 --- a/game/src/main/kotlin/content/skill/magic/book/lunar/StatSpy.kt +++ b/game/src/main/kotlin/content/skill/magic/book/lunar/StatSpy.kt @@ -25,7 +25,7 @@ class StatSpy : Script { if (hasClock("action_delay")) { return@onPlayerApproach } - if (!target.get("accept_aid", true)) { + if (!target["accept_aid", true]) { message("This player is not currently accepting aid.") return@onPlayerApproach } @@ -40,6 +40,7 @@ class StatSpy : Script { target.sound("stat_spy_impact") exp(Skill.Magic, Tables.int("spells.stat_spy.xp") / 10.0) open("player_stat_spy") + clear("spell") for (skill in Skill.all) { val name = name(skill) // Constitution is stored as lifepoints (x10); the interface shows levels diff --git a/game/src/main/kotlin/content/skill/summoning/pet/PetShopOwner.kt b/game/src/main/kotlin/content/skill/summoning/pet/PetShopOwner.kt index 616e096974..d83255eb4d 100644 --- a/game/src/main/kotlin/content/skill/summoning/pet/PetShopOwner.kt +++ b/game/src/main/kotlin/content/skill/summoning/pet/PetShopOwner.kt @@ -114,9 +114,7 @@ class PetShopOwner : Script { player("Isn't that a little steep?") npc( owner.id, - "Well, if we gave them away for free then people would just buy them and dump them without a care. " + - "Dogs are a big responsibility and should be cared for. If a person is unwilling to invest $PUPPY_PRICE coins, " + - "then they don't deserve to have the puppy in the first place. So, do you still want one?", + "Well, if we gave them away for free then people would just buy them and dump them without a care. Dogs are a big responsibility and should be cared for. If a person is unwilling to invest $PUPPY_PRICE coins, then they don't deserve to have the puppy in the first place. So, do you still want one?", ) choice { option("Okay, I'll take the ${breed.string("option")}.") { @@ -150,35 +148,29 @@ class PetShopOwner : Script { player("Such as?") npc( owner.id, - "Well, we sell nuts. Those can be used to feed squirrels. If you want to capture a squirrel, you'll need to use the nuts " + - "on the trap you set, as the little scamps won't be fooled by anything else.", + "Well, we sell nuts. Those can be used to feed squirrels. If you want to capture a squirrel, you'll need to use the nuts on the trap you set, as the little scamps won't be fooled by anything else.", ) player("I'll bear that in mind!") npc( owner.id, - "There are also a number of birds that live in the woodlands of the world. If you can find their eggs then you can use " + - "the incubator over there to hatch it. So long as you are the first thing they see out of the shell, they will follow " + - "you anywhere. After that, you just need to feed the chick ground fishing bait until it's old enough to eat it solid.", + "There are also a number of birds that live in the woodlands of the world. If you can find their eggs then you can use the incubator over there to hatch it. So long as you are the first thing they see out of the shell, they will follow you anywhere. After that, you just need to feed the chick ground fishing bait until it's old enough to eat it solid.", ) player("I'll make sure to keep an eye on them if I go anywhere dangerous.") npc( owner.id, - "There are also a number of fabulous and exotic lizards in Karamja. Some can be caught easily in a box trap, while others " + - "will need to be raised from an egg.", + "There are also a number of fabulous and exotic lizards in Karamja. Some can be caught easily in a box trap, while others will need to be raised from an egg.", ) player("Will the incubator work for them, too?") npc(owner.id, "Of course! I'll keep an eye on all the eggs you put in there, so they will never end up hard-boiled.") player("Thank goodness!") npc( owner.id, - "The geckos of Karamja are quite easy to trap, like raccoons. Both will investigate a trap happily without any special bait. " + - "Monkeys are a different story, however!", + "The geckos of Karamja are quite easy to trap, like raccoons. Both will investigate a trap happily without any special bait. Monkeys are a different story, however!", ) player("What do you mean?") npc( owner.id, - "Well, they are clever little things and can easily get out of a box trap, unless they are stuck. The easiest way to do that " + - "is to put a banana into the workings. They will hang on tight, and never let go, even when the trap closes!", + "Well, they are clever little things and can easily get out of a box trap, unless they are stuck. The easiest way to do that is to put a banana into the workings. They will hang on tight, and never let go, even when the trap closes!", ) player("Thanks a lot, you've been very helpful!") npc(owner.id, "It's always a pleasure to help a fellow animal-lover. Come back and visit soon.") diff --git a/game/src/test/kotlin/InstructionCalls.kt b/game/src/test/kotlin/InstructionCalls.kt index 82d03c826e..858c59c422 100644 --- a/game/src/test/kotlin/InstructionCalls.kt +++ b/game/src/test/kotlin/InstructionCalls.kt @@ -261,6 +261,11 @@ fun Player.itemOnNpc(npc: NPC, itemSlot: Int, inventory: String = "inventory") { interactItemOn(npc, inventory, inventory, item, itemSlot) } +fun Player.itemOnFloorItem(floorItem: FloorItem, itemSlot: Int, inventory: String = "inventory") { + val item = inventories.inventory(inventory)[itemSlot] + interactItemOn(floorItem, inventory, inventory, item, itemSlot) +} + fun Player.itemOnItem( firstSlot: Int, secondSlot: Int, diff --git a/game/src/test/kotlin/content/entity/effect/toxin/DiseaseTest.kt b/game/src/test/kotlin/content/entity/effect/toxin/DiseaseTest.kt new file mode 100644 index 0000000000..3bb1273a1f --- /dev/null +++ b/game/src/test/kotlin/content/entity/effect/toxin/DiseaseTest.kt @@ -0,0 +1,91 @@ +package content.entity.effect.toxin + +import FakeRandom +import WorldTest +import containsMessage +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.type.setRandom +import java.util.concurrent.TimeUnit + +class DiseaseTest : WorldTest() { + + @Test + fun `Disease fades over time`() { + setRandom(object : FakeRandom() { + override fun nextInt(until: Int) = 0 + }) + val player = createPlayer() + player.levels.set(Skill.Constitution, 990) + player.levels.set(Skill.Attack, 3) + player.disease(player, 3) + assertTrue(player.containsMessage("You have been diseased")) + assertEquals(3, player.diseaseDamage) + assertTrue(player.timers.contains("disease")) + assertTrue(player.diseased) + tick(30) + assertEquals(1, player.levels.get(Skill.Attack)) + assertEquals(2, player.diseaseDamage) + tick(30) + assertEquals(970, player.levels.get(Skill.Constitution)) + assertEquals(1, player.diseaseDamage) + tick(30) + assertEquals(0, player.diseaseDamage) + assertFalse(player.diseased) + assertFalse(player.timers.contains("disease")) + } + + @Test + fun `Anti-disease fades over time`() { + val player = createPlayer() + player.antiDisease(36, TimeUnit.SECONDS) + assertEquals(-2, player.diseaseDamage) + assertTrue(player.timers.contains("disease")) + assertTrue(player.antiDisease) + tick(30) + assertEquals(100, player.levels.get(Skill.Constitution)) + assertEquals(-1, player.diseaseDamage) + assertTrue(player.containsMessage("Your disease resistance is about to wear off")) + tick(30) + assertEquals(100, player.levels.get(Skill.Constitution)) + assertEquals(0, player.diseaseDamage) + assertFalse(player.antiDisease) + assertFalse(player.timers.contains("disease")) + assertTrue(player.containsMessage("Your disease resistance has worn off")) + } + + @Test + fun `Can't re-disease target with lower damage`() { + val player = createPlayer() + player.levels.set(Skill.Constitution, 990) + player.disease(player, 10) + assertEquals(10, player.diseaseDamage) + assertTrue(player.timers.contains("disease")) + tick(30) + player.disease(player, 8) + assertEquals(9, player.diseaseDamage) + } + + @Test + fun `Disease resets with higher damage`() { + val player = createPlayer() + player.levels.set(Skill.Constitution, 990) + player.disease(player, 10) + assertEquals(10, player.diseaseDamage) + assertTrue(player.timers.contains("disease")) + tick(30) + player.disease(player, 11) + assertEquals(11, player.diseaseDamage) + } + + @Test + fun `Can't disease target with immunity`() { + val player = createPlayer() + player.antiDisease(1) + player.disease(player, 10) + assertEquals(-3, player.diseaseDamage) + assertFalse(player.diseased) + assertTrue(player.antiDisease) + } +} diff --git a/game/src/test/kotlin/content/quest/member/zogre_flesh_eaters/ZogreFleshEatersTest.kt b/game/src/test/kotlin/content/quest/member/zogre_flesh_eaters/ZogreFleshEatersTest.kt new file mode 100644 index 0000000000..448ff8c82b --- /dev/null +++ b/game/src/test/kotlin/content/quest/member/zogre_flesh_eaters/ZogreFleshEatersTest.kt @@ -0,0 +1,251 @@ +package content.quest.member.zogre_flesh_eaters + +import WorldTest +import content.quest.quest +import dialogueOption +import itemOnFloorItem +import itemOnNpc +import itemOnObject +import itemOption +import npcOption +import objectOption +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNotNull +import skipDialogues +import world.gregs.voidps.engine.client.instruction.handle.interactObject +import world.gregs.voidps.engine.entity.character.move.tele +import world.gregs.voidps.engine.entity.character.npc.NPCs +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.entity.item.floor.FloorItems +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import world.gregs.voidps.type.setRandom +import kotlin.random.Random +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ZogreFleshEatersTest : WorldTest() { + override var loadNpcs: Boolean = true + + @Test + fun `Complete the test`() { + val player = createPlayer(Tile(2445, 3052)) + player.levels.set(Skill.Ranged, 30) + player.experience.set(Skill.Ranged, Level.experience(30)) + player["chompy_birds"] = 65 + player["jungle_potion"] = "completed" + val grish = NPCs.findBySpawn(Tile(2443, 3051), "grish") + + player.npcOption(grish, "Talk-to") + tick(1) + player.skipDialogues() + player.dialogueOption(2) // Sickies? + player.skipDialogues() + player.dialogueOption(4) // Can I help? + player.skipDialogues() + player.dialogueOption(2) // Ok + player.skipDialogues() + player.dialogueOption(1) // Really sure + player.skipDialogues() + tick(4) + + assertEquals("investigate", player.quest("zogre_flesh_eaters")) + assertEquals(3, player.inventory.count("cooked_chompy")) + assertEquals(2, player.inventory.count("super_restore_3")) + + val guard = NPCs.findBySpawn(Tile(2454, 3047), "zogre_ogre_guard") + player.tele(2453, 3048) + player.npcOption(guard, "Talk-to") + tick(1) + player.skipDialogues() + tick(6) + assertEquals("barricade", player.quest("zogre_flesh_eaters")) + + val barricade = GameObjects.find(Tile(2456, 3049), "ogre_barricade_collapsed") + player.objectOption(barricade, "Climb-over") + tick(6) + assertEquals(Tile(2457, 3049), player.tile) + + player.tele(2443, 9460, 2) + val lecturn = GameObjects.find(Tile(2443, 9459, 2), "zogre_lecturn") + player.objectOption(lecturn, "Search") + tick(3) + assertTrue(player.inventory.contains("torn_page")) + + player.tele(2442, 9459, 2) + player["insta_kill"] = true + player["auto_retaliate"] = true + val skeleton = GameObjects.find(Tile(2442, 9459, 2), "zogre_brentle_skeleton") + player.objectOption(skeleton, "Search") + tick(15) + + assertTrue(FloorItems.at(player.tile.zone).any { list -> list.any { item -> item.id == "ruined_backpack" } }) + player.inventory.add("ruined_backpack") + + player.itemOption("Open", "ruined_backpack") + tick(1) + player.skipDialogues() + assertTrue(player.inventory.contains("dragon_inn_tankard")) + assertTrue(player.inventory.contains("rotten_food")) + assertTrue(player.inventory.contains("knife")) + + player.tele(2440, 9459, 2) + val coffin = GameObjects.find(Tile(2438, 9458, 2), "zogre_coffin_base") + player.interactObject(coffin, "Search") + tick(1) + player.skipDialogues() + player.itemOnObject(coffin, player.inventory.indexOf("knife")) + tick(4) + player.interactObject(coffin, "Search") + tick(1) + player.dialogueOption("continue") + setRandom(object : Random() { + override fun nextBits(bitCount: Int) = 0 + }) + tick(8) + player.skipDialogues() + tick(2) + player.interactObject(coffin, "Search") + tick(4) + assertTrue(player.inventory.contains("black_prism")) + assertEquals(3, player["thzfe_prismsearch", 0]) + + player.tele(2556, 3079, 0) + val bartender = NPCs.findBySpawn(Tile(2556, 3078), "bartender_dragon_inn") + player.itemOnNpc(bartender, player.inventory.indexOf("dragon_inn_tankard")) + tick(1) + player.skipDialogues() + assertTrue(player["thzfe_showntankard", false]) + + player.tele(2598, 3086) + val bell = GameObjects.find(Tile(2598, 3085), "zogre_outdoor_bell") + player.objectOption(bell, "Ring") + tick(1) + player.skipDialogues() + assertEquals(4, player["thzfe_prismsearch", 0]) + + player.tele(2590, 3104, 1) + val sithik = GameObjects.find(Tile(2591, 3103, 1), "zogre_sithik_bed") + player.objectOption(sithik, "Talk-to") + tick(1) + player.skipDialogues() + player.dialogueOption(1) + player.skipDialogues() + player.objectOption(sithik, "Talk-to") + tick(1) + player.dialogueOption(2) + player.skipDialogues() + assertEquals(5, player["thzfe_prismsearch", 0]) + + player.tele(2590, 3104, 1) + val wardrobe = GameObjects.find(Tile(2590, 3103, 1), "sithiks_wardrobe") + player.objectOption(wardrobe, "Search") + tick(1) + assertTrue(player.inventory.contains("book_of_ham")) + + player.tele(2593, 3105, 1) + val cupboard = GameObjects.find(Tile(2594, 3104, 1), "sithiks_cupboard") + player.objectOption(cupboard, "Search") + tick(1) + assertTrue(player.inventory.contains("necromancy_book")) + + player.tele(2593, 3103, 1) + val drawers = GameObjects.find(Tile(2594, 3103, 1), "sithiks_drawers") + player.objectOption(drawers, "Search") + tick(1) + player.skipDialogues() + assertTrue(player.inventory.contains("charcoal")) + assertTrue(player.inventory.contains("papyrus")) + assertTrue(player.inventory.contains("book_of_portraiture")) + + player.itemOnObject(sithik, player.inventory.indexOf("papyrus")) + tick(1) + player.skipDialogues() + tick(2) + assertTrue(player.inventory.contains("zogre_sithik_portrait_good")) + + player.tele(2556, 3079, 0) + player.itemOnNpc(bartender, player.inventory.indexOf("zogre_sithik_portrait_good")) + tick(1) + player.skipDialogues() + assertTrue(player["thzfe_innkeeperportraitshown", false]) + + player.tele(2588, 3090, 1) + val rarve = NPCs.findBySpawn(Tile(2588, 3091, 1), "zavistic_rarve") + player.npcOption(rarve, "Talk-to") + tick(1) + player.skipDialogues() + player.dialogueOption(3) + player.skipDialogues() + assertEquals("sithik", player.quest("zogre_flesh_eaters")) + assertTrue(player.inventory.contains("zogre_ogre_trans_potion")) + assertFalse(player.inventory.contains("book_of_ham")) + assertFalse(player.inventory.contains("necromancy_book")) + assertFalse(player.inventory.contains("zogre_sithik_portrait_signed")) + + player.tele(2593, 3103, 1) + val floorItem = FloorItems.add(Tile(2594, 3103, 1), "cup_of_tea_zogre_flesh_eaters") + player.itemOnFloorItem(floorItem, player.inventory.indexOf("zogre_ogre_trans_potion")) + tick(3) + player.skipDialogues() + assertEquals("potion", player.quest("zogre_flesh_eaters")) + + player.tele(2597, 3108, 0) + val ladder = GameObjects.find(Tile(2597, 3107), "basic_ladder_bottom") + player.objectOption(ladder, "Climb-up") + tick(2) + assertEquals(1, player["thzfe_sithik_transformed", 0]) + + player.tele(2593, 3103, 1) + player.objectOption(sithik, "Talk-to") + tick(1) + player.skipDialogues() + player.dialogueOption(1) + player.skipDialogues() + assertEquals("permanent_spell", player.quest("zogre_flesh_eaters")) + player.dialogueOption(2) + player.skipDialogues() + assertTrue(player["thzfe_makebrutalarrow", false]) + player.dialogueOption(3) + player.skipDialogues() + assertTrue(player["thzfe_makecuredisease", false]) + + player.tele(2445, 3052, 0) + player.npcOption(grish, "Talk-to") + tick(1) + player.skipDialogues() + player.dialogueOption(1) + player.skipDialogues() + assertTrue(player.inventory.contains("ogre_gate_key")) + assertEquals("given_key", player.quest("zogre_flesh_eaters")) + + player.tele(2482, 9445) + val stand = GameObjects.find(Tile(2483, 9445), "zogre_stand") + player.objectOption(stand, "Search") + tick(1) + player.skipDialogues() + tick(15) + + assertEquals("killed_slash_bash", player.quest("zogre_flesh_eaters")) + val artifact = FloorItems.firstOrNull(Tile(2477, 9444), "ogre_artefact") + assertNotNull(artifact) + + player.objectOption(stand, "Search") + tick(4) + player.skipDialogues() + assertTrue(player.inventory.contains("ogre_artefact")) + + player.tele(2445, 3052, 0) + player.npcOption(grish, "Talk-to") + tick(1) + player.skipDialogues() + player.dialogueOption(1) + player.skipDialogues() + tick(1) + assertEquals("completed", player.quest("zogre_flesh_eaters")) + } +} From 1cc002d20f624d877245a003b8a390db1404abf7 Mon Sep 17 00:00:00 2001 From: Harley Gilpin <75695035+HarleyGilpin@users.noreply.github.com> Date: Thu, 25 Jun 2026 06:42:30 -0700 Subject: [PATCH 11/15] Small bug fixes (#1046) * bug: fix default hair on new player character creation * bug: fix infinite shooting star with negative remaining health Mining stardust while holding the max (>=200) returned -1 from addOre, which short-circuited deplete() in the mining loop. handleMinedStarDust never ran, so the shared totalCollected counter grew unbounded while the star never advanced a layer - causing an infinite star and a negative '% left of this layer' prospect message. Count the maxed swing as a successful mine (returns 1) so the star depletes normally and experience is awarded. Also clamp the layer percentage to >=0 as a guard. Fixes #1043 * test: maxed-stardust mining still depletes a shooting star Regression test for the infinite shooting star (#1043): a player holding 200 stardust mining a crashed star one layer-collection away from advancing must push it to the next tier. Fails against the old return -1 path (star never depletes, hits the tick limit). * bug: Brimhaven customs officer ship sails to Ardougne not Port Sarim The customs officer (npc 380) spawns at both Musa Point and Brimhaven docks but the shared script always sent players to Port Sarim. The Brimhaven officer now routes to Ardougne docks via the brimhaven_to_ardougne journey, branching on the officer's location. Adds the Brimhaven and Ardougne dock gangplank objects/teleports (2085-2088) so players can board and disembark the legacy ship. Closes #1045 * fix: set Brimhaven->Ardougne ship cutscene delay to 5 ticks Matches the canonical journey delay for varp value 8 (~3s). The ship's stop position on the journey map is defined by the client cache for that varp value, not by server data, so the delay only controls how long the cutscene runs before teleporting. * feat: add Captain Barnaby charter dialogue (Ardougne -> Brimhaven) - Captain Barnaby Talk-to/Pay-fare dialogue sails Ardougne -> Brimhaven (varp value 7). Repoints the dock spawn to captain_barnaby_2 (4974), the interactive variant; the previously-spawned captain_barnaby_1 (4961) has no Talk-to option. - Boarding either docked ship via the gangplank shows a hint to speak to the operator first (via objTeleportLand, so the teleport still runs). - Ship's ladder (9745) Climb-down gives the standard refusal message. Mirrors the Ardougne<->Brimhaven charter from 2011Scape/game#610. * Remove duplicate npc spawn * Remove duplicate npc spawn * fix: spawn interactive Captain Barnaby variant at Ardougne dock The remaining captain_barnaby spawn (id 381) has no Talk-to/Pay-fare option; only captain_barnaby_2 (id 4974) is the charter operator. * Revert "fix: spawn interactive Captain Barnaby variant at Ardougne dock" This reverts commit f61a6f525d5487743f139ce74dd5c85a5916adfe. --- .../ardougne/east_ardougne.npc-spawns.toml | 1 - .../kandarin/ardougne/east_ardougne.objs.toml | 12 ++++ .../ardougne/east_ardougne.teles.toml | 10 +++ .../karamja/brimhaven/brimhaven.objs.toml | 10 ++- .../karamja/brimhaven/brimhaven.teles.toml | 12 +++- .../character/player/equip/BodyParts.kt | 9 ++- .../activity/shooting_star/ShootingStar.kt | 2 +- .../area/kandarin/ardougne/CaptainBarnaby.kt | 66 +++++++++++++++++++ .../area/karamja/musa_point/CustomsOfficer.kt | 21 ++++-- .../kotlin/content/skill/mining/Mining.kt | 5 +- .../shooting_star/ShootingStarTest.kt | 51 ++++++++++++++ .../kandarin/ardougne/CaptainBarnabyTest.kt | 53 +++++++++++++++ .../karamja/musa_point/CustomsOfficerTest.kt | 64 ++++++++++++++++++ .../tools/photobooth/PlayerModelAssembler.kt | 2 +- 14 files changed, 302 insertions(+), 16 deletions(-) create mode 100644 game/src/main/kotlin/content/area/kandarin/ardougne/CaptainBarnaby.kt create mode 100644 game/src/test/kotlin/content/activity/shooting_star/ShootingStarTest.kt create mode 100644 game/src/test/kotlin/content/area/kandarin/ardougne/CaptainBarnabyTest.kt create mode 100644 game/src/test/kotlin/content/area/karamja/musa_point/CustomsOfficerTest.kt diff --git a/data/area/kandarin/ardougne/east_ardougne.npc-spawns.toml b/data/area/kandarin/ardougne/east_ardougne.npc-spawns.toml index 65f55008ba..e11418732a 100644 --- a/data/area/kandarin/ardougne/east_ardougne.npc-spawns.toml +++ b/data/area/kandarin/ardougne/east_ardougne.npc-spawns.toml @@ -178,7 +178,6 @@ spawns = [ { id = "ambassador_gimblewap", x = 2572, y = 3299, level = 1 }, { id = "banker_4", x = 2616, y = 3330 }, { id = "my_arm_ardougne", x = 2684, y = 3274, members = true }, - { id = "captain_barnaby_1", x = 2683, y = 3275, members = true }, { id = "charlie_ardougne", x = 2607, y = 3264, members = true }, { id = "penguin_ardougne", x = 2596, y = 3270, members = true }, { id = "silver_merchant_ardougne", x = 2659, y = 3316 }, diff --git a/data/area/kandarin/ardougne/east_ardougne.objs.toml b/data/area/kandarin/ardougne/east_ardougne.objs.toml index 141630cbc8..ef1f088e27 100644 --- a/data/area/kandarin/ardougne/east_ardougne.objs.toml +++ b/data/area/kandarin/ardougne/east_ardougne.objs.toml @@ -192,3 +192,15 @@ id = 5792 [dairy_churn_ardougne] id = 34800 examine = "Used to make dairy products." + +[gangplank_ardougne_enter] +id = 2085 +examine = "Handy for boarding the ship." + +[gangplank_ardougne_exit] +id = 2086 +examine = "Handy for boarding the ship." + +[captain_barnaby_ship_ladder] +id = 9745 +examine = "A sturdy wooden ladder." diff --git a/data/area/kandarin/ardougne/east_ardougne.teles.toml b/data/area/kandarin/ardougne/east_ardougne.teles.toml index 67fe945f9b..dc28934ae8 100644 --- a/data/area/kandarin/ardougne/east_ardougne.teles.toml +++ b/data/area/kandarin/ardougne/east_ardougne.teles.toml @@ -272,3 +272,13 @@ to = { x = 2044, y = 4649 } option = "Climb-up" tile = { x = 2044, y = 4650 } to = { x = 2543, y = 3327 } + +[gangplank_ardougne_enter] +option = "Cross" +tile = { x = 2683, y = 3270 } +to = { x = 2683, y = 3268, level = 1 } + +[gangplank_ardougne_exit] +option = "Cross" +tile = { x = 2683, y = 3269, level = 1 } +to = { x = 2683, y = 3271 } diff --git a/data/area/karamja/brimhaven/brimhaven.objs.toml b/data/area/karamja/brimhaven/brimhaven.objs.toml index a95fd654d2..611596070f 100644 --- a/data/area/karamja/brimhaven/brimhaven.objs.toml +++ b/data/area/karamja/brimhaven/brimhaven.objs.toml @@ -60,4 +60,12 @@ examine = "A closed overgrown dungeon entrance" [brimhaven_vine_4] id = 5106 -examine = "A closed overgrown dungeon entrance" \ No newline at end of file +examine = "A closed overgrown dungeon entrance" + +[gangplank_brimhaven_enter] +id = 2087 +examine = "Handy for boarding the ship." + +[gangplank_brimhaven_exit] +id = 2088 +examine = "Handy for boarding the ship." \ No newline at end of file diff --git a/data/area/karamja/brimhaven/brimhaven.teles.toml b/data/area/karamja/brimhaven/brimhaven.teles.toml index c54280d0c3..b156b684f7 100644 --- a/data/area/karamja/brimhaven/brimhaven.teles.toml +++ b/data/area/karamja/brimhaven/brimhaven.teles.toml @@ -56,4 +56,14 @@ to = { x = 2636, y = 9510, level = 2} [brimhaven_walk_down_4] option = "Walk-down" tile = { x = 2635, y = 9511, level = 2} -to = { x = 2636, y = 9517} \ No newline at end of file +to = { x = 2636, y = 9517} + +[gangplank_brimhaven_enter] +option = "Cross" +tile = { x = 2773, y = 3234 } +to = { x = 2775, y = 3234, level = 1 } + +[gangplank_brimhaven_exit] +option = "Cross" +tile = { x = 2774, y = 3234, level = 1 } +to = { x = 2772, y = 3234 } \ No newline at end of file diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/equip/BodyParts.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/equip/BodyParts.kt index 036bb54539..cfc1a6df27 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/equip/BodyParts.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/character/player/equip/BodyParts.kt @@ -13,7 +13,7 @@ import world.gregs.voidps.network.login.protocol.visual.update.player.BodyPart data class BodyParts( override var male: Boolean = true, val looks: IntArray = if (male) DEFAULT_LOOK_MALE.clone() else DEFAULT_LOOK_FEMALE.clone(), - val colours: IntArray = DEFAULT_COLOURS.clone(), + val colours: IntArray = if (male) DEFAULT_COLOURS_MALE.clone() else DEFAULT_COLOURS_FEMALE.clone(), ) : Body { private val parts = IntArray(12) @@ -134,8 +134,11 @@ data class BodyParts( } companion object { - val DEFAULT_LOOK_MALE = intArrayOf(0, 14, 18, 26, 34, 38, 42) + // Hair, beard, chest, arms, hands, legs, feet. Hair 5 = "Short" body_look_id. + val DEFAULT_LOOK_MALE = intArrayOf(5, 14, 18, 26, 34, 38, 42) val DEFAULT_LOOK_FEMALE = intArrayOf(45, -1, 58, 61, 68, 72, 80) - val DEFAULT_COLOURS = IntArray(5) + // Hair, top, legs, feet, skin. Hair 7 = "Willow brown" (light brown). + val DEFAULT_COLOURS_MALE = intArrayOf(7, 0, 0, 0, 0) + val DEFAULT_COLOURS_FEMALE = IntArray(5) } } diff --git a/game/src/main/kotlin/content/activity/shooting_star/ShootingStar.kt b/game/src/main/kotlin/content/activity/shooting_star/ShootingStar.kt index 77dba40cbe..240811bba8 100644 --- a/game/src/main/kotlin/content/activity/shooting_star/ShootingStar.kt +++ b/game/src/main/kotlin/content/activity/shooting_star/ShootingStar.kt @@ -213,7 +213,7 @@ class ShootingStar : Script { } fun getLayerPercentage(totalCollected: Int, totalNeeded: Int): String { - val remaining = totalNeeded - totalCollected + val remaining = (totalNeeded - totalCollected).coerceAtLeast(0) val percentageRemaining = (remaining.toDouble() / totalNeeded.toDouble()) * 100 return String.format("%.2f", percentageRemaining) } diff --git a/game/src/main/kotlin/content/area/kandarin/ardougne/CaptainBarnaby.kt b/game/src/main/kotlin/content/area/kandarin/ardougne/CaptainBarnaby.kt new file mode 100644 index 0000000000..b437dca662 --- /dev/null +++ b/game/src/main/kotlin/content/area/kandarin/ardougne/CaptainBarnaby.kt @@ -0,0 +1,66 @@ +package content.area.kandarin.ardougne + +import content.entity.obj.ship.boatTravel +import content.entity.player.dialogue.Happy +import content.entity.player.dialogue.Neutral +import content.entity.player.dialogue.Quiz +import content.entity.player.dialogue.Sad +import content.entity.player.dialogue.type.choice +import content.entity.player.dialogue.type.npc +import content.entity.player.dialogue.type.player +import content.entity.player.dialogue.type.statement +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.inv.remove +import world.gregs.voidps.type.Tile + +class CaptainBarnaby : Script { + + init { + npcOperate("Talk-to", "captain_barnaby_2") { + npc("Do you want to go on a trip to Brimhaven?") + npc("The trip will cost you 30 coins.") + choice { + option("Yes please.") { + if (!inventory.remove("coins", 30)) { + player("Oh dear, I don't seem to have enough money.") + return@option + } + travel() + } + option("No, thank you.") + } + } + + npcOperate("Pay-fare", "captain_barnaby_2") { + if (!inventory.remove("coins", 30)) { + message("You do not have enough money for that.") + return@npcOperate + } + travel() + } + + // Boarding the docked ship doesn't set sail; the trip starts by talking to the operator. + objTeleportLand("Cross", "gangplank_ardougne_enter") { _, _ -> + message("You must speak to Captain Barnaby before it will set sail.") + } + objTeleportLand("Cross", "gangplank_brimhaven_enter") { _, _ -> + message("You must speak to the Customs officer before it will set sail.") + } + + objectOperate("Climb-down", "captain_barnaby_ship_ladder") { (ladder) -> + if (ladder.tile != Tile(2682, 3267, 1)) { + return@objectOperate + } + message("I don't think Captain Barnaby wants me going down there.") + } + } + + private suspend fun Player.travel() { + message("You pay 30 coins and board the ship.") + boatTravel("ardougne_to_brimhaven", 5, Tile(2775, 3234, 1)) + statement("The ship arrives at Brimhaven.") + } +} diff --git a/game/src/main/kotlin/content/area/karamja/musa_point/CustomsOfficer.kt b/game/src/main/kotlin/content/area/karamja/musa_point/CustomsOfficer.kt index 9c1ddaf62f..24206222fb 100644 --- a/game/src/main/kotlin/content/area/karamja/musa_point/CustomsOfficer.kt +++ b/game/src/main/kotlin/content/area/karamja/musa_point/CustomsOfficer.kt @@ -10,6 +10,8 @@ import content.entity.player.dialogue.type.player import content.entity.player.dialogue.type.statement import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.definition.Areas +import world.gregs.voidps.engine.entity.character.npc.NPC import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.inv.remove @@ -18,7 +20,7 @@ import world.gregs.voidps.type.Tile class CustomsOfficer : Script { init { - npcOperate("Talk-to", "customs_officer_brimhaven") { + npcOperate("Talk-to", "customs_officer_brimhaven") { (officer) -> npc("Can I help you?") choice { option("Can I journey on this ship?") { @@ -30,7 +32,7 @@ class CustomsOfficer : Script { player("Oh dear, I don't seem to have enough money.") return@option } - travel() + travel(officer) } option("Oh, I'll not bother then.") } @@ -42,18 +44,23 @@ class CustomsOfficer : Script { } } - npcOperate("Pay-Fare", "customs_officer_brimhaven") { + npcOperate("Pay-Fare", "customs_officer_brimhaven") { (officer) -> if (!inventory.remove("coins", 30)) { message("You do not have enough money for that.") return@npcOperate } - travel() + travel(officer) } } - private suspend fun Player.travel() { + private suspend fun Player.travel(officer: NPC) { message("You pay 30 coins and board the ship.") - boatTravel("karamja_to_port_sarim", 7, Tile(3032, 3217, 1)) - statement("The ship arrives at Port Sarim.") + if (officer.tile in Areas["brimhaven"]) { + boatTravel("brimhaven_to_ardougne", 5, Tile(2683, 3268, 1)) + statement("The ship arrives at Ardougne.") + } else { + boatTravel("karamja_to_port_sarim", 7, Tile(3032, 3217, 1)) + statement("The ship arrives at Port Sarim.") + } } } diff --git a/game/src/main/kotlin/content/skill/mining/Mining.kt b/game/src/main/kotlin/content/skill/mining/Mining.kt index f31fd2da59..cb307de785 100644 --- a/game/src/main/kotlin/content/skill/mining/Mining.kt +++ b/game/src/main/kotlin/content/skill/mining/Mining.kt @@ -161,7 +161,10 @@ class Mining : Script { val totalStarDust = player.inventory.count(ore) + player.bank.count(ore) if (totalStarDust >= 200) { player.message("You have the maximum amount of stardust but was still rewarded experience.") - return -1 + // Still count as a successful mine so the star depletes and experience is awarded, + // even though the dust can't be carried. Returning <1 here would skip deplete() and + // let totalCollected grow unbounded (negative "% left of this layer"). + return 1 } } var amount = when (target.id) { diff --git a/game/src/test/kotlin/content/activity/shooting_star/ShootingStarTest.kt b/game/src/test/kotlin/content/activity/shooting_star/ShootingStarTest.kt new file mode 100644 index 0000000000..933ba33814 --- /dev/null +++ b/game/src/test/kotlin/content/activity/shooting_star/ShootingStarTest.kt @@ -0,0 +1,51 @@ +package content.activity.shooting_star + +import WorldTest +import content.entity.player.bank.bank +import objectOption +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.entity.obj.ObjectLayer +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.setRandom +import kotlin.random.Random + +internal class ShootingStarTest : WorldTest() { + + /** + * Regression test for https://github.com/GregHib/void/issues/1043 + * + * A player holding the maximum amount of stardust (>= 200) used to make `addOre` return -1, + * which short-circuited `deplete()` in the mining loop so the star's layer never advanced and + * the shared `totalCollected` counter grew without bound - an infinite star with a negative + * "% left of this layer". A maxed-out mine must still count towards depleting the star. + */ + @Test + fun `Mining a star with maximum stardust still depletes a layer`() { + setRandom(Random) + val player = createPlayer(emptyTile) + player.levels.set(Skill.Mining, 99) + player.inventory.add("rune_pickaxe") + // Maxed out on stardust so every successful mine goes through the >= 200 branch of addOre. + player.bank.add("stardust", 200) + + val tile = emptyTile.addY(1) + val star = createObject("crashed_star_tier_9", tile) // collect_for_next_layer = 15 + ShootingStarHandler.currentStarTile = tile + ShootingStarHandler.currentActiveObject = star + ShootingStarHandler.totalCollected = 14 // one successful mine away from the next layer + + player.objectOption(star, "Mine") + // The very next stardust mined by the maxed player must advance the star to the next tier. + tickIf(limit = 200) { + GameObjects.getLayer(tile, ObjectLayer.GROUND)?.id == "crashed_star_tier_9" + } + + assertEquals("crashed_star_tier_8", GameObjects.getLayer(tile, ObjectLayer.GROUND)?.id) + assertEquals(0, ShootingStarHandler.totalCollected) // counter reset, never runs away negative + assertEquals(0, player.inventory.count("stardust")) // none carried while maxed out + } +} diff --git a/game/src/test/kotlin/content/area/kandarin/ardougne/CaptainBarnabyTest.kt b/game/src/test/kotlin/content/area/kandarin/ardougne/CaptainBarnabyTest.kt new file mode 100644 index 0000000000..aefc7ff10f --- /dev/null +++ b/game/src/test/kotlin/content/area/kandarin/ardougne/CaptainBarnabyTest.kt @@ -0,0 +1,53 @@ +package content.area.kandarin.ardougne + +import WorldTest +import containsMessage +import npcOption +import objectOption +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CaptainBarnabyTest : WorldTest() { + + @Test + fun `Pay fare from Ardougne sails to Brimhaven`() { + val player = createPlayer(Tile(2683, 3274)) + player.inventory.add("coins", 30) + val barnaby = createNPC("captain_barnaby_2", Tile(2683, 3275)) + + player.npcOption(barnaby, "Pay-fare") + + tickIf(limit = 200) { player.tile != Tile(2775, 3234, 1) } + + assertEquals(Tile(2775, 3234, 1), player.tile) + assertEquals(0, player.inventory.count("coins")) + } + + @Test + fun `Boarding the Ardougne ship warns to speak to Captain Barnaby`() { + val player = createPlayer(Tile(2683, 3270)) + val gangplank = GameObjects.find(Tile(2683, 3270), "gangplank_ardougne_enter") + + player.objectOption(gangplank, "Cross") + tickIf(limit = 20) { player.tile != Tile(2683, 3268, 1) } + + assertEquals(Tile(2683, 3268, 1), player.tile) + assertTrue(player.containsMessage("You must speak to Captain Barnaby before it will set sail.")) + } + + @Test + fun `Ship's ladder cannot be climbed`() { + val player = createPlayer(Tile(2683, 3268, 1)) + val ladder = GameObjects.find(Tile(2682, 3267, 1), "captain_barnaby_ship_ladder") + + player.objectOption(ladder, "Climb-down") + tick(2) + + assertTrue(player.containsMessage("I don't think Captain Barnaby wants me going down there.")) + } +} diff --git a/game/src/test/kotlin/content/area/karamja/musa_point/CustomsOfficerTest.kt b/game/src/test/kotlin/content/area/karamja/musa_point/CustomsOfficerTest.kt new file mode 100644 index 0000000000..9ef4f7d75c --- /dev/null +++ b/game/src/test/kotlin/content/area/karamja/musa_point/CustomsOfficerTest.kt @@ -0,0 +1,64 @@ +package content.area.karamja.musa_point + +import WorldTest +import npcOption +import objectOption +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.obj.GameObjects +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.Tile +import kotlin.test.assertEquals + +class CustomsOfficerTest : WorldTest() { + + @Test + fun `Pay fare from Brimhaven sails to Ardougne`() { + val player = createPlayer(Tile(2772, 3226)) + player.inventory.add("coins", 30) + val officer = createNPC("customs_officer_brimhaven", Tile(2772, 3225)) + + player.npcOption(officer, "Pay-Fare") + + tickIf(limit = 200) { player.tile != Tile(2683, 3268, 1) } + + assertEquals(Tile(2683, 3268, 1), player.tile) + assertEquals(0, player.inventory.count("coins")) + } + + @Test + fun `Pay fare from Musa Point sails to Port Sarim`() { + val player = createPlayer(Tile(2953, 3148)) + player.inventory.add("coins", 30) + val officer = createNPC("customs_officer_brimhaven", Tile(2953, 3147)) + + player.npcOption(officer, "Pay-Fare") + + tickIf(limit = 200) { player.tile != Tile(3032, 3217, 1) } + + assertEquals(Tile(3032, 3217, 1), player.tile) + assertEquals(0, player.inventory.count("coins")) + } + + @Test + fun `Cross gangplank to disembark at Ardougne docks`() { + val player = createPlayer(Tile(2683, 3268, 1)) + val gangplank = GameObjects.find(Tile(2683, 3269, 1), "gangplank_ardougne_exit") + + player.objectOption(gangplank, "Cross") + tickIf(limit = 20) { player.tile != Tile(2683, 3271) } + + assertEquals(Tile(2683, 3271), player.tile) + } + + @Test + fun `Cross gangplank to board ship at Brimhaven docks`() { + val player = createPlayer(Tile(2772, 3234)) + val gangplank = GameObjects.find(Tile(2773, 3234), "gangplank_brimhaven_enter") + + player.objectOption(gangplank, "Cross") + tickIf(limit = 20) { player.tile != Tile(2775, 3234, 1) } + + assertEquals(Tile(2775, 3234, 1), player.tile) + } +} diff --git a/tools/src/main/kotlin/world/gregs/voidps/tools/photobooth/PlayerModelAssembler.kt b/tools/src/main/kotlin/world/gregs/voidps/tools/photobooth/PlayerModelAssembler.kt index b5658eba5b..b6594a582e 100644 --- a/tools/src/main/kotlin/world/gregs/voidps/tools/photobooth/PlayerModelAssembler.kt +++ b/tools/src/main/kotlin/world/gregs/voidps/tools/photobooth/PlayerModelAssembler.kt @@ -141,7 +141,7 @@ class PlayerModelAssembler( companion object { // Mirrors BodyParts.DEFAULT_LOOK_*. - private val DEFAULT_LOOK_MALE = intArrayOf(0, 14, 18, 26, 34, 38, 42) + private val DEFAULT_LOOK_MALE = intArrayOf(5, 14, 18, 26, 34, 38, 42) private val DEFAULT_LOOK_FEMALE = intArrayOf(45, -1, 58, 61, 68, 72, 80) private const val KIT_ID_BASE = 0x100000 private const val PLAYER_MODEL_ID = 0x200000 From d3efc105b8e648f9bca7cd86b68b654f99ae91d5 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 28 Jun 2026 06:08:03 -0700 Subject: [PATCH 12/15] refactor: store punishments as epoch seconds Longs aren't supported by the variable format system; sub-second precision isn't needed for mutes, bans, black marks or reports. Switch them to epoch seconds (Int), with PERMANENT as Int.MAX_VALUE. Chat message evidence keeps millisecond precision. --- data/social/report/report.vars.toml | 4 ++-- .../voidps/engine/client/PlayerAccountLoader.kt | 4 ++-- .../gregs/voidps/engine/data/AbuseReport.kt | 4 ++-- .../engine/client/PlayerAccountLoaderTest.kt | 4 ++-- .../main/kotlin/content/social/report/Ban.kt | 8 ++++---- .../kotlin/content/social/report/BlackMarks.kt | 10 +++++----- .../main/kotlin/content/social/report/Mute.kt | 17 +++++++++++------ .../kotlin/content/social/report/ReportAbuse.kt | 2 +- .../content/social/report/PunishmentsTest.kt | 6 +++--- 9 files changed, 32 insertions(+), 27 deletions(-) diff --git a/data/social/report/report.vars.toml b/data/social/report/report.vars.toml index 2d98c1c149..1e53a4154d 100644 --- a/data/social/report/report.vars.toml +++ b/data/social/report/report.vars.toml @@ -1,9 +1,9 @@ [muted_until] -format = "long" +format = "int" persist = true [banned_until] -format = "long" +format = "int" persist = true [black_marks] diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/client/PlayerAccountLoader.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/client/PlayerAccountLoader.kt index cc5d571463..e7f72efe22 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/client/PlayerAccountLoader.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/client/PlayerAccountLoader.kt @@ -79,8 +79,8 @@ class PlayerAccountLoader( } private fun banned(variables: Map): Boolean { - val until = (variables["banned_until"] as? Number)?.toLong() ?: return false - return until > System.currentTimeMillis() + val until = (variables["banned_until"] as? Number)?.toInt() ?: return false + return until > (System.currentTimeMillis() / 1000).toInt() } suspend fun connect(player: Player, client: Client, displayMode: Int = 0, viewport: Boolean = true) { diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/data/AbuseReport.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/data/AbuseReport.kt index e60063be31..7e84be823b 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/data/AbuseReport.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/data/AbuseReport.kt @@ -8,7 +8,7 @@ package world.gregs.voidps.engine.data * @param ruleName Readable name of the rule broken * @param mute Whether a moderator requested the accused be muted * @param suggestion Additional text submitted with the report - * @param time Epoch millisecond timestamp the report was received + * @param time Epoch second timestamp the report was received * @param evidence Recent chat messages sent by the accused player */ data class AbuseReport( @@ -18,6 +18,6 @@ data class AbuseReport( val ruleName: String, val mute: Boolean, val suggestion: String, - val time: Long, + val time: Int, val evidence: List, ) diff --git a/engine/src/test/kotlin/world/gregs/voidps/engine/client/PlayerAccountLoaderTest.kt b/engine/src/test/kotlin/world/gregs/voidps/engine/client/PlayerAccountLoaderTest.kt index 13fc81ab22..d650eca223 100644 --- a/engine/src/test/kotlin/world/gregs/voidps/engine/client/PlayerAccountLoaderTest.kt +++ b/engine/src/test/kotlin/world/gregs/voidps/engine/client/PlayerAccountLoaderTest.kt @@ -95,7 +95,7 @@ internal class PlayerAccountLoaderTest : KoinMock() { @Test fun `Can't login if banned`() = runTest { val client: Client = mockk(relaxed = true) - playerSave = PlayerSave("name", "hash", Tile.EMPTY, intArrayOf(), emptyList(), intArrayOf(), true, intArrayOf(), intArrayOf(), mapOf("banned_until" to Long.MAX_VALUE), emptyMap(), emptyMap(), emptyList(), arrayOf(), emptyList()) + playerSave = PlayerSave("name", "hash", Tile.EMPTY, intArrayOf(), emptyList(), intArrayOf(), true, intArrayOf(), intArrayOf(), mapOf("banned_until" to Int.MAX_VALUE), emptyMap(), emptyMap(), emptyList(), arrayOf(), emptyList()) val instructions = loader.load(client, "name", "pass", 2) assertNull(instructions) @@ -105,7 +105,7 @@ internal class PlayerAccountLoaderTest : KoinMock() { @Test fun `Can login once ban expires`() = runTest { val client: Client = mockk(relaxed = true) - playerSave = PlayerSave("name", "hash", Tile.EMPTY, intArrayOf(), emptyList(), intArrayOf(), true, intArrayOf(), intArrayOf(), mapOf("banned_until" to 1L), emptyMap(), emptyMap(), emptyList(), arrayOf(), emptyList()) + playerSave = PlayerSave("name", "hash", Tile.EMPTY, intArrayOf(), emptyList(), intArrayOf(), true, intArrayOf(), intArrayOf(), mapOf("banned_until" to 1), emptyMap(), emptyMap(), emptyList(), arrayOf(), emptyList()) coEvery { queue.await() } just Runs val instructions = loader.load(client, "name", "pass", 2) diff --git a/game/src/main/kotlin/content/social/report/Ban.kt b/game/src/main/kotlin/content/social/report/Ban.kt index 4bdae6510e..b6f9bc34e4 100644 --- a/game/src/main/kotlin/content/social/report/Ban.kt +++ b/game/src/main/kotlin/content/social/report/Ban.kt @@ -14,10 +14,10 @@ import world.gregs.voidps.engine.event.AuditLog import java.util.concurrent.TimeUnit val Player.isBanned: Boolean - get() = this["banned_until", 0L] > System.currentTimeMillis() + get() = this["banned_until", 0] > epochSeconds() fun Player.ban(hours: Int = 48, rule: Rule? = null) { - this["banned_until"] = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(hours.toLong()) + this["banned_until"] = epochSeconds() + TimeUnit.HOURS.toSeconds(hours.toLong()).toInt() addBlackMark(rule) } @@ -34,7 +34,7 @@ class Ban(val accounts: AccountDefinitions, val manager: AccountManager, val sto init { modCommand("ban", stringArg("player-name", autofill = accounts.displayNames.keys), intArg("hours", optional = true), desc = "Temporarily ban a player from logging in") { args -> val hours = args.getOrNull(1)?.toIntOrNull() ?: 48 - val until = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(hours.toLong()) + val until = epochSeconds() + TimeUnit.HOURS.toSeconds(hours.toLong()).toInt() val target = Players.find(args[0]) if (target != null) { target.ban(hours) @@ -92,7 +92,7 @@ class Ban(val accounts: AccountDefinitions, val manager: AccountManager, val sto /** * Bans an offline player's saved account and adds a black mark */ - private fun banOffline(displayName: String, until: Long): Boolean { + private fun banOffline(displayName: String, until: Int): Boolean { val account = accounts.get(displayName)?.accountName ?: displayName val save = storage.load(account) ?: return false val variables = save.variables.toMutableMap() diff --git a/game/src/main/kotlin/content/social/report/BlackMarks.kt b/game/src/main/kotlin/content/social/report/BlackMarks.kt index eedb35472d..c3a56c4ee3 100644 --- a/game/src/main/kotlin/content/social/report/BlackMarks.kt +++ b/game/src/main/kotlin/content/social/report/BlackMarks.kt @@ -25,7 +25,7 @@ import java.util.concurrent.TimeUnit */ const val BLACK_MARK_LIMIT = 10 -private val TWELVE_MONTHS = TimeUnit.DAYS.toMillis(365) +private val TWELVE_MONTHS = TimeUnit.DAYS.toSeconds(365) private val PERMANENT_RULES = setOf(Rule.BreakingRealWorldLaws) private val DATE_FORMAT = DateTimeFormatter.ofPattern("d MMM yyyy").withZone(ZoneOffset.UTC) @@ -49,7 +49,7 @@ fun Player.activeBlackMarks(): List { } fun activeBlackMarks(marks: List): List { - val now = System.currentTimeMillis() + val now = epochSeconds() return marks.filter { expiry(it) > now } } @@ -61,17 +61,17 @@ fun Player.addBlackMark(rule: Rule? = null) { * A black mark entry for breaking [rule], or at a moderator's discretion when no rule is given */ fun blackMark(rule: Rule? = null): String { - val expiry = if (rule != null && rule in PERMANENT_RULES) PERMANENT else System.currentTimeMillis() + TWELVE_MONTHS + val expiry = if (rule != null && rule in PERMANENT_RULES) PERMANENT else epochSeconds() + TWELVE_MONTHS.toInt() return "${rule?.id ?: -1}:$expiry" } -private fun expiry(mark: String): Long = mark.substringAfter(':').toLongOrNull() ?: 0L +private fun expiry(mark: String): Int = mark.substringAfter(':').toIntOrNull() ?: 0 private fun describe(mark: String): String { val id = mark.substringBefore(':').toIntOrNull() ?: -1 val title = if (id == -1) "Moderator discretion" else Rule.byId(id)?.title ?: "Unknown offence" val expiry = expiry(mark) - val expires = if (expiry == PERMANENT) "never expires" else "expires ${DATE_FORMAT.format(Instant.ofEpochMilli(expiry))}" + val expires = if (expiry == PERMANENT) "never expires" else "expires ${DATE_FORMAT.format(Instant.ofEpochSecond(expiry.toLong()))}" return "$title - $expires" } diff --git a/game/src/main/kotlin/content/social/report/Mute.kt b/game/src/main/kotlin/content/social/report/Mute.kt index 0d89373ce6..dc14795995 100644 --- a/game/src/main/kotlin/content/social/report/Mute.kt +++ b/game/src/main/kotlin/content/social/report/Mute.kt @@ -14,13 +14,18 @@ import world.gregs.voidps.engine.event.AuditLog import java.util.concurrent.TimeUnit // Max value rather than -1 as small numbers are loaded back from saves as ints -const val PERMANENT = Long.MAX_VALUE +const val PERMANENT = Int.MAX_VALUE + +/** + * Current time as an epoch second; punishments don't need sub-second precision + */ +fun epochSeconds(): Int = (System.currentTimeMillis() / 1000).toInt() val Player.isMuted: Boolean - get() = this["muted_until", 0L] > System.currentTimeMillis() + get() = this["muted_until", 0] > epochSeconds() fun Player.mute(hours: Int = 48, rule: Rule? = null) { - this["muted_until"] = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(hours.toLong()) + this["muted_until"] = epochSeconds() + TimeUnit.HOURS.toSeconds(hours.toLong()).toInt() addBlackMark(rule) message("You have been temporarily muted due to breaking a rule.") } @@ -38,12 +43,12 @@ fun Player.unmute() { * Informs a muted player why their chat attempt was blocked */ fun Player.sendMuteMessage() { - val until = this["muted_until", 0L] + val until = this["muted_until", 0] if (until == PERMANENT) { message("You are permanently muted because of breaking a rule.") } else { - val day = TimeUnit.DAYS.toMillis(1) - val days = (until - System.currentTimeMillis() + day - 1) / day + val day = TimeUnit.DAYS.toSeconds(1) + val days = (until - epochSeconds() + day - 1) / day message("You are temporarily muted because of breaking a rule. This mute will remain for a further $days ${"day".plural(days)}. To prevent further mutes please read the rules.") } } diff --git a/game/src/main/kotlin/content/social/report/ReportAbuse.kt b/game/src/main/kotlin/content/social/report/ReportAbuse.kt index 4c158089b9..bcd87c4741 100644 --- a/game/src/main/kotlin/content/social/report/ReportAbuse.kt +++ b/game/src/main/kotlin/content/social/report/ReportAbuse.kt @@ -79,7 +79,7 @@ class ReportAbuse(val reports: Reports, val accounts: AccountDefinitions) : Scri ruleName = rule.title, mute = muted, suggestion = suggestion, - time = System.currentTimeMillis(), + time = epochSeconds(), evidence = ChatHistory.recent(account), ), ) diff --git a/game/src/test/kotlin/content/social/report/PunishmentsTest.kt b/game/src/test/kotlin/content/social/report/PunishmentsTest.kt index 40deecd27a..4582fb8391 100644 --- a/game/src/test/kotlin/content/social/report/PunishmentsTest.kt +++ b/game/src/test/kotlin/content/social/report/PunishmentsTest.kt @@ -23,10 +23,10 @@ internal class PunishmentsTest : WorldTest() { @Test fun `Expired black marks degrade`() = runTest { val player = createPlayer(name = "offender") - player["black_marks"] = listOf("7:1", "15:${Long.MAX_VALUE}") + player["black_marks"] = listOf("7:1", "15:$PERMANENT") assertEquals(1, player.blackMarks) - assertEquals(listOf("15:${Long.MAX_VALUE}"), player.activeBlackMarks()) + assertEquals(listOf("15:$PERMANENT"), player.activeBlackMarks()) } @Test @@ -35,7 +35,7 @@ internal class PunishmentsTest : WorldTest() { player.addBlackMark(Rule.BreakingRealWorldLaws) - assertTrue(player.activeBlackMarks().single().endsWith(":${Long.MAX_VALUE}")) + assertTrue(player.activeBlackMarks().single().endsWith(":$PERMANENT")) } @Test From 561edef5583e498b35cf68a4b1f5067cb4c79889 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 28 Jun 2026 06:09:19 -0700 Subject: [PATCH 13/15] refactor: rename permban command to perm_ban for consistency --- game/src/main/kotlin/content/social/report/Ban.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/game/src/main/kotlin/content/social/report/Ban.kt b/game/src/main/kotlin/content/social/report/Ban.kt index b6f9bc34e4..bd0bb3d5be 100644 --- a/game/src/main/kotlin/content/social/report/Ban.kt +++ b/game/src/main/kotlin/content/social/report/Ban.kt @@ -49,7 +49,7 @@ class Ban(val accounts: AccountDefinitions, val manager: AccountManager, val sto message("${args[0]} has been banned for $hours hours.") } - modCommand("permban", stringArg("player-name", autofill = accounts.displayNames.keys), desc = "Permanently ban a player from logging in") { args -> + modCommand("perm_ban", stringArg("player-name", autofill = accounts.displayNames.keys), desc = "Permanently ban a player from logging in") { args -> val target = Players.find(args[0]) if (target != null) { if (target.blackMarks < BLACK_MARK_LIMIT) { From ff9cc60fc6c27cbdf038407bd46f2c447d273801 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 28 Jun 2026 06:23:41 -0700 Subject: [PATCH 14/15] Fix type mismatch on time --- database/src/main/kotlin/world/gregs/voidps/storage/Tables.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/src/main/kotlin/world/gregs/voidps/storage/Tables.kt b/database/src/main/kotlin/world/gregs/voidps/storage/Tables.kt index 75eb3e2aba..4beaeb6882 100644 --- a/database/src/main/kotlin/world/gregs/voidps/storage/Tables.kt +++ b/database/src/main/kotlin/world/gregs/voidps/storage/Tables.kt @@ -165,7 +165,7 @@ internal object ReportsTable : Table("abuse_reports") { val ruleName = text("rule_name") val mute = bool("mute") val suggestion = text("suggestion") - val time = long("time") + val time = integer("time") val evidence = array("evidence") override val primaryKey = PrimaryKey(id, name = "pk_report_id") From 300265749371ff32cbb3c8ccc83e9ec9f523fd2d Mon Sep 17 00:00:00 2001 From: GregHib Date: Sun, 28 Jun 2026 20:06:12 +0100 Subject: [PATCH 15/15] Stop force logout from reconnecting --- .../kotlin/world/gregs/voidps/engine/data/AccountManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/data/AccountManager.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/data/AccountManager.kt index 1dff65ec67..058a3aac29 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/data/AccountManager.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/data/AccountManager.kt @@ -107,8 +107,8 @@ class AccountManager( return } player["logged_out"] = true + player.client?.logout() if (safely) { - player.client?.logout() player.strongQueue("logout") { // Make sure nothing else starts }