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/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..1e53a4154d --- /dev/null +++ b/data/social/report/report.vars.toml @@ -0,0 +1,10 @@ +[muted_until] +format = "int" +persist = true + +[banned_until] +format = "int" +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..4beaeb6882 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 = integer("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..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 @@ -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)?.toInt() ?: return false + return until > (System.currentTimeMillis() / 1000).toInt() + } + 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..7e84be823b --- /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 second 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: Int, + val evidence: List, +) 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 } 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..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 @@ -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 Int.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 1), 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/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"])) } 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..bd0bb3d5be --- /dev/null +++ b/game/src/main/kotlin/content/social/report/Ban.kt @@ -0,0 +1,121 @@ +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", 0] > epochSeconds() + +fun Player.ban(hours: Int = 48, rule: Rule? = null) { + this["banned_until"] = epochSeconds() + TimeUnit.HOURS.toSeconds(hours.toLong()).toInt() + addBlackMark(rule) +} + +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 = epochSeconds() + TimeUnit.HOURS.toSeconds(hours.toLong()).toInt() + val target = Players.find(args[0]) + if (target != null) { + target.ban(hours) + AuditLog.event(this, "banned", target, hours) + manager.logout(target, false) + } else if (!banOffline(args[0], 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("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) { + 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.") + } + } + + /** + * Bans an offline player's saved account and adds a black mark + */ + 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() + variables["banned_until"] = until + val marks = activeBlackMarks((variables["black_marks"] as? List<*>)?.filterIsInstance() ?: emptyList()) + variables["black_marks"] = marks + blackMark() + storage.save(listOf(save.copy(variables = variables))) + return true + } + + /** + * 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..c3a56c4ee3 --- /dev/null +++ b/game/src/main/kotlin/content/social/report/BlackMarks.kt @@ -0,0 +1,110 @@ +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 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) + +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 = epochSeconds() + return marks.filter { expiry(it) > now } +} + +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 epochSeconds() + TWELVE_MONTHS.toInt() + return "${rule?.id ?: -1}:$expiry" +} + +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.ofEpochSecond(expiry.toLong()))}" + return "$title - $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..dc14795995 --- /dev/null +++ b/game/src/main/kotlin/content/social/report/Mute.kt @@ -0,0 +1,97 @@ +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 = 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", 0] > epochSeconds() + +fun Player.mute(hours: Int = 48, rule: Rule? = null) { + this["muted_until"] = epochSeconds() + TimeUnit.HOURS.toSeconds(hours.toLong()).toInt() + addBlackMark(rule) + 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", 0] + if (until == PERMANENT) { + message("You are permanently muted because of breaking a rule.") + } else { + 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.") + } +} + +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("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]}'.") + 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..bcd87c4741 100644 --- a/game/src/main/kotlin/content/social/report/ReportAbuse.kt +++ b/game/src/main/kotlin/content/social/report/ReportAbuse.kt @@ -1,19 +1,106 @@ 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.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 +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") { - 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_player_security") + openReportAbuse(this) + } + + interfaceOption("Report Abuse", "private_chat:line*") { + openReportAbuse(this) + } + + 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 = epochSeconds(), + evidence = ChatHistory.recent(account), + ), + ) + AuditLog.event(player, "report_abuse", target ?: name, rule.name, muted) + if (muted) { + target?.mute(rule = rule) + } + 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.") + } + } + + 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/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 74a0659dab..69a1d9b1a7 100644 --- a/game/src/main/resources/game.properties +++ b/game/src/main/resources/game.properties @@ -376,6 +376,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..4582fb8391 --- /dev/null +++ b/game/src/test/kotlin/content/social/report/PunishmentsTest.kt @@ -0,0 +1,82 @@ +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:$PERMANENT") + + assertEquals(1, player.blackMarks) + assertEquals(listOf("15:$PERMANENT"), 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(":$PERMANENT")) + } + + @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 a black mark`() = runTest { + val player = createPlayer(name = "offender") + + player.ban() + + assertEquals(1, player.blackMarks) + } + + @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..2ba287ccfd --- /dev/null +++ b/game/src/test/kotlin/content/social/report/ReportAbuseTest.kt @@ -0,0 +1,243 @@ +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 `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") + 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) } }