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/" },