From bad98c501341a490f82b013bc26d8b1429651ba0 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:51:22 +0000 Subject: [PATCH] Add the exp_reports plugin and move the scenario onto it Reports were the last legacy control module, kept in the lua state of each instance. They are now an exp_reports plugin which stores them on the controller: a report is immutable and holds the reported player, who made it, the reason, the instance name and when it was made, and the only operations are get, list, create and delete. In game nothing is stored. The lua module sends each request over ipc, the instance plugin asks the controller and hands the answer back with rcon, and the module prints it to whoever asked if they are still online. Answers are dropped if the instance stopped meanwhile. The module raises on_player_reported with the count and reporters of every report against the player, which report_jail and discord_alerts use, and the commands and player list button call into it. The web ui gets a /reports page: a table with search on the player, reporter and reason, a filter on the instance, delete per row and a create form which reports in the name of the web user. New reports are posted to two optional webhooks configured on the controller, a Discord channel hook as an embed and a generic JSON hook. Failures are logged rather than failing the report. Tests cover the messages, the controller against a real Controller, the instance plugin against a real Instance, and the lua module through the shared harness. --- .../module/config/gui/player_list_actions.lua | 19 +- exp_legacy/module/modules/control/reports.lua | 225 ------------------ exp_reports/controller.ts | 143 +++++++++++ exp_reports/index.ts | 83 +++++++ exp_reports/instance.ts | 95 ++++++++ exp_reports/messages.ts | 222 +++++++++++++++++ exp_reports/module/control.lua | 194 +++++++++++++++ exp_reports/module/globals.lua | 10 + exp_reports/module/locale/en.cfg | 10 + exp_reports/module/locale/zh-CN.cfg | 10 + exp_reports/module/locale/zh-TW.cfg | 10 + exp_reports/module/module.json | 12 + exp_reports/module/module_exports.lua | 3 + exp_reports/package.json | 53 +++++ exp_reports/test/controller.test.js | 193 +++++++++++++++ exp_reports/test/instance.test.js | 142 +++++++++++ exp_reports/test/messages.test.js | 62 +++++ exp_reports/test/module.test.js | 14 ++ exp_reports/test/module/env.lua | 59 +++++ exp_reports/test/module/receivers.lua | 102 ++++++++ exp_reports/test/module/requests.lua | 34 +++ exp_reports/tsconfig.browser.json | 4 + exp_reports/tsconfig.json | 7 + exp_reports/tsconfig.node.json | 5 + exp_reports/web/components/ReportsPage.tsx | 145 +++++++++++ exp_reports/web/index.tsx | 27 +++ exp_reports/webpack.config.js | 34 +++ exp_scenario/module/commands/reports.lua | 62 +---- .../module/control/discord_alerts.lua | 20 +- exp_scenario/module/control/report_jail.lua | 26 +- exp_scenario/module/locale/en.cfg | 10 - exp_scenario/module/locale/zh-CN.cfg | 10 - exp_scenario/module/locale/zh-TW.cfg | 10 - exp_scenario/module/module.json | 1 + tsconfig.json | 1 + 35 files changed, 1707 insertions(+), 350 deletions(-) delete mode 100644 exp_legacy/module/modules/control/reports.lua create mode 100644 exp_reports/controller.ts create mode 100644 exp_reports/index.ts create mode 100644 exp_reports/instance.ts create mode 100644 exp_reports/messages.ts create mode 100644 exp_reports/module/control.lua create mode 100644 exp_reports/module/globals.lua create mode 100644 exp_reports/module/locale/en.cfg create mode 100644 exp_reports/module/locale/zh-CN.cfg create mode 100644 exp_reports/module/locale/zh-TW.cfg create mode 100644 exp_reports/module/module.json create mode 100644 exp_reports/module/module_exports.lua create mode 100644 exp_reports/package.json create mode 100644 exp_reports/test/controller.test.js create mode 100644 exp_reports/test/instance.test.js create mode 100644 exp_reports/test/messages.test.js create mode 100644 exp_reports/test/module.test.js create mode 100644 exp_reports/test/module/env.lua create mode 100644 exp_reports/test/module/receivers.lua create mode 100644 exp_reports/test/module/requests.lua create mode 100644 exp_reports/tsconfig.browser.json create mode 100644 exp_reports/tsconfig.json create mode 100644 exp_reports/tsconfig.node.json create mode 100644 exp_reports/web/components/ReportsPage.tsx create mode 100644 exp_reports/web/index.tsx create mode 100644 exp_reports/webpack.config.js diff --git a/exp_legacy/module/config/gui/player_list_actions.lua b/exp_legacy/module/config/gui/player_list_actions.lua index fd631006bc..7f0a127065 100644 --- a/exp_legacy/module/config/gui/player_list_actions.lua +++ b/exp_legacy/module/config/gui/player_list_actions.lua @@ -8,7 +8,7 @@ local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") local Roles = require("modules/exp_roles") -local Reports = require("modules.exp_legacy.modules.control.reports") --- @dep modules.control.reports +local Reports = require("modules/exp_reports") local Jail = require("modules/exp_scenario/control/jail") local Colors = require("modules/exp_util/include/color") local format_player_name = ExpUtil.format_player_name_locale @@ -80,23 +80,12 @@ local bring_player = new_button("utility/import", { "exp-gui_player-list.bring-p -- @element report_player local report_player = new_button("utility/spawn_flag", { "exp-gui_player-list.report-player" }) :on_click(function(def, player, element) - local selected_player = get_action_player(player) - if Reports.is_reported(selected_player.name, player.name) then - player.print({ "exp-commands_report.already-reported" }, Colors.orange_red) - else - set_selected_action(player, "exp_scenario.command.create_report") - end + set_selected_action(player, "exp_scenario.command.create_report") end) local function report_player_callback(player, reason) - local selected_player, selected_player_color = get_action_player(player) - local by_player_name_color = format_player_name(player) - game.print{ "exp-commands_reports.response", selected_player_color, reason } - local trainee = Roles.get_role_by_name("Trainee") - for _, role in ipairs(trainee and Roles.get_higher_roles(trainee) or {}) do - role:print{ "exp-commands_reports.response-admin", selected_player_color, by_player_name_color, reason } - end - Reports.report_player(selected_player.name, player.name, reason) + local selected_player = get_action_player(player) + Reports.create_report(player, selected_player, reason) end --- Jails the action player, requires a reason diff --git a/exp_legacy/module/modules/control/reports.lua b/exp_legacy/module/modules/control/reports.lua deleted file mode 100644 index bb103ba6a2..0000000000 --- a/exp_legacy/module/modules/control/reports.lua +++ /dev/null @@ -1,225 +0,0 @@ ---[[-- Control Module - Reports - - Adds a way to report players and store report messages. - @control Reports - @alias Reports - - @usage - -- import the module from the control modules - local Reports = require("modules.exp_legacy.modules.control.reports") --- @dep modules.control.reports - - -- This will place a report on "MrBiter" (must be a valid player) the report will have been made - -- by "Cooldude2606" (must be the player name) with the reason 'Liking biters too much' this can be - -- seen by using Reports.get_report. - Reports.report_player('MrBiter', 'Cooldude2606', 'Liking biters too much') -- true - - -- The other get methods can be used to get all the reports on a player or to test if a player is reported. - Reports.get_report('MrBiter', 'Cooldude2606') -- 'Liking biters too much' - - -- This will remove the warning on 'MrBiter' (must be a valid player) which was made by 'Cooldude2606'. - Reports.remove_report('MrBiter', 'Cooldude2606') -- true - - -- This will remove all the report that have been made against 'MrBiter'. Note that the remove event will - -- be triggered once per report issused. - Reports.remove_all('MrBiter') -- true - -]] - -local Storage = require("modules/exp_util/storage") - -local valid_player = function(p) return type(p) == "userdata" and p or game.get_player(p) end - -local Reports = { - user_reports = {}, -- stores all user reports, global table - events = { - --- When a player is reported - -- @event on_player_reported - -- @tparam number player_index the player index of the player who got reported - -- @tparam string by_player_name the name of the player who made the report - -- @tparam string reason the reason given for the report - on_player_reported = script.generate_event_name(), - --- When a report is removed from a player - -- @event on_report_removed - -- @tparam number player_index the player index of the player who has the report removed - -- @tparam string reported_by_name the name of the player who made the removed report - -- @tparam string removed_by_name the name of the player who removed the report - -- @tparam number batch_count the number of reports removed in this batch, always one when not a batch - -- @tparam number batch the index of this event in a batch, always one when not a batch - on_report_removed = script.generate_event_name(), - }, -} - -local user_reports = Reports.user_reports -Storage.register(user_reports, function(tbl) - Reports.user_reports = tbl - user_reports = Reports.user_reports -end) - ---- Getters. --- Functions used to get information from reports --- @section get-functions - ---- Gets a list of all reports that a player has against them --- @tparam LuaPlayer player the player to get the report for --- @treturn table a list of all reports, key is by player name, value is reason -function Reports.get_reports(player) - player = valid_player(player) - if not player then return end - - return user_reports[player.name] or {} -end - ---- Gets a single report against a player given the name of the player who made the report --- @tparam LuaPlayer player the player to get the report for --- @tparam string by_player_name the name of the player who made the report --- @treturn ?string|nil string is the reason that the player was reported, if the player is not reported -function Reports.get_report(player, by_player_name) - player = valid_player(player) - if not player then return end - if not by_player_name then return end - - local reports = user_reports[player.name] - return reports and reports[by_player_name] -end - ---- Checks if a player is reported, option to get if reported by a certain player --- @tparam LuaPlayer player the player to check if reported --- @tparam[opt] string by_player_name when given will check if reported by this player --- @treturn boolean if the player has been reported -function Reports.is_reported(player, by_player_name) - player = valid_player(player) - if not player then return end - - local reports = user_reports[player.name] or {} - if by_player_name then - return reports[by_player_name] ~= nil - else - return table_size(reports) > 0 - end -end - ---- Counts the number of reports that a player has aganist them --- @tparam LuaPlayer player the player to count the reports for --- @tparam[opt] function custom_count when given this function will be used to count the reports --- @treturn number the number of reports that the user has -function Reports.count_reports(player, custom_count) - player = valid_player(player) - if not player then return end - - local reports = user_reports[player.name] or {} - if custom_count then - local ctn = 0 - for by_player_name, reason in pairs(reports) do - ctn = ctn + custom_count(player, by_player_name, reason) - end - - return ctn - else - return table_size(reports) - end -end - ---- Setters. --- Functions used to get information from reports --- @section set-functions - ---- Adds a report to a player, each player can only report another player once --- @tparam LuaPlayer player the player to add the report to --- @tparam string by_player_name the name of the player that is making the report --- @tparam[opt='Non given.'] string reason the reason that the player is being reported --- @treturn boolean whether the report was added successfully -function Reports.report_player(player, by_player_name, reason) - player = valid_player(player) - if not player then return end - local player_name = player.name - - if reason == nil or not reason:find("%S") then reason = "No reason given" end - - local reports = user_reports[player_name] - if not reports then - reports = {} - user_reports[player_name] = reports - end - - if reports[by_player_name] then - return false - else - reports[by_player_name] = reason - end - - script.raise_event(Reports.events.on_player_reported, { - name = Reports.events.on_player_reported, - tick = game.tick, - player_index = player.index, - by_player_name = by_player_name, - reason = reason, - }) - - return true -end - ---- Used to emit the report removed event, own function due to repeated use in Report.remove_all --- @tparam LuaPlayer player the player who is having the report removed from them --- @tparam string reported_by_name the player who had the report --- @tparam string removed_by_name the player who is clearing the report --- @tparam number batch the index of this event in a batch, always one when not a batch --- @tparam number batch_count the number of reports removed in this batch, always one when not a batch -local function report_removed_event(player, reported_by_name, removed_by_name, batch, batch_count) - script.raise_event(Reports.events.on_report_removed, { - name = Reports.events.on_report_removed, - tick = game.tick, - player_index = player.index, - reported_by_name = reported_by_name, - removed_by_name = removed_by_name, - batch_count = batch_count or 1, - batch = batch or 1, - }) -end - ---- Removes a report from a player --- @tparam LuaPlayer player the player to remove the report from --- @tparam string reported_by_name the name of the player that made the report --- @tparam string removed_by_name the name of the player who removed the report --- @treturn boolean whether the report was removed successfully -function Reports.remove_report(player, reported_by_name, removed_by_name) - player = valid_player(player) - if not player then return end - - local reports = user_reports[player.name] - if not reports then - return false - end - - local reason = reports[reported_by_name] - if not reason then - return false - end - - report_removed_event(player, reported_by_name, removed_by_name) - - reports[reported_by_name] = nil - return true -end - ---- Removes all reports from a player --- @tparam LuaPlayer player the player to remove the reports from --- @tparam string removed_by_name the name of the player who removed the report --- @treturn boolean whether the reports were removed successfully -function Reports.remove_all(player, removed_by_name) - player = valid_player(player) - if not player then return end - - local reports = user_reports[player.name] - if not reports then - return false - end - local ctn, total = 0, #reports - for reported_by_name, _ in pairs(reports) do - ctn = ctn + 1 - report_removed_event(player, reported_by_name, removed_by_name, ctn, total) - end - - user_reports[player.name] = nil - return true -end - -return Reports diff --git a/exp_reports/controller.ts b/exp_reports/controller.ts new file mode 100644 index 0000000000..f6b09a3b99 --- /dev/null +++ b/exp_reports/controller.ts @@ -0,0 +1,143 @@ +import { BaseControllerPlugin } from "@clusterio/controller"; +import * as lib from "@clusterio/lib"; +import * as messages from "./messages"; +import * as path from "node:path"; + +export class ControllerPlugin extends BaseControllerPlugin { + reports!: lib.SubscribableDatastore; + + async init() { + const databaseDirectory = this.controller.config.get("controller.database_directory"); + + this.reports = new lib.SubscribableDatastore( + ...await new lib.JsonIdDatastoreProvider( + path.join(databaseDirectory, "exp_reports", "reports.json"), + messages.ReportRecord.fromJSON.bind(messages.ReportRecord), + ).bootstrap() + ); + + this.controller.subscriptions.handle(messages.ReportUpdatedEvent, this.handleReportSubscription.bind(this)); + this.reports.on("update", this.reportsUpdated.bind(this)); + + this.controller.handle(messages.ReportListRequest, this.handleReportListRequest.bind(this)); + this.controller.handle(messages.ReportGetRequest, this.handleReportGetRequest.bind(this)); + this.controller.handle(messages.ReportCreateRequest, this.handleReportCreateRequest.bind(this)); + this.controller.handle(messages.ReportDeleteRequest, this.handleReportDeleteRequest.bind(this)); + } + + async onShutdown() { + await this.reports.save(); + } + + /** Reports are immutable, so any update which is not a deletion is a new report. */ + reportsUpdated(reports: messages.ReportRecord[]) { + this.controller.subscriptions.broadcast(new messages.ReportUpdatedEvent(reports)); + + for (const report of reports) { + if (!report.isDeleted) { + this.sendWebhooks(report); + } + } + } + + async handleReportSubscription(request: lib.SubscriptionRequest) { + const reports = [...this.reports.values()].filter(report => report.updatedAtMs > request.lastRequestTimeMs); + return reports.length ? new messages.ReportUpdatedEvent(reports) : null; + } + + /** Every report, or only those against one player. */ + listReports(playerName?: string) { + const reports = [...this.reports.values()]; + return playerName === undefined ? reports : reports.filter(report => report.playerName === playerName); + } + + async handleReportListRequest(request: messages.ReportListRequest) { + return this.listReports(request.playerName); + } + + async handleReportGetRequest(request: messages.ReportGetRequest) { + const report = this.reports.get(request.id); + if (!report) { + throw new lib.RequestError(`Report with ID ${request.id} does not exist`); + } + return report; + } + + async handleReportCreateRequest(request: messages.ReportCreateRequest, src: lib.Address) { + let byPlayerName = request.byPlayerName; + let instanceName = ""; + if (src.type === lib.Address.control) { + byPlayerName = this.controller.wsServer.controlConnections.get(src.id)!.user.name; + } else { + const instance = this.controller.instances.get(src.id); + instanceName = instance ? instance.config.get("instance.name") : String(src.id); + } + + if (!byPlayerName) { + throw new lib.RequestError("A report needs the name of the player making it"); + } + if (!request.reason.trim()) { + throw new lib.RequestError("A report needs a reason"); + } + if (this.listReports(request.playerName).some(report => report.byPlayerName === byPlayerName)) { + throw new lib.RequestError(`${byPlayerName} has already reported ${request.playerName}`); + } + + let id = Math.random() * 2 ** 31 | 0; + while (this.reports.has(id)) { + id = Math.random() * 2 ** 31 | 0; + } + + const report = new messages.ReportRecord(id, request.playerName, byPlayerName, request.reason.trim(), instanceName); + this.reports.set(report); + return report; + } + + async handleReportDeleteRequest(request: messages.ReportDeleteRequest) { + const report = this.reports.getMutable(request.id); + if (!report) { + throw new lib.RequestError(`Report with ID ${request.id} does not exist`); + } + this.reports.delete(report); + } + + /** Post a new report to the configured webhooks, failures are logged rather than failing the report. */ + sendWebhooks(report: messages.ReportRecord) { + const discordUrl = this.controller.config.get("exp_reports.discord_webhook_url"); + if (discordUrl) { + this.postWebhook(discordUrl, { + embeds: [{ + title: "Player reported", + color: 0xffcc00, + timestamp: new Date(report.updatedAtMs).toISOString(), + fields: [ + { name: "Player", value: report.playerName, inline: true }, + { name: "By", value: report.byPlayerName, inline: true }, + { name: "Instance", value: report.instanceName || "Web UI", inline: true }, + { name: "Reason", value: report.reason }, + ], + }], + }); + } + + const jsonUrl = this.controller.config.get("exp_reports.json_webhook_url"); + if (jsonUrl) { + this.postWebhook(jsonUrl, { type: "report_created", report: report.toJSON() }); + } + } + + async postWebhook(url: string, body: unknown) { + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + this.logger.warn(`Webhook ${url} responded with ${response.status}`); + } + } catch (err: any) { + this.logger.warn(`Webhook ${url} failed: ${err.message}`); + } + } +} diff --git a/exp_reports/index.ts b/exp_reports/index.ts new file mode 100644 index 0000000000..4c7459775b --- /dev/null +++ b/exp_reports/index.ts @@ -0,0 +1,83 @@ +import * as lib from "@clusterio/lib"; +import * as messages from "./messages"; + +declare module "@clusterio/lib" { + export interface ControllerConfigFields { + "exp_reports.discord_webhook_url": string | null; + "exp_reports.json_webhook_url": string | null; + } +} + +lib.definePermission({ + name: "exp_reports.report.get", + title: "Get Reports", + description: "Retrieve a specific report by id.", + grantByDefault: true, +}); +lib.definePermission({ + name: "exp_reports.report.list", + title: "List Reports", + description: "List the reports against every player, or against one player.", + grantByDefault: true, +}); +lib.definePermission({ + name: "exp_reports.report.subscribe", + title: "Subscribe to Report Updates", + description: "Receive updates when reports are made or deleted.", + grantByDefault: true, +}); +lib.definePermission({ + name: "exp_reports.report.create", + title: "Create Reports", + description: "Report a player.", + grantByDefault: false, +}); +lib.definePermission({ + name: "exp_reports.report.delete", + title: "Delete Reports", + description: "Delete reports made against a player.", + grantByDefault: false, +}); + +export const plugin: lib.PluginDeclaration = { + name: "exp_reports", + title: "ExpGaming - Reports", + description: "Clusterio plugin storing player reports on the controller", + + features: [ + "SavePatching", + "ScriptCommands", + ], + + messages: [ + messages.ReportUpdatedEvent, + + messages.ReportListRequest, + messages.ReportGetRequest, + messages.ReportCreateRequest, + messages.ReportDeleteRequest, + ], + + instanceEntrypoint: "./dist/node/instance", + + controllerEntrypoint: "./dist/node/controller", + controllerConfigFields: { + "exp_reports.discord_webhook_url": { + title: "Discord Webhook URL", + description: "Discord channel webhook which new reports are posted to as an embed.", + type: "string", + optional: true, + }, + "exp_reports.json_webhook_url": { + title: "JSON Webhook URL", + description: "URL which new reports are posted to as JSON.", + type: "string", + optional: true, + }, + }, + + webEntrypoint: "./web", + routes: [ + "/reports", + ], +}; diff --git a/exp_reports/instance.ts b/exp_reports/instance.ts new file mode 100644 index 0000000000..bf6baf1e46 --- /dev/null +++ b/exp_reports/instance.ts @@ -0,0 +1,95 @@ +import { BaseInstancePlugin } from "@clusterio/host"; +import * as messages from "./messages"; + +/** Sent by the lua side when a player reports another. */ +export type IpcReportCreate = { + player_name: string, + by_player_name: string, + reason: string, +}; + +/** Sent by the lua side to list the reports for a player, or for everyone. */ +export type IpcReportList = { + caller: string, + player_name: string | undefined, +}; + +/** Sent by the lua side to delete the reports against a player, optionally only those from one player. */ +export type IpcReportDelete = { + caller: string, + player_name: string, + by_player_name: string | undefined, +}; + +/** + * Bridges the lua side and the controller. + * + * Nothing is kept in the lua state, every request goes to the controller and + * the answer is handed back to lua once it arrives. The instance may have + * stopped by then, in which case the answer is dropped. + */ +export class InstancePlugin extends BaseInstancePlugin { + async init() { + this.instance.server.handle("exp_reports:create", this.handleCreateIPC.bind(this)); + this.instance.server.handle("exp_reports:list", this.handleListIPC.bind(this)); + this.instance.server.handle("exp_reports:delete", this.handleDeleteIPC.bind(this)); + } + + async handleCreateIPC(event: IpcReportCreate) { + try { + const report = await this.instance.sendTo("controller", new messages.ReportCreateRequest( + event.player_name, event.reason, event.by_player_name, + )); + const reports = await this.instance.sendTo("controller", new messages.ReportListRequest(event.player_name)); + await this.luaSend("receive_created", { + report: report.toJSON(), + reports: reports.map(other => other.toJSON()), + }); + } catch (err: any) { + await this.luaSend("receive_error", { caller: event.by_player_name, message: err.message }); + } + } + + async handleListIPC(event: IpcReportList) { + try { + const reports = await this.instance.sendTo("controller", new messages.ReportListRequest(event.player_name)); + await this.luaSend("receive_list", { + caller: event.caller, + player_name: event.player_name, + reports: reports.map(report => report.toJSON()), + }); + } catch (err: any) { + await this.luaSend("receive_error", { caller: event.caller, message: err.message }); + } + } + + async handleDeleteIPC(event: IpcReportDelete) { + try { + let reports = await this.instance.sendTo("controller", new messages.ReportListRequest(event.player_name)); + if (event.by_player_name !== undefined) { + reports = reports.filter(report => report.byPlayerName === event.by_player_name); + } + for (const report of reports) { + await this.instance.sendTo("controller", new messages.ReportDeleteRequest(report.id)); + } + await this.luaSend("receive_deleted", { + caller: event.caller, + player_name: event.player_name, + by_player_name: event.by_player_name, + count: reports.length, + }); + } catch (err: any) { + await this.luaSend("receive_error", { caller: event.caller, message: err.message }); + } + } + + /** Hand an answer to lua, unless the instance stopped while it was being fetched. */ + async luaSend(receiver: string, json: any) { + if (this.instance.status !== "running") { + return; + } + await this.instance.sendRcon( + `/sc exp_reports.${receiver}(helpers.json_to_table[=[${JSON.stringify(json)}]=])`, true + ); + } +} diff --git a/exp_reports/messages.ts b/exp_reports/messages.ts new file mode 100644 index 0000000000..c59c97759d --- /dev/null +++ b/exp_reports/messages.ts @@ -0,0 +1,222 @@ +import * as lib from "@clusterio/lib"; +import { Type, Static } from "@sinclair/typebox"; + +/* + Data records +*/ + +/** + * A report made against a player. + * + * Reports are immutable, so until it is deleted updatedAtMs is when the + * report was made. + */ +export class ReportRecord { + constructor( + public id: number, + public playerName: string, + public byPlayerName: string, + public reason: string, + /** Name of the instance the report was made on, empty when made from the web ui. */ + public instanceName: string, + public updatedAtMs: number = 0, + public isDeleted: boolean = false, + ) {} + + static jsonSchema = Type.Object({ + id: Type.Integer(), + player_name: Type.String(), + by_player_name: Type.String(), + reason: Type.String(), + instance_name: Type.String(), + updated_at_ms: Type.Optional(Type.Number()), + is_deleted: Type.Optional(Type.Boolean()), + }); + + toJSON() { + const json: Static = { + id: this.id, + player_name: this.playerName, + by_player_name: this.byPlayerName, + reason: this.reason, + instance_name: this.instanceName, + }; + + if (this.updatedAtMs) { + json.updated_at_ms = this.updatedAtMs; + } + + if (this.isDeleted) { + json.is_deleted = true; + } + + return json; + } + + static fromJSON(json: Static) { + return new this( + json.id, + json.player_name, + json.by_player_name, + json.reason, + json.instance_name, + json.updated_at_ms ?? 0, + json.is_deleted ?? false, + ); + } +} + +/* + Update events +*/ + +export class ReportUpdatedEvent { + declare ["constructor"]: typeof ReportUpdatedEvent; + static plugin = "exp_reports" as const; + static type = "event" as const; + static src = "controller" as const; + static dst = "control" as const; + static permission = "exp_reports.report.subscribe" as const; + + constructor( + public updates: ReportRecord[], + ) {} + + static jsonSchema = Type.Object({ + updates: Type.Array(ReportRecord.jsonSchema), + }); + + toJSON() { + return { updates: this.updates.map(report => report.toJSON()) }; + } + + static fromJSON(json: Static) { + return new this(json.updates.map(report => ReportRecord.fromJSON(report))); + } +} + +/* + Report requests +*/ + +/** List every report, or only those against one player. */ +export class ReportListRequest { + declare ["constructor"]: typeof ReportListRequest; + static plugin = "exp_reports" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = "exp_reports.report.list" as const; + static Response = lib.jsonArray(ReportRecord); + + constructor( + public playerName?: string, + ) {} + + static jsonSchema = Type.Object({ + player_name: Type.Optional(Type.String()), + }); + + toJSON() { + const json: Static = {}; + if (this.playerName !== undefined) { + json.player_name = this.playerName; + } + return json; + } + + static fromJSON(json: Static) { + return new this(json.player_name); + } +} + +export class ReportGetRequest { + declare ["constructor"]: typeof ReportGetRequest; + static plugin = "exp_reports" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = "exp_reports.report.get" as const; + static Response = ReportRecord; + + constructor( + public id: number, + ) {} + + static jsonSchema = Type.Object({ + id: Type.Integer(), + }); + + toJSON() { + return { id: this.id }; + } + + static fromJSON(json: Static) { + return new this(json.id); + } +} + +/** + * Report a player. + * + * From an instance byPlayerName is the player who made the report. From the + * web ui it is ignored and the report is made by the user of the connection. + */ +export class ReportCreateRequest { + declare ["constructor"]: typeof ReportCreateRequest; + static plugin = "exp_reports" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = "exp_reports.report.create" as const; + static Response = ReportRecord; + + constructor( + public playerName: string, + public reason: string, + public byPlayerName: string = "", + ) {} + + static jsonSchema = Type.Object({ + player_name: Type.String(), + reason: Type.String(), + by_player_name: Type.String(), + }); + + toJSON() { + return { + player_name: this.playerName, + reason: this.reason, + by_player_name: this.byPlayerName, + }; + } + + static fromJSON(json: Static) { + return new this(json.player_name, json.reason, json.by_player_name); + } +} + +export class ReportDeleteRequest { + declare ["constructor"]: typeof ReportDeleteRequest; + static plugin = "exp_reports" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = "exp_reports.report.delete" as const; + + constructor( + public id: number, + ) {} + + static jsonSchema = Type.Object({ + id: Type.Integer(), + }); + + toJSON() { + return { id: this.id }; + } + + static fromJSON(json: Static) { + return new this(json.id); + } +} diff --git a/exp_reports/module/control.lua b/exp_reports/module/control.lua new file mode 100644 index 0000000000..17b7d102d5 --- /dev/null +++ b/exp_reports/module/control.lua @@ -0,0 +1,194 @@ +--[[-- ExpReports +Lets players report each other, with the reports kept on the controller. + +Nothing is stored in the lua state. Each call sends a request to the +controller through the instance plugin, and the answer is printed to the +player who asked once it arrives, if they are still online. +]] + +local clusterio_api = require("modules/clusterio/api") +local ExpUtil = require("modules/exp_util") + +local format_player_name = ExpUtil.format_player_name_locale + +--- @class ExpReports +local ExpReports = { + --- Raised once the controller has accepted a report, the reported player is online + --- @type EventData.ExpReports.on_player_reported + on_player_reported = script.generate_event_name(), + --- Raised once the controller has deleted reports, the reported player is online + --- @type EventData.ExpReports.on_reports_deleted + on_reports_deleted = script.generate_event_name(), +} + +--- @class EventData.ExpReports.on_player_reported : EventData +--- @field player_index uint +--- @field by_player_name string +--- @field reason string +--- @field report_count number Reports against the player including this one +--- @field by_player_names string[] Who made each of those reports + +--- @class EventData.ExpReports.on_reports_deleted : EventData +--- @field player_index uint +--- @field by_player_name string Who deleted the reports +--- @field count number + +--- @class ExpReports.Report +--- @field id number +--- @field player_name string +--- @field by_player_name string +--- @field reason string +--- @field instance_name string +--- @field updated_at_ms number + +local error_settings = { color = ExpUtil.color.orange_red, sound_path = "utility/wire_pickup" } + +--- Report a player, the outcome is printed once the controller answers +--- @param player LuaPlayer +--- @param reported_player LuaPlayer +--- @param reason string +function ExpReports.create_report(player, reported_player, reason) + clusterio_api.send_json("exp_reports:create", { + player_name = reported_player.name, + by_player_name = player.name, + reason = reason, + }) +end + +--- List the reports against a player, or against everyone, printed to the player once the controller answers +--- @param player LuaPlayer +--- @param reported_player LuaPlayer? +function ExpReports.list_reports(player, reported_player) + clusterio_api.send_json("exp_reports:list", { + caller = player.name, + player_name = reported_player and reported_player.name or nil, + }) +end + +--- Delete the reports against a player, or only those from one player +--- @param player LuaPlayer +--- @param reported_player LuaPlayer +--- @param by_player_name string? +function ExpReports.delete_reports(player, reported_player, by_player_name) + clusterio_api.send_json("exp_reports:delete", { + caller = player.name, + player_name = reported_player.name, + by_player_name = by_player_name, + }) +end + +--- A player who can still be printed to, nil once they have left +--- @param name string +--- @return LuaPlayer? +local function get_online_player(name) + local player = game.get_player(name) + if player and player.valid and player.connected then + return player + end + return nil +end + +--- The controller accepted a report +--- @param payload { report: ExpReports.Report, reports: ExpReports.Report[] } +function ExpReports.receive_created(payload) + local report = payload.report + local player_name = format_player_name(report.player_name) + local by_player_name = format_player_name(report.by_player_name) + for _, player in pairs(game.connected_players) do + if player.admin then + player.print{ "exp-reports.created-admin", player_name, by_player_name, report.reason } + else + player.print{ "exp-reports.created", player_name, report.reason } + end + end + + local player = get_online_player(report.player_name) + if not player then return end + + local by_player_names = {} + for index, other in ipairs(payload.reports) do + by_player_names[index] = other.by_player_name + end + + script.raise_event(ExpReports.on_player_reported, { + name = ExpReports.on_player_reported, + tick = game.tick, + player_index = player.index, + by_player_name = report.by_player_name, + reason = report.reason, + report_count = #payload.reports, + by_player_names = by_player_names, + }) +end + +--- The controller answered a list request +--- @param payload { caller: string, player_name: string?, reports: ExpReports.Report[] } +function ExpReports.receive_list(payload) + local caller = get_online_player(payload.caller) + if not caller then return end + + if payload.player_name then + caller.print{ "exp-reports.list-title", format_player_name(payload.player_name), #payload.reports } + for _, report in ipairs(payload.reports) do + caller.print{ "exp-reports.list-entry", format_player_name(report.by_player_name), report.reason } + end + return + end + + local counts = {} --- @type table + local names = {} --- @type string[] + for _, report in ipairs(payload.reports) do + if not counts[report.player_name] then + names[#names + 1] = report.player_name + counts[report.player_name] = 0 + end + counts[report.player_name] = counts[report.player_name] + 1 + end + + if #names == 0 then + caller.print{ "exp-reports.list-all-none" } + return + end + + table.sort(names) + caller.print{ "exp-reports.list-all-title" } + for _, name in ipairs(names) do + caller.print{ "exp-reports.list-all-entry", format_player_name(name), counts[name] } + end +end + +--- The controller deleted reports +--- @param payload { caller: string, player_name: string, by_player_name: string?, count: number } +function ExpReports.receive_deleted(payload) + if payload.count == 0 then + local caller = get_online_player(payload.caller) + if caller then + caller.print({ "exp-reports.deleted-none", format_player_name(payload.player_name) }, error_settings) + end + return + end + + game.print{ "exp-reports.deleted", format_player_name(payload.player_name), payload.count, format_player_name(payload.caller) } + + local player = get_online_player(payload.player_name) + if not player then return end + + script.raise_event(ExpReports.on_reports_deleted, { + name = ExpReports.on_reports_deleted, + tick = game.tick, + player_index = player.index, + by_player_name = payload.caller, + count = payload.count, + }) +end + +--- The controller refused a request, the message is printed to whoever asked +--- @param payload { caller: string, message: string } +function ExpReports.receive_error(payload) + local caller = get_online_player(payload.caller) + if caller then + caller.print(payload.message, error_settings) + end +end + +return ExpReports diff --git a/exp_reports/module/globals.lua b/exp_reports/module/globals.lua new file mode 100644 index 0000000000..e54584891e --- /dev/null +++ b/exp_reports/module/globals.lua @@ -0,0 +1,10 @@ +--[[ +It is best practice to not expose any globals because all modules share a global environment +However, sometimes you need globals, for example to access functions within rcon commands +Therefore, we advise that this should be the only file in your module to expose globals +]] + +--- @diagnostic disable: global-in-non-module + +-- Access using `/sc exp_reports.foo()` +exp_reports = require("modules/exp_reports/control") diff --git a/exp_reports/module/locale/en.cfg b/exp_reports/module/locale/en.cfg new file mode 100644 index 0000000000..efeb54c3d3 --- /dev/null +++ b/exp_reports/module/locale/en.cfg @@ -0,0 +1,10 @@ +[exp-reports] +created=__1__ was reported for __2__. +created-admin=__1__ was reported by __2__ for __3__. +list-title=__1__ has __2__ __plural_for_parameter__2__{1=report|rest=reports}__ against them: +list-entry=__1__: __2__ +list-all-title=The following players have reports against them: +list-all-entry=__1__: __2__ __plural_for_parameter__2__{1=report|rest=reports}__ +list-all-none=No players have reports against them. +deleted=__1__ had __2__ __plural_for_parameter__2__{1=report|rest=reports}__ removed by __3__. +deleted-none=__1__ has no reports against them. diff --git a/exp_reports/module/locale/zh-CN.cfg b/exp_reports/module/locale/zh-CN.cfg new file mode 100644 index 0000000000..f17f0dcb5d --- /dev/null +++ b/exp_reports/module/locale/zh-CN.cfg @@ -0,0 +1,10 @@ +[exp-reports] +created=__1__ 因 __2__ 被舉報了。 +created-admin=__1__ 因 __3__ 被 __2__ 舉報了。 +list-title=__1__ 有 __2__ 項被舉報的紀錄: +list-entry=__1__: __2__ +list-all-title=該用戶現在的被舉報紀錄: +list-all-entry=__1__: __2__ +list-all-none=沒有用戶被舉報。 +deleted=__1__ 被舉報的 __2__ 個紀錄已被 __3__ 清除。 +deleted-none=__1__ 沒有被舉報的紀錄。 diff --git a/exp_reports/module/locale/zh-TW.cfg b/exp_reports/module/locale/zh-TW.cfg new file mode 100644 index 0000000000..f17f0dcb5d --- /dev/null +++ b/exp_reports/module/locale/zh-TW.cfg @@ -0,0 +1,10 @@ +[exp-reports] +created=__1__ 因 __2__ 被舉報了。 +created-admin=__1__ 因 __3__ 被 __2__ 舉報了。 +list-title=__1__ 有 __2__ 項被舉報的紀錄: +list-entry=__1__: __2__ +list-all-title=該用戶現在的被舉報紀錄: +list-all-entry=__1__: __2__ +list-all-none=沒有用戶被舉報。 +deleted=__1__ 被舉報的 __2__ 個紀錄已被 __3__ 清除。 +deleted-none=__1__ 沒有被舉報的紀錄。 diff --git a/exp_reports/module/module.json b/exp_reports/module/module.json new file mode 100644 index 0000000000..f53fc5d71e --- /dev/null +++ b/exp_reports/module/module.json @@ -0,0 +1,12 @@ +{ + "name": "exp_reports", + "load": [ + ], + "require": [ + "globals.lua" + ], + "dependencies": { + "clusterio": "*", + "exp_util": "*" + } +} diff --git a/exp_reports/module/module_exports.lua b/exp_reports/module/module_exports.lua new file mode 100644 index 0000000000..052cf658ac --- /dev/null +++ b/exp_reports/module/module_exports.lua @@ -0,0 +1,3 @@ + +-- Access the exports from other modules using require("modules/exp_reports") +return require("modules/exp_reports/control") diff --git a/exp_reports/package.json b/exp_reports/package.json new file mode 100644 index 0000000000..daef5451ea --- /dev/null +++ b/exp_reports/package.json @@ -0,0 +1,53 @@ +{ + "name": "@expcluster/reports", + "version": "7.0.1", + "description": "Clusterio plugin storing player reports on the controller", + "author": "Cooldude2606 ", + "license": "MIT", + "repository": "explosivegaming/ExpCluster", + "main": "dist/node/index.js", + "scripts": { + "prepare": "tsc --build && webpack-cli --env production", + "test": "tap --disable-coverage --allow-empty-coverage test/*.test.js", + "coverage": "tap --coverage-report=text test/*.test.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", + "@clusterio/lib": "workspace:^", + "@clusterio/web_ui": "workspace:^" + }, + "devDependencies": { + "@ant-design/icons": "^6.2.3", + "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", + "@clusterio/lib": "workspace:^", + "@clusterio/web_ui": "workspace:^", + "@types/node": "catalog:", + "@types/react": "catalog:", + "antd": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-router-dom": "catalog:", + "typescript": "catalog:", + "webpack": "catalog:", + "fengari": "^0.1.5", + "tap": "^21.1.0", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" + }, + "dependencies": { + "@sinclair/typebox": "catalog:" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "clusterio", + "clusterio-plugin", + "factorio" + ] +} diff --git a/exp_reports/test/controller.test.js b/exp_reports/test/controller.test.js new file mode 100644 index 0000000000..62782e788d --- /dev/null +++ b/exp_reports/test/controller.test.js @@ -0,0 +1,193 @@ +"use strict"; +const t = require("tap"); +const lib = require("@clusterio/lib"); +const { Controller, InstanceRecord } = require("@clusterio/controller"); +const { ControllerPlugin } = require("../dist/node/controller"); +const messages = require("../dist/node/messages"); +const { plugin: pluginDeclaration } = require("../dist/node/index"); + +// The controller validates message classes against the link registry, and the +// plugin's config fields must be defined before a ControllerConfig can set them +for (const Message of pluginDeclaration.messages) { + lib.Link.register(Message); +} +lib.addPluginConfigFields([pluginDeclaration]); + +const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} }; + +// Silence the singleton logger to avoid polluting the test output +lib.logger.silent = true; +t.after(() => { + lib.logger.silent = false; +}); + +const fromInstance = lib.Address.fromShorthand({ instanceId: 1 }); +const fromUnknownInstance = lib.Address.fromShorthand({ instanceId: 9 }); +const fromControl = lib.Address.fromShorthand({ controlId: 7 }); + +/** Build the plugin around a real controller, which is side effect free while not started. */ +async function startPlugin(t2, { config = {} } = {}) { + const controllerConfig = new lib.ControllerConfig("controller", { + "controller.database_directory": t2.testdir(), + ...config, + }); + const controller = new Controller(logger, [], controllerConfig); + + const instanceConfig = new lib.InstanceConfig("controller"); + instanceConfig.set("instance.id", 1); + instanceConfig.set("instance.name", "EXP"); + controller.instances.records.set(new InstanceRecord(instanceConfig, "running")); + controller.wsServer.controlConnections.set(7, { user: { name: "admin" } }); + + // Spies which record and then defer to the real behaviour + const state = { broadcasts: [], posts: [], warnings: [] }; + const broadcast = controller.subscriptions.broadcast.bind(controller.subscriptions); + controller.subscriptions.broadcast = event => { + state.broadcasts.push(event); + broadcast(event); + }; + + const plugin = new ControllerPlugin({ name: "exp_reports" }, controller, undefined, logger); + plugin.logger = { ...logger, warn: message => state.warnings.push(message) }; + plugin.postWebhook = async (url, body) => { state.posts.push({ url, body }); }; + await plugin.init(); + return { plugin, controller, state }; +} + +t.test("class ControllerPlugin", t2 => { + t2.test(".handleReportCreateRequest() from an instance records the instance and the reporter", async t3 => { + const { plugin } = await startPlugin(t3); + const report = await plugin.handleReportCreateRequest( + new messages.ReportCreateRequest("bob", " griefing ", "alice"), fromInstance, + ); + + t3.strictSame(report.playerName, "bob"); + t3.strictSame(report.byPlayerName, "alice", "the reporter comes from the request"); + t3.strictSame(report.reason, "griefing", "the reason is trimmed"); + t3.strictSame(report.instanceName, "EXP", "the instance is named"); + t3.ok(report.updatedAtMs > 0, "the report is timestamped"); + t3.strictSame(plugin.listReports(), [report], "the report is stored"); + + const unknown = await plugin.handleReportCreateRequest( + new messages.ReportCreateRequest("carol", "spam", "alice"), fromUnknownInstance, + ); + t3.strictSame(unknown.instanceName, "9", "an unknown instance is named by its id"); + }); + + t2.test(".handleReportCreateRequest() from the web ui uses the user of the connection", async t3 => { + const { plugin } = await startPlugin(t3); + const report = await plugin.handleReportCreateRequest( + new messages.ReportCreateRequest("bob", "griefing", "someone-else"), fromControl, + ); + + t3.strictSame(report.byPlayerName, "admin", "the reporter is the connected user"); + t3.strictSame(report.instanceName, "", "there is no instance"); + }); + + t2.test(".handleReportCreateRequest() rejects reports which can not be stored", async t3 => { + const { plugin } = await startPlugin(t3); + await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance); + + await t3.rejects( + plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "again", "alice"), fromInstance), + { message: "alice has already reported bob" }, + "a player can only report another once", + ); + await t3.rejects( + plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", " ", "carol"), fromInstance), + { message: "A report needs a reason" }, + "a reason is required", + ); + await t3.rejects( + plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "spam", ""), fromInstance), + { message: "A report needs the name of the player making it" }, + "an instance must name the reporter", + ); + t3.strictSame(plugin.listReports().length, 1, "nothing else was stored"); + }); + + t2.test(".handleReportListRequest() and .handleReportGetRequest() find reports", async t3 => { + const { plugin } = await startPlugin(t3); + const first = await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance); + await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("carol", "spam", "alice"), fromInstance); + + t3.strictSame((await plugin.handleReportListRequest(new messages.ReportListRequest())).length, 2, "everything is listed"); + t3.strictSame( + await plugin.handleReportListRequest(new messages.ReportListRequest("bob")), [first], + "a player's reports are listed", + ); + t3.strictSame(await plugin.handleReportGetRequest(new messages.ReportGetRequest(first.id)), first); + await t3.rejects( + plugin.handleReportGetRequest(new messages.ReportGetRequest(123)), + { message: "Report with ID 123 does not exist" }, + ); + }); + + t2.test(".handleReportDeleteRequest() removes the report and broadcasts the deletion", async t3 => { + const { plugin, state } = await startPlugin(t3); + const report = await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance); + + state.broadcasts.length = 0; + await plugin.handleReportDeleteRequest(new messages.ReportDeleteRequest(report.id)); + t3.strictSame(plugin.listReports(), [], "the report is gone"); + t3.ok( + state.broadcasts.some(event => event instanceof messages.ReportUpdatedEvent && event.updates[0].isDeleted), + "subscribers are told it was deleted", + ); + await t3.rejects( + plugin.handleReportDeleteRequest(new messages.ReportDeleteRequest(report.id)), + { message: `Report with ID ${report.id} does not exist` }, + ); + }); + + t2.test(".handleReportSubscription() replays only newer records", async t3 => { + const { plugin } = await startPlugin(t3); + const report = await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance); + + const all = await plugin.handleReportSubscription({ lastRequestTimeMs: 0 }); + t3.strictSame(all.updates, [report], "everything is replayed from the start"); + const none = await plugin.handleReportSubscription({ lastRequestTimeMs: report.updatedAtMs }); + t3.strictSame(none, null, "nothing is replayed when up to date"); + }); + + t2.test(".sendWebhooks() posts new reports to the configured hooks", async t3 => { + const { plugin, state } = await startPlugin(t3, { config: { + "exp_reports.discord_webhook_url": "https://discord.example/hook", + "exp_reports.json_webhook_url": "https://json.example/hook", + } }); + const report = await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance); + + t3.strictSame(state.posts.map(post => post.url), ["https://discord.example/hook", "https://json.example/hook"]); + const embed = state.posts[0].body.embeds[0]; + t3.strictSame(embed.fields.map(field => field.value), ["bob", "alice", "EXP", "griefing"], "the embed names the report"); + t3.strictSame(state.posts[1].body, { type: "report_created", report: report.toJSON() }, "the json hook gets the record"); + + state.posts.length = 0; + await plugin.handleReportDeleteRequest(new messages.ReportDeleteRequest(report.id)); + t3.strictSame(state.posts, [], "deletions are not posted"); + }); + + t2.test(".sendWebhooks() does nothing without hooks", async t3 => { + const { plugin, state } = await startPlugin(t3); + await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance); + t3.strictSame(state.posts, []); + }); + + t2.test(".postWebhook() logs rather than throws when the hook fails", async t3 => { + const { plugin, state } = await startPlugin(t3); + plugin.postWebhook = ControllerPlugin.prototype.postWebhook; + + const realFetch = globalThis.fetch; + globalThis.fetch = async () => { throw new Error("connection refused"); }; + t3.teardown(() => { globalThis.fetch = realFetch; }); + + await plugin.postWebhook("https://down.example/hook", {}); + t3.strictSame(state.warnings, ["Webhook https://down.example/hook failed: connection refused"]); + + globalThis.fetch = async () => ({ ok: false, status: 404 }); + await plugin.postWebhook("https://down.example/hook", {}); + t3.strictSame(state.warnings[1], "Webhook https://down.example/hook responded with 404"); + }); + + t2.end(); +}); diff --git a/exp_reports/test/instance.test.js b/exp_reports/test/instance.test.js new file mode 100644 index 0000000000..6c150aa346 --- /dev/null +++ b/exp_reports/test/instance.test.js @@ -0,0 +1,142 @@ +"use strict"; +const t = require("tap"); +const lib = require("@clusterio/lib"); +const { Instance } = require("@clusterio/host"); +const { InstancePlugin } = require("../dist/node/instance"); +const { plugin: pluginDeclaration } = require("../dist/node/index"); +const messages = require("../dist/node/messages"); + +// The instance validates message classes against the link registry +for (const Message of pluginDeclaration.messages) { + lib.Link.register(Message); +} + +class TestConnector extends lib.BaseConnector { + constructor() { + super(lib.Address.fromShorthand({ instanceId: 1 }), lib.Address.fromShorthand({ hostId: 1 })); + this.valid = true; + this.connected = true; + this.hasSession = true; + } + + send() {} +} + +const report = (id, playerName, byPlayerName) => new messages.ReportRecord(id, playerName, byPlayerName, "griefing", "EXP", 1000); + +/** Build a plugin around a real running instance with spies on what leaves it. */ +async function startPlugin(t2, { reports = [report(1, "bob", "alice"), report(2, "bob", "carol")] } = {}) { + const instanceConfig = new lib.InstanceConfig("host"); + instanceConfig.set("instance.id", 1); + instanceConfig.set("instance.name", "test"); + + const instance = new Instance( + { assignGamePort: () => 1 }, new TestConnector(), t2.testdir(), "factorioDir", instanceConfig + ); + + // Spies which record the messages and commands leaving the instance + const state = { sent: [], rcons: [], failNext: null }; + instance.server = { + handle: () => {}, + sendRcon: async command => { + state.rcons.push(command); + return ""; + }, + }; + instance.sendTo = async (dst, request) => { + state.sent.push(request); + if (state.failNext) { + const error = state.failNext; + state.failNext = null; + throw error; + } + if (request instanceof messages.ReportCreateRequest) { + return report(3, request.playerName, request.byPlayerName); + } + if (request instanceof messages.ReportListRequest) { + return reports.filter(other => request.playerName === undefined || other.playerName === request.playerName); + } + return undefined; + }; + + instance.notifyStatus("running"); + state.sent.length = 0; + + const plugin = new InstancePlugin({ name: "exp_reports" }, instance, {}); + await plugin.init(); + return { plugin, instance, state }; +} + +/** The lua receiver and payload of a recorded rcon command. */ +function decodeRcon(command) { + const match = command.match(/^\/sc exp_reports\.(\w+)\(helpers\.json_to_table\[=\[(.*)\]=\]\)$/s); + return { receiver: match[1], payload: JSON.parse(match[2]) }; +} + +t.test("class InstancePlugin", t2 => { + t2.test(".handleCreateIPC() creates the report and hands it to lua with the others against the player", async t3 => { + const { plugin, state } = await startPlugin(t3); + await plugin.handleCreateIPC({ player_name: "bob", by_player_name: "dave", reason: "griefing" }); + + const create = state.sent.find(request => request instanceof messages.ReportCreateRequest); + t3.strictSame([create.playerName, create.byPlayerName, create.reason], ["bob", "dave", "griefing"]); + t3.ok(state.sent.some(request => request instanceof messages.ReportListRequest && request.playerName === "bob"), + "the reports against the player are requested"); + + const { receiver, payload } = decodeRcon(state.rcons[0]); + t3.strictSame(receiver, "receive_created"); + t3.strictSame(payload.report.by_player_name, "dave", "the new report is sent"); + t3.strictSame(payload.reports.map(other => other.by_player_name), ["alice", "carol"], "along with the existing ones"); + }); + + t2.test(".handleCreateIPC() prints a refusal to the reporter", async t3 => { + const { plugin, state } = await startPlugin(t3); + state.failNext = new lib.RequestError("dave has already reported bob"); + await plugin.handleCreateIPC({ player_name: "bob", by_player_name: "dave", reason: "griefing" }); + + const { receiver, payload } = decodeRcon(state.rcons[0]); + t3.strictSame(receiver, "receive_error"); + t3.strictSame(payload, { caller: "dave", message: "dave has already reported bob" }); + }); + + t2.test(".handleListIPC() lists the reports for the caller", async t3 => { + const { plugin, state } = await startPlugin(t3); + await plugin.handleListIPC({ caller: "admin", player_name: "bob" }); + await plugin.handleListIPC({ caller: "admin", player_name: undefined }); + + const forPlayer = decodeRcon(state.rcons[0]); + t3.strictSame(forPlayer.receiver, "receive_list"); + t3.strictSame([forPlayer.payload.caller, forPlayer.payload.player_name], ["admin", "bob"]); + t3.strictSame(forPlayer.payload.reports.length, 2); + + const forAll = decodeRcon(state.rcons[1]); + t3.strictSame(forAll.payload.player_name, undefined, "no player when listing everyone"); + }); + + t2.test(".handleDeleteIPC() deletes only the matching reports", async t3 => { + const { plugin, state } = await startPlugin(t3); + await plugin.handleDeleteIPC({ caller: "admin", player_name: "bob", by_player_name: "carol" }); + + const deletes = state.sent.filter(request => request instanceof messages.ReportDeleteRequest); + t3.strictSame(deletes.map(request => request.id), [2], "only carol's report is deleted"); + const { receiver, payload } = decodeRcon(state.rcons[0]); + t3.strictSame(receiver, "receive_deleted"); + t3.strictSame(payload, { caller: "admin", player_name: "bob", by_player_name: "carol", count: 1 }); + + state.sent.length = 0; + await plugin.handleDeleteIPC({ caller: "admin", player_name: "bob", by_player_name: undefined }); + t3.strictSame( + state.sent.filter(request => request instanceof messages.ReportDeleteRequest).length, 2, + "every report is deleted without a reporter", + ); + }); + + t2.test(".luaSend() drops answers once the instance has stopped", async t3 => { + const { plugin, instance, state } = await startPlugin(t3); + instance.notifyStatus("stopped"); + await plugin.handleListIPC({ caller: "admin", player_name: undefined }); + t3.strictSame(state.rcons, [], "nothing is sent to a stopped instance"); + }); + + t2.end(); +}); diff --git a/exp_reports/test/messages.test.js b/exp_reports/test/messages.test.js new file mode 100644 index 0000000000..1471eebd7f --- /dev/null +++ b/exp_reports/test/messages.test.js @@ -0,0 +1,62 @@ +"use strict"; +const t = require("tap"); +const messages = require("../dist/node/messages"); +const { testMatrix, testRoundTripJsonSerialisable } = require("../../test/common"); + +const sampleReport = new messages.ReportRecord(7, "bob", "alice", "griefing", "EXP", 12345); + +t.test("class ReportRecord", t2 => { + testRoundTripJsonSerialisable(t2, messages.ReportRecord, testMatrix( + [7], // id + ["bob"], // playerName + ["alice"], // byPlayerName + ["griefing"], // reason + ["", "EXP"], // instanceName + [0, 12345], // updatedAtMs + [false, true], // isDeleted + )); + + t2.end(); +}); + +t.test("class ReportUpdatedEvent", t2 => { + testRoundTripJsonSerialisable(t2, messages.ReportUpdatedEvent, testMatrix( + [[], [sampleReport]], // updates + )); + + t2.end(); +}); + +t.test("class ReportListRequest", t2 => { + testRoundTripJsonSerialisable(t2, messages.ReportListRequest, testMatrix( + [undefined, "bob"], // playerName + )); + + t2.end(); +}); + +t.test("class ReportGetRequest", t2 => { + testRoundTripJsonSerialisable(t2, messages.ReportGetRequest, testMatrix( + [7], // id + )); + + t2.end(); +}); + +t.test("class ReportCreateRequest", t2 => { + testRoundTripJsonSerialisable(t2, messages.ReportCreateRequest, testMatrix( + ["bob"], // playerName + ["griefing"], // reason + ["", "alice"], // byPlayerName + )); + + t2.end(); +}); + +t.test("class ReportDeleteRequest", t2 => { + testRoundTripJsonSerialisable(t2, messages.ReportDeleteRequest, testMatrix( + [7], // id + )); + + t2.end(); +}); diff --git a/exp_reports/test/module.test.js b/exp_reports/test/module.test.js new file mode 100644 index 0000000000..7017a1e347 --- /dev/null +++ b/exp_reports/test/module.test.js @@ -0,0 +1,14 @@ +"use strict"; +const path = require("node:path"); +const t = require("tap"); +const { reportLuaTests } = require("../../test/lua/runner"); + +const envFile = path.join(__dirname, "module", "env.lua"); + +// Each file runs in its own lua state, and each test in a fresh environment +t.test("control.lua", t2 => { + for (const file of ["requests.lua", "receivers.lua"]) { + t2.test(file, t3 => reportLuaTests(t3, envFile, path.join(__dirname, "module", file))); + } + t2.end(); +}); diff --git a/exp_reports/test/module/env.lua b/exp_reports/test/module/env.lua new file mode 100644 index 0000000000..106eb012e6 --- /dev/null +++ b/exp_reports/test/module/env.lua @@ -0,0 +1,59 @@ +--[[-- Test environment for module/control.lua +Extends the shared stubs with a fresh copy of the reports module, a stub of +the exp_util functions it uses, and helpers to build the payloads the +instance plugin sends. + +Run as a chunk by test/lua/runner.js, receiving the shared folder. The suite +returned here becomes `...` in each test file. +]] + +local shared_root = ... --- @type string +local source = assert(debug.getinfo(1, "S")).source:gsub("\\", "/") +local plugin_root = assert(source:match("^@(.*)/test/module/env%.lua$")) + +local Framework = assert(loadfile(shared_root .. "/framework.lua"))() --- @type Framework + +--- A stubbed player which also carries the admin flag the module reads +--- @class ExpReports.TestPlayer : Stubs.Player +--- @field admin boolean + +--- The environment given to each test: the stubs extended with a fresh copy +--- of the reports module and the fixture helpers +--- @class ExpReports.TestEnv : Stubs +--- @field Reports ExpReports A fresh copy of the reports module +--- @field add_player fun(name: string, is_connected: boolean?, is_admin: boolean?): ExpReports.TestPlayer +--- @field report fun(player_name: string, by_player_name: string, reason: string?): ExpReports.Report A report as the controller sends it + +return Framework.suite(function(env) + --- @cast env ExpReports.TestEnv + env.extend_requires{ + ["modules/exp_util"] = { + format_player_name_locale = function(name) return name end, + color = { orange_red = "orange_red" }, + }, + } + env.Reports = assert(loadfile(plugin_root .. "/module/control.lua"))() --- @type ExpReports + + -- The module reads player.admin, which the strict stub raises on unless it is set + local add_player = env.add_player + function env.add_player(name, is_connected, is_admin) + local player = add_player(name, is_connected) --[[@as ExpReports.TestPlayer]] + player.admin = is_admin == true + return player + end + + local next_id = 0 + function env.report(player_name, by_player_name, reason) + next_id = next_id + 1 + return { + id = next_id, + player_name = player_name, + by_player_name = by_player_name, + reason = reason or "griefing", + instance_name = "test", + updated_at_ms = 1000, + } + end + + return env +end) diff --git a/exp_reports/test/module/receivers.lua b/exp_reports/test/module/receivers.lua new file mode 100644 index 0000000000..80a3d92057 --- /dev/null +++ b/exp_reports/test/module/receivers.lua @@ -0,0 +1,102 @@ +local Suite = ... --- @type Suite + +Suite.test("receive_created() tells everyone and raises on_player_reported", function(env) + env.add_player("alice", true, true) + local bob = env.add_player("bob") + env.add_player("carol") + local report = env.report("bob", "alice") + env.Reports.receive_created{ report = report, reports = { report, env.report("bob", "carol") } } + + Suite.eq(env.printed, { + { to = "alice", { "exp-reports.created-admin", "bob", "alice", "griefing" } }, + { to = "bob", { "exp-reports.created", "bob", "griefing" } }, + { to = "carol", { "exp-reports.created", "bob", "griefing" } }, + }, "admins see who reported, everyone else only the reason") + Suite.eq(env.events, { + { + name = env.Reports.on_player_reported, + tick = 1, + player_index = bob.index, + by_player_name = "alice", + reason = "griefing", + report_count = 2, + by_player_names = { "alice", "carol" }, + }, + }, "the event carries every report against the player") +end) + +Suite.test("receive_created() raises nothing when the reported player has left", function(env) + env.add_player("alice") + env.add_player("bob", false) + local report = env.report("bob", "alice") + env.Reports.receive_created{ report = report, reports = { report } } + Suite.eq(env.printed, { { to = "alice", { "exp-reports.created", "bob", "griefing" } } }, "the report is still announced") + Suite.empty(env.events, "there is nobody to act on") +end) + +Suite.test("receive_list() prints the reports against a player to the caller", function(env) + env.add_player("alice") + env.Reports.receive_list{ + caller = "alice", + player_name = "bob", + reports = { env.report("bob", "carol", "spam"), env.report("bob", "dave", "griefing") }, + } + Suite.eq(env.printed, { + { to = "alice", { "exp-reports.list-title", "bob", 2 } }, + { to = "alice", { "exp-reports.list-entry", "carol", "spam" } }, + { to = "alice", { "exp-reports.list-entry", "dave", "griefing" } }, + }, "the title is followed by one line per report") +end) + +Suite.test("receive_list() prints how many reports each player has", function(env) + env.add_player("alice") + env.Reports.receive_list{ + caller = "alice", + reports = { env.report("dave", "alice"), env.report("bob", "carol"), env.report("dave", "carol") }, + } + Suite.eq(env.printed, { + { to = "alice", { "exp-reports.list-all-title" } }, + { to = "alice", { "exp-reports.list-all-entry", "bob", 1 } }, + { to = "alice", { "exp-reports.list-all-entry", "dave", 2 } }, + }, "players are listed by name with their count") +end) + +Suite.test("receive_list() says when nobody is reported", function(env) + env.add_player("alice") + env.Reports.receive_list{ caller = "alice", reports = {} } + Suite.eq(env.printed, { { to = "alice", { "exp-reports.list-all-none" } } }, "the caller is told there are none") +end) + +Suite.test("receive_list() prints nothing once the caller has left", function(env) + env.add_player("alice", false) + env.Reports.receive_list{ caller = "alice", reports = { env.report("bob", "carol") } } + Suite.empty(env.printed, "nothing is printed") +end) + +Suite.test("receive_deleted() announces the deletion and raises on_reports_deleted", function(env) + env.add_player("alice") + local bob = env.add_player("bob") + env.Reports.receive_deleted{ caller = "alice", player_name = "bob", count = 2 } + Suite.eq(env.printed, { { "exp-reports.deleted", "bob", 2, "alice" } }, "everyone is told") + Suite.eq(env.events, { + { name = env.Reports.on_reports_deleted, tick = 1, player_index = bob.index, by_player_name = "alice", count = 2 }, + }, "the event names who deleted how many") +end) + +Suite.test("receive_deleted() tells the caller when there was nothing to delete", function(env) + env.add_player("alice") + env.add_player("bob") + env.Reports.receive_deleted{ caller = "alice", player_name = "bob", count = 0 } + Suite.eq(env.printed, { { to = "alice", { "exp-reports.deleted-none", "bob" } } }, "only the caller is told") + Suite.empty(env.events, "nothing was deleted") +end) + +Suite.test("receive_error() prints the refusal to the caller while they are online", function(env) + env.add_player("alice") + env.add_player("bob", false) + env.Reports.receive_error{ caller = "alice", message = "alice has already reported bob" } + env.Reports.receive_error{ caller = "bob", message = "gone" } + Suite.eq(env.printed, { { to = "alice", "alice has already reported bob" } }, "the offline player is skipped") +end) + +return Suite.run() diff --git a/exp_reports/test/module/requests.lua b/exp_reports/test/module/requests.lua new file mode 100644 index 0000000000..f50aaac4ea --- /dev/null +++ b/exp_reports/test/module/requests.lua @@ -0,0 +1,34 @@ +local Suite = ... --- @type Suite + +Suite.test("create_report() sends the report to the controller", function(env) + local alice = env.add_player("alice") + local bob = env.add_player("bob") + env.Reports.create_report(alice, bob, "griefing") + Suite.eq(env.sent, { + { channel = "exp_reports:create", data = { player_name = "bob", by_player_name = "alice", reason = "griefing" } }, + }, "the report names both players") +end) + +Suite.test("list_reports() sends the caller and the player", function(env) + local alice = env.add_player("alice") + local bob = env.add_player("bob") + env.Reports.list_reports(alice, bob) + env.Reports.list_reports(alice) + Suite.eq(env.sent, { + { channel = "exp_reports:list", data = { caller = "alice", player_name = "bob" } }, + { channel = "exp_reports:list", data = { caller = "alice" } }, + }, "the player is left out when listing everyone") +end) + +Suite.test("delete_reports() sends the caller, the player and the reporter", function(env) + local alice = env.add_player("alice") + local bob = env.add_player("bob") + env.Reports.delete_reports(alice, bob, "carol") + env.Reports.delete_reports(alice, bob) + Suite.eq(env.sent, { + { channel = "exp_reports:delete", data = { caller = "alice", player_name = "bob", by_player_name = "carol" } }, + { channel = "exp_reports:delete", data = { caller = "alice", player_name = "bob" } }, + }, "the reporter is left out when deleting every report") +end) + +return Suite.run() diff --git a/exp_reports/tsconfig.browser.json b/exp_reports/tsconfig.browser.json new file mode 100644 index 0000000000..1e3889e7d5 --- /dev/null +++ b/exp_reports/tsconfig.browser.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.browser.json", + "include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ], +} diff --git a/exp_reports/tsconfig.json b/exp_reports/tsconfig.json new file mode 100644 index 0000000000..303b8f284b --- /dev/null +++ b/exp_reports/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.browser.json" } + ] +} diff --git a/exp_reports/tsconfig.node.json b/exp_reports/tsconfig.node.json new file mode 100644 index 0000000000..3218f2e757 --- /dev/null +++ b/exp_reports/tsconfig.node.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./**/*.ts"], + "exclude": ["test/*", "./dist/*"], +} diff --git a/exp_reports/web/components/ReportsPage.tsx b/exp_reports/web/components/ReportsPage.tsx new file mode 100644 index 0000000000..ce4e16ed3e --- /dev/null +++ b/exp_reports/web/components/ReportsPage.tsx @@ -0,0 +1,145 @@ +import React, { useContext, useState } from "react"; +import { Button, Form, Input, Modal, Popconfirm, Table } from "antd"; + +import { + ControlContext, PageHeader, PageLayout, notifyErrorHandler, useAccount, + useColumnSearch, useTableQueryState, +} from "@clusterio/web_ui"; + +import { ReportCreateRequest, ReportDeleteRequest, ReportRecord } from "../../messages"; +import type { WebPlugin } from ".."; + +const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; + +/** Modal which reports a player in the name of the web user. */ +function CreateReportButton() { + const control = useContext(ControlContext); + const [open, setOpen] = useState(false); + const [form] = Form.useForm<{ playerName: string, reason: string }>(); + + async function createReport() { + const values = await form.validateFields(); + await control.send(new ReportCreateRequest(values.playerName, values.reason)); + form.resetFields(); + setOpen(false); + } + + return <> + + { createReport().catch(notifyErrorHandler("Error creating report")); }} + onCancel={() => setOpen(false)} + destroyOnHidden + > +
+ + + + + + +
+
+ ; +} + +/** Table of every report with search on the player, reporter and reason, and a filter on the instance. */ +function ReportsTable() { + const control = useContext(ControlContext); + const account = useAccount(); + const plugin = control.plugins.get("exp_reports") as WebPlugin; + const [reports, synced] = plugin.useReports(); + + const tableState = useTableQueryState({ + namespace: "report", + defaultSortKey: "time", + pagination: { defaultPageSize: 50 }, + }); + const playerSearch = useColumnSearch(tableState, "player", report => report.playerName, "Search players"); + const bySearch = useColumnSearch(tableState, "by", report => report.byPlayerName, "Search reporters"); + const reasonSearch = useColumnSearch(tableState, "reason", report => report.reason, "Search reasons"); + + const data = [...reports.values()]; + const instanceNames = [...new Set(data.map(report => report.instanceName))].sort(strcmp); + const instanceFilters = instanceNames.map(name => ({ text: name || "Web UI", value: name })); + const canDelete = account.hasPermission("exp_reports.report.delete"); + + return report.playerName, + sorter: (a, b) => strcmp(a.playerName, b.playerName), + sortOrder: tableState.sortOrder("player"), + filteredValue: tableState.filteredValue("player"), + ...playerSearch, + }, + { + title: "Reported by", + key: "by", + render: (_, report) => report.byPlayerName, + sorter: (a, b) => strcmp(a.byPlayerName, b.byPlayerName), + sortOrder: tableState.sortOrder("by"), + filteredValue: tableState.filteredValue("by"), + ...bySearch, + }, + { + title: "Reason", + key: "reason", + render: (_, report) => report.reason, + filteredValue: tableState.filteredValue("reason"), + ...reasonSearch, + }, + { + title: "Instance", + key: "instance", + render: (_, report) => report.instanceName || "Web UI", + filters: instanceFilters, + filteredValue: tableState.filteredValue("instance"), + onFilter: (value, report) => report.instanceName === value, + sorter: (a, b) => strcmp(a.instanceName, b.instanceName), + sortOrder: tableState.sortOrder("instance"), + }, + { + title: "Time", + key: "time", + render: (_, report) => new Date(report.updatedAtMs).toLocaleString(), + sorter: (a, b) => a.updatedAtMs - b.updatedAtMs, + sortOrder: tableState.sortOrder("time"), + defaultSortOrder: "descend", + }, + ...canDelete ? [{ + key: "actions", + render: (_: unknown, report: ReportRecord) => { + control.send(new ReportDeleteRequest(report.id)).catch(notifyErrorHandler("Error deleting report")); + }} + > + + , + }] : [], + ]} + dataSource={data} + loading={!synced} + rowKey={report => report.id} + pagination={tableState.pagination} + onChange={tableState.onChange} + />; +} + +export default function ReportsPage() { + const account = useAccount(); + return + : undefined} + /> + + ; +} diff --git a/exp_reports/web/index.tsx b/exp_reports/web/index.tsx new file mode 100644 index 0000000000..3b9dc85df2 --- /dev/null +++ b/exp_reports/web/index.tsx @@ -0,0 +1,27 @@ +import React, { useCallback, useSyncExternalStore } from "react"; +import { BaseWebPlugin } from "@clusterio/web_ui"; + +import * as lib from "@clusterio/lib"; +import * as messages from "../messages"; + +import ReportsPage from "./components/ReportsPage"; + +export class WebPlugin extends BaseWebPlugin { + reports = new lib.MapSubscriber(messages.ReportUpdatedEvent, this.control); + + async init() { + this.pages = [ + { + path: "/reports", + sidebarName: "Reports", + permission: "exp_reports.report.list", + content: , + }, + ]; + } + + useReports() { + const subscribe = useCallback((cb: () => void) => this.reports.subscribe(cb), []); + return useSyncExternalStore(subscribe, () => this.reports.getSnapshot()); + } +} diff --git a/exp_reports/webpack.config.js b/exp_reports/webpack.config.js new file mode 100644 index 0000000000..4f147284e9 --- /dev/null +++ b/exp_reports/webpack.config.js @@ -0,0 +1,34 @@ +"use strict"; +const path = require("path"); +const webpack = require("webpack"); +const { merge } = require("webpack-merge"); + +const common = require("@clusterio/web_ui/webpack.common"); + +module.exports = (env = {}) => merge(common(env), { + context: __dirname, + entry: "./web/index.tsx", + output: { + path: path.resolve(__dirname, "dist", "web"), + }, + plugins: [ + new webpack.container.ModuleFederationPlugin({ + name: "exp_reports", + library: { type: "window", name: "plugin_exp_reports" }, + exposes: { + "./": "./index.ts", + "./package.json": "./package.json", + "./web": "./web/index.tsx", + }, + shared: { + "@clusterio/lib": { import: false }, + "@clusterio/web_ui": { import: false }, + "antd": { import: false }, + "react": { import: false }, + "react-dom": { import: false }, + "react-router": { import: false }, + "react-router-dom": { import: false }, + }, + }), + ], +}); diff --git a/exp_scenario/module/commands/reports.lua b/exp_scenario/module/commands/reports.lua index 166d4b6458..58ca30ef8d 100644 --- a/exp_scenario/module/commands/reports.lua +++ b/exp_scenario/module/commands/reports.lua @@ -3,13 +3,12 @@ Adds a commands that allow players to report other players ]] local Commands = require("modules/exp_commands") -local format_player_name = Commands.format_player_name_locale local parse_input = Commands.parse_input local Roles = require("modules/exp_roles") local player_has_permission = Roles.player_has_permission -local Reports = require("modules.exp_legacy.modules.control.reports") --- @dep modules.control.reports +local Reports = require("modules/exp_reports") --- @param input string --- @param player LuaPlayer @@ -29,7 +28,7 @@ local function reportable_player(input, player) end end ---- Reports a player and notifies admins +--- Reports a player and notifies admins, the outcome is printed once the controller answers Commands.new("create-report", { "exp-commands_reports.description-create" }) :argument("player", { "exp-commands_reports.arg-player-create" }, reportable_player) :argument("reason", { "exp-commands_reports.arg-reason" }, Commands.types.string) @@ -38,50 +37,20 @@ Commands.new("create-report", { "exp-commands_reports.description-create" }) :register(function(player, other_player, reason) --- @cast other_player LuaPlayer --- @cast reason string - local player_name = format_player_name(player) - local other_player_name = format_player_name(other_player) - if Reports.report_player(other_player, player.name, reason) then - local user_message = { "exp-commands_reports.response", other_player_name, reason } - local admin_message = { "exp-commands_reports.response-admin", other_player_name, player_name, reason } - for _, connected_player in ipairs(game.connected_players) do - if connected_player.admin then - connected_player.print(admin_message) - else - connected_player.print(user_message) - end - end - else - return Commands.status.invalid_input{ "exp-commands_reports.already-reported" } - end + Reports.create_report(player, other_player, reason) end) ---- Gets a list of all reports that a player has on them. If no player then lists all players and the number of reports on them. +--- Lists the reports against a player, or the number against every player, printed once the controller answers Commands.new("get-reports", { "exp-commands_reports.description-get" }) :optional("player", { "exp-commands_reports.arg-player-get" }, Commands.types.player) :add_aliases{ "reports" } :add_flags{ "admin_only" } :register(function(player, other_player) --- @cast other_player LuaPlayer? - if other_player then - local reports = Reports.get_reports(other_player) - local other_player_name = format_player_name(other_player) - Commands.print{ "exp-commands_reports.player-title", other_player_name, #reports } - for by_player_name, reason in pairs(reports) do - local by_player_name_formatted = format_player_name(by_player_name) - Commands.print{ "exp-commands_reports.list-element", by_player_name_formatted, reason } - end - else - local reports = Reports.user_reports - Commands.print{ "exp-commands_reports.reports-title" } - for player_name in pairs(reports) do - local player_name_formatted = format_player_name(player_name) - local report_count = Reports.count_reports(player_name) - Commands.print{ "exp-commands_reports.list-element", player_name_formatted, report_count } - end - end + Reports.list_reports(player, other_player) end) ---- Clears all reports from a player or just the report from one player. +--- Clears all reports from a player or just the report from one player, printed once the controller answers Commands.new("clear-reports", { "exp-commands_reports.description-clear" }) :argument("player", { "exp-commands_reports.arg-player-clear" }, Commands.types.player) :optional("from-player", { "exp-commands_reports.arg-from-player" }, Commands.types.player) @@ -89,22 +58,5 @@ Commands.new("clear-reports", { "exp-commands_reports.description-clear" }) :register(function(player, other_player, from_player) --- @cast other_player LuaPlayer --- @cast from_player LuaPlayer? - local player_name = format_player_name(player) - local other_player_name = format_player_name(other_player) - if from_player then - if not Reports.remove_report(other_player, from_player.name, player.name) then - local from_player_name = format_player_name(other_player) - return Commands.status.invalid_input{ "exp-commands_reports.not-reported-by", from_player_name } - else - game.print{ "exp-commands_reports.removed", other_player_name, player_name } - return Commands.status.success() - end - else - if not Reports.remove_all(other_player, player.name) then - return Commands.status.invalid_input{ "exp-commands_reports.not-reported" } - else - game.print{ "exp-commands_reports.removed-all", other_player_name, player_name } - return Commands.status.success() - end - end + Reports.delete_reports(player, other_player, from_player and from_player.name) end) diff --git a/exp_scenario/module/control/discord_alerts.lua b/exp_scenario/module/control/discord_alerts.lua index ff1fa5c978..7386cef8be 100644 --- a/exp_scenario/module/control/discord_alerts.lua +++ b/exp_scenario/module/control/discord_alerts.lua @@ -99,10 +99,10 @@ end --- Reports added and removed if config.player_reports then - local Reports = require("modules.exp_legacy.modules.control.reports") - events[Reports.events.on_player_reported] = function(event) + local Reports = require("modules/exp_reports") + --- @param event EventData.ExpReports.on_player_reported + events[Reports.on_player_reported] = function(event) local player_name, by_player_name = get_player_name(event) - local player = assert(game.get_player(player_name)) emit_event{ title = "Report", description = "A player was reported", @@ -110,22 +110,22 @@ if config.player_reports then fields = { { name = "Player", inline = true, value = append_playtime(player_name) }, { name = "By", inline = true, value = append_playtime(by_player_name) }, - { name = "Report Count", inline = true, value = Reports.count_reports(player) }, + { name = "Report Count", inline = true, value = tostring(event.report_count) }, { name = "Reason", value = event.reason }, }, } end - events[Reports.events.on_report_removed] = function(event) - if event.batch ~= 1 then return end - local player_name = get_player_name(event) + --- @param event EventData.ExpReports.on_reports_deleted + events[Reports.on_reports_deleted] = function(event) + local player_name, by_player_name = get_player_name(event) emit_event{ title = "Reports Removed", - description = "A player has a report removed", + description = "A player has reports removed", color = Colors.green, fields = { { name = "Player", inline = true, value = append_playtime(player_name) }, - { name = "By", inline = true, value = append_playtime(event.removed_by_name) }, - { name = "Report Count", inline = true, value = tostring(event.batch_count) }, + { name = "By", inline = true, value = append_playtime(by_player_name) }, + { name = "Report Count", inline = true, value = tostring(event.count) }, }, } end diff --git a/exp_scenario/module/control/report_jail.lua b/exp_scenario/module/control/report_jail.lua index 25ec24f9a0..fc05ece677 100644 --- a/exp_scenario/module/control/report_jail.lua +++ b/exp_scenario/module/control/report_jail.lua @@ -4,28 +4,24 @@ When a player is reported, the player is automatically jailed if the combined pl local ExpUtil = require("modules/exp_util") local Jail = require("modules/exp_scenario/control/jail") -local Reports = require("modules.exp_legacy.modules.control.reports") +local Reports = require("modules/exp_reports") local max = math.max local format_player_name = ExpUtil.format_player_name_locale ---- Returns the playtime of the reporter. Used when calculating the total playtime of all reporters ---- @param player LuaPlayer ---- @param by_player_name string ---- @param reason string ---- @return number -local function reporter_playtime(player, by_player_name, reason) - local by_player = game.get_player(by_player_name) - return by_player and by_player.online_time or 0 -end - ---- Check if the player has too many reports against them (based on playtime) +--- Check if the player has too many reports against them, weighed by the playtime of the reporters +--- @param event EventData.ExpReports.on_player_reported local function on_player_reported(event) local player = assert(game.get_player(event.player_index)) - local total_playtime = Reports.count_reports(player, reporter_playtime) + + local total_playtime = 0 + for _, by_player_name in ipairs(event.by_player_names) do + local by_player = game.get_player(by_player_name) + total_playtime = total_playtime + (by_player and by_player.online_time or 0) + end -- Total time greater than the players own time, or 30 minutes, which ever is greater - if Reports.count_reports(player) > 1 and total_playtime > max(player.online_time * 2, 108000) then + if event.report_count > 1 and total_playtime > max(player.online_time * 2, 108000) then Jail.jail_player(player, "", "Reported by too many players, please wait for a moderator.") game.print{ "exp_report-jail.chat-jailed", format_player_name(player) } end @@ -33,6 +29,6 @@ end return { events = { - [Reports.events.on_player_reported] = on_player_reported, + [Reports.on_player_reported] = on_player_reported, } } diff --git a/exp_scenario/module/locale/en.cfg b/exp_scenario/module/locale/en.cfg index 283460361e..cd7711f522 100644 --- a/exp_scenario/module/locale/en.cfg +++ b/exp_scenario/module/locale/en.cfg @@ -172,16 +172,6 @@ arg-reason=Reason you want to report this player. arg-from-player=Only remove the report from this player. player-immune=This player can not be reported. self-report=You cannot report yourself. -response=__1__ was reported for __2__. -response-admin=__1__ was reported by __2__ for __3__. -already-reported=You can only report a player once, you can ask a moderator to clear this report. -not-reported=The player had no reports on them. -not-reported-by=The player had no reports on them from __1__. -reports-title=The following players have reports against them: -player-title=__1__ has __2__ __plural_for_parameter__2__{1=report|rest=reports}__ against them: -list-element=__1__: __2__ -removed-all=__1__ has has all of their reports removed by __2__. -removed=__1__ has a report removed by __2__. [exp-commands_roles] description-assign=Assigns a role to a player. diff --git a/exp_scenario/module/locale/zh-CN.cfg b/exp_scenario/module/locale/zh-CN.cfg index a4783e0cb7..bc46d985f7 100644 --- a/exp_scenario/module/locale/zh-CN.cfg +++ b/exp_scenario/module/locale/zh-CN.cfg @@ -173,16 +173,6 @@ arg-reason=原因 arg-from-player=用戶 player-immune=用戶不能被舉報 self-report=你不能舉報自己 -response=__1__ 因 __2__ 被舉報了。 -response-admin=__1__ 因 __3__ 被 __2__ 舉報了。 -already-reported=你已經舉報過該用戶了,你可以讓管理員清除報告來重寫 -not-reported=該用戶沒有被舉報的紀錄。 -not-reported-by=該用戶沒有被 __1__ 舉報的紀錄。 -reports-title==該用戶現在的被舉報紀錄: -player-title=__1__ 有 __2__ 項被舉報的紀錄: -list-element=__1__: __2__ -removed-all=__1__ 被舉報的所有紀錄已被 __2__ 清除。 -removed=__1__ 被舉報的一個紀錄已被 __2__ 清除。 [exp-commands_roles] description-assign=為用戶指配用戶組 diff --git a/exp_scenario/module/locale/zh-TW.cfg b/exp_scenario/module/locale/zh-TW.cfg index a4783e0cb7..bc46d985f7 100644 --- a/exp_scenario/module/locale/zh-TW.cfg +++ b/exp_scenario/module/locale/zh-TW.cfg @@ -173,16 +173,6 @@ arg-reason=原因 arg-from-player=用戶 player-immune=用戶不能被舉報 self-report=你不能舉報自己 -response=__1__ 因 __2__ 被舉報了。 -response-admin=__1__ 因 __3__ 被 __2__ 舉報了。 -already-reported=你已經舉報過該用戶了,你可以讓管理員清除報告來重寫 -not-reported=該用戶沒有被舉報的紀錄。 -not-reported-by=該用戶沒有被 __1__ 舉報的紀錄。 -reports-title==該用戶現在的被舉報紀錄: -player-title=__1__ 有 __2__ 項被舉報的紀錄: -list-element=__1__: __2__ -removed-all=__1__ 被舉報的所有紀錄已被 __2__ 清除。 -removed=__1__ 被舉報的一個紀錄已被 __2__ 清除。 [exp-commands_roles] description-assign=為用戶指配用戶組 diff --git a/exp_scenario/module/module.json b/exp_scenario/module/module.json index 23b572f04d..34082135cb 100644 --- a/exp_scenario/module/module.json +++ b/exp_scenario/module/module.json @@ -9,6 +9,7 @@ "clusterio": "*", "exp_util": "*", "exp_roles": "*", + "exp_reports": "*", "exp_gui": "*", "exp_commands": "*" } diff --git a/tsconfig.json b/tsconfig.json index 8afb8e7c2e..420ba64757 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ { "path": "./exp_groups/" }, { "path": "./exp_gui/" }, { "path": "./exp_legacy/" }, + { "path": "./exp_reports/" }, { "path": "./exp_roles/" }, { "path": "./exp_scenario/" }, { "path": "./exp_server_ups/" },