mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-14 08:14:52 +09:00
Merge pull request #469 from bbassie/feature/reports-plugin
Add the exp_reports plugin and move the scenario onto it
This commit is contained in:
43 files changed
+1822
-481
No files matched your search
@@ -4,7 +4,6 @@
|
||||
return {
|
||||
show_playtime = true,
|
||||
entity_protection = true,
|
||||
player_reports = true,
|
||||
player_bans = true,
|
||||
player_mutes = true,
|
||||
player_kicks = true,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,143 @@
|
||||
import { BaseControllerPlugin } from "@clusterio/controller";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
import * as path from "node:path";
|
||||
|
||||
export class ControllerPlugin extends BaseControllerPlugin {
|
||||
reports!: lib.SubscribableDatastore<messages.ReportRecord>;
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
declare module "@clusterio/lib" {
|
||||
export interface ControllerConfigFields {
|
||||
"exp_reports.discord_webhook_url": string | null;
|
||||
"exp_reports.json_webhook_url": string | null;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
],
|
||||
|
||||
permissions: [
|
||||
{
|
||||
name: "exp_reports.report.get",
|
||||
title: "Get Reports",
|
||||
description: "Retrieve a specific report by id.",
|
||||
grantByDefault: true,
|
||||
},
|
||||
{
|
||||
name: "exp_reports.report.list",
|
||||
title: "List Reports",
|
||||
description: "List the reports against every player, or against one player.",
|
||||
grantByDefault: true,
|
||||
},
|
||||
{
|
||||
name: "exp_reports.report.subscribe",
|
||||
title: "Subscribe to Report Updates",
|
||||
description: "Receive updates when reports are made or deleted.",
|
||||
grantByDefault: true,
|
||||
},
|
||||
{
|
||||
name: "exp_reports.report.create",
|
||||
title: "Create Reports",
|
||||
description: "Report a player.",
|
||||
grantByDefault: false,
|
||||
},
|
||||
{
|
||||
name: "exp_reports.report.delete",
|
||||
title: "Delete Reports",
|
||||
description: "Delete reports made against a player.",
|
||||
grantByDefault: false,
|
||||
},
|
||||
],
|
||||
|
||||
instanceEntrypoint: "./dist/node/instance.js",
|
||||
|
||||
controllerEntrypoint: "./dist/node/controller.js",
|
||||
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",
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { BaseInstancePlugin } from "@clusterio/host";
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
/** 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
|
||||
declare module "@clusterio/lib" {
|
||||
export interface Permissions {
|
||||
"exp_reports.report.get": never;
|
||||
"exp_reports.report.list": never;
|
||||
"exp_reports.report.subscribe": never;
|
||||
"exp_reports.report.create": never;
|
||||
"exp_reports.report.delete": never;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
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<typeof ReportRecord.jsonSchema> = {
|
||||
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<typeof this.jsonSchema>) {
|
||||
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<typeof this.jsonSchema>) {
|
||||
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<typeof ReportListRequest.jsonSchema> = {};
|
||||
if (this.playerName !== undefined) {
|
||||
json.player_name = this.playerName;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
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<typeof this.jsonSchema>) {
|
||||
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<typeof this.jsonSchema>) {
|
||||
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<typeof this.jsonSchema>) {
|
||||
return new this(json.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
--[[-- ExpReports - Commands
|
||||
Commands which let players report each other and admins manage the reports
|
||||
]]
|
||||
|
||||
local Commands = require("modules/exp_commands")
|
||||
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_reports")
|
||||
|
||||
--- @param input string
|
||||
--- @param player LuaPlayer
|
||||
--- @return Commands.Status
|
||||
--- @return LuaPlayer | LocalisedString
|
||||
local function reportable_player(input, player)
|
||||
local success, status, result = parse_input(input, player, Commands.types.player)
|
||||
if not success then return status, result end
|
||||
--- @cast result LuaPlayer
|
||||
|
||||
if player_has_permission(result, "exp_scenario.bypass.reports") then
|
||||
return Commands.status.invalid_input{ "exp-reports_commands.player-immune" }
|
||||
elseif player == result then
|
||||
return Commands.status.invalid_input{ "exp-reports_commands.self-report" }
|
||||
else
|
||||
return Commands.status.success(result)
|
||||
end
|
||||
end
|
||||
|
||||
--- Reports a player and notifies admins, the outcome is printed once the controller answers
|
||||
Commands.new("create-report", { "exp-reports_commands.description-create" })
|
||||
:argument("player", { "exp-reports_commands.arg-player-create" }, reportable_player)
|
||||
:argument("reason", { "exp-reports_commands.arg-reason" }, Commands.types.string)
|
||||
:enable_auto_concatenation()
|
||||
:add_aliases{ "report" }
|
||||
:register(function(player, other_player, reason)
|
||||
--- @cast other_player LuaPlayer
|
||||
--- @cast reason string
|
||||
Reports.create_report(player, other_player, reason)
|
||||
end)
|
||||
|
||||
--- Lists the reports against a player, or the number against every player, printed once the controller answers
|
||||
Commands.new("get-reports", { "exp-reports_commands.description-get" })
|
||||
:optional("player", { "exp-reports_commands.arg-player-get" }, Commands.types.player)
|
||||
:add_aliases{ "reports" }
|
||||
:add_flags{ "admin_only" }
|
||||
:register(function(player, other_player)
|
||||
--- @cast other_player LuaPlayer?
|
||||
Reports.list_reports(player, other_player)
|
||||
end)
|
||||
|
||||
--- Clears all reports from a player or just the report from one player, printed once the controller answers
|
||||
Commands.new("clear-reports", { "exp-reports_commands.description-clear" })
|
||||
:argument("player", { "exp-reports_commands.arg-player-clear" }, Commands.types.player)
|
||||
:optional("from-player", { "exp-reports_commands.arg-from-player" }, Commands.types.player)
|
||||
:add_flags{ "admin_only" }
|
||||
:register(function(player, other_player, from_player)
|
||||
--- @cast other_player LuaPlayer
|
||||
--- @cast from_player LuaPlayer?
|
||||
Reports.delete_reports(player, other_player, from_player and from_player.name)
|
||||
end)
|
||||
@@ -0,0 +1,206 @@
|
||||
--[[-- 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_locale = ExpUtil.format_player_name_locale
|
||||
local format_rich_text_color_locale = ExpUtil.format_rich_text_color_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
|
||||
|
||||
--- Names come from the controller, so the player may never have joined this server
|
||||
--- @param name string
|
||||
--- @return LocalisedString
|
||||
local function format_player_name(name)
|
||||
local player = game.get_player(name)
|
||||
if player then
|
||||
return format_player_name_locale(player)
|
||||
end
|
||||
return format_rich_text_color_locale(name, ExpUtil.color.white)
|
||||
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<string, number>
|
||||
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
|
||||
@@ -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")
|
||||
@@ -0,0 +1,22 @@
|
||||
[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.
|
||||
|
||||
[exp-reports_commands]
|
||||
description-create=Reports a player and notifies moderators
|
||||
description-get=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.
|
||||
description-clear=Clears all reports from a player or just the report from one player.
|
||||
arg-player-create=Player you want to report.
|
||||
arg-player-get=Player to get the reports of.
|
||||
arg-player-clear=Player to clear the reports of.
|
||||
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.
|
||||
@@ -0,0 +1,22 @@
|
||||
[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__ 沒有被舉報的紀錄。
|
||||
|
||||
[exp-reports_commands]
|
||||
description-create=檢舉用戶並通知管理員。
|
||||
description-get=取得用戶的報告清單。如果沒有設定用戶,則列出所有用戶以及有關他們的報告數量。
|
||||
description-clear=清除一名用戶的所有報告或僅清除一名用戶的報告。
|
||||
arg-player-create=用戶
|
||||
arg-player-get=用戶
|
||||
arg-player-clear=用戶
|
||||
arg-reason=原因
|
||||
arg-from-player=用戶
|
||||
player-immune=用戶不能被舉報
|
||||
self-report=你不能舉報自己。
|
||||
@@ -0,0 +1,22 @@
|
||||
[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__ 沒有被舉報的紀錄。
|
||||
|
||||
[exp-reports_commands]
|
||||
description-create=檢舉用戶並通知管理員。
|
||||
description-get=取得用戶的報告清單。如果沒有設定用戶,則列出所有用戶以及有關他們的報告數量。
|
||||
description-clear=清除一名用戶的所有報告或僅清除一名用戶的報告。
|
||||
arg-player-create=用戶
|
||||
arg-player-get=用戶
|
||||
arg-player-clear=用戶
|
||||
arg-reason=原因
|
||||
arg-from-player=用戶
|
||||
player-immune=用戶不能被舉報
|
||||
self-report=你不能舉報自己。
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "exp_reports",
|
||||
"load": [
|
||||
"commands.lua"
|
||||
],
|
||||
"require": [
|
||||
"globals.lua"
|
||||
],
|
||||
"dependencies": {
|
||||
"clusterio": "*",
|
||||
"exp_util": "*",
|
||||
"exp_commands": "*",
|
||||
"exp_roles": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
-- Access the exports from other modules using require("modules/exp_reports")
|
||||
return require("modules/exp_reports/control")
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@expcluster/reports",
|
||||
"version": "7.0.1",
|
||||
"description": "Clusterio plugin storing player reports on the controller",
|
||||
"author": "Cooldude2606 <https://github.com/Cooldude2606>",
|
||||
"license": "MIT",
|
||||
"repository": "explosivegaming/ExpCluster",
|
||||
"type": "module",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --env production",
|
||||
"test": "tap --type-strip-only --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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import t from "tap";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Controller, InstanceRecord } from "@clusterio/controller";
|
||||
import { ControllerPlugin } from "../dist/node/controller.js";
|
||||
import * as messages from "../dist/node/messages.js";
|
||||
import { plugin as pluginDeclaration } from "../dist/node/index.js";
|
||||
|
||||
// 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();
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import t from "tap";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Instance } from "@clusterio/host";
|
||||
import { InstancePlugin } from "../dist/node/instance.js";
|
||||
import { plugin as pluginDeclaration } from "../dist/node/index.js";
|
||||
import * as messages from "../dist/node/messages.js";
|
||||
|
||||
// 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();
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import t from "tap";
|
||||
import * as messages from "../dist/node/messages.js";
|
||||
import { testMatrix, testRoundTripJsonSerialisable } from "../../test/common.js";
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import path from "node:path";
|
||||
import t from "tap";
|
||||
import { reportLuaTests } from "../../test/lua/runner.js";
|
||||
|
||||
const envFile = path.join(import.meta.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(import.meta.dirname, "module", file)));
|
||||
}
|
||||
t2.end();
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
--[[-- 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
|
||||
|
||||
--- 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 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(player)
|
||||
assert(type(player) == "table", "format_player_name_locale takes a LuaPlayer")
|
||||
return player.name
|
||||
end,
|
||||
format_rich_text_color_locale = function(message) return message end,
|
||||
color = { orange_red = "orange_red", white = "white" },
|
||||
},
|
||||
}
|
||||
env.Reports = assert(loadfile(plugin_root .. "/module/control.lua"))() --- @type ExpReports
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,102 @@
|
||||
local Suite = ... --- @type Suite<ExpReports.TestEnv>
|
||||
|
||||
Suite.test("receive_created() tells everyone and raises on_player_reported", function(env)
|
||||
env.add_player{ name = "alice", admin = true }
|
||||
local bob = env.add_player{ name = "bob" }
|
||||
env.add_player{ name = "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{ name = "alice" }
|
||||
env.add_player{ name = "bob", connected = 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{ name = "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{ name = "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{ name = "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{ name = "alice", connected = 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{ name = "alice" }
|
||||
local bob = env.add_player{ name = "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{ name = "alice" }
|
||||
env.add_player{ name = "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{ name = "alice" }
|
||||
env.add_player{ name = "bob", connected = 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()
|
||||
@@ -0,0 +1,34 @@
|
||||
local Suite = ... --- @type Suite<ExpReports.TestEnv>
|
||||
|
||||
Suite.test("create_report() sends the report to the controller", function(env)
|
||||
local alice = env.add_player{ name = "alice" }
|
||||
local bob = env.add_player{ name = "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{ name = "alice" }
|
||||
local bob = env.add_player{ name = "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{ name = "alice" }
|
||||
local bob = env.add_player{ name = "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()
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../tsconfig.browser.json",
|
||||
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.browser.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../tsconfig.node.json",
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["test/*", "./dist/*"],
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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.js";
|
||||
import { useReports } 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 <>
|
||||
<Button type="primary" onClick={() => setOpen(true)}>Create</Button>
|
||||
<Modal
|
||||
title="Report Player"
|
||||
okText="Report"
|
||||
open={open}
|
||||
onOk={() => { createReport().catch(notifyErrorHandler("Error creating report")); }}
|
||||
onCancel={() => setOpen(false)}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="playerName" label="Player" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="Reason" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
/** 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 [reports, synced] = useReports();
|
||||
|
||||
const tableState = useTableQueryState<ReportRecord>({
|
||||
namespace: "report",
|
||||
defaultSortKey: "time",
|
||||
pagination: { defaultPageSize: 50 },
|
||||
});
|
||||
const playerSearch = useColumnSearch<ReportRecord>(tableState, "player", report => report.playerName, "Search players");
|
||||
const bySearch = useColumnSearch<ReportRecord>(tableState, "by", report => report.byPlayerName, "Search reporters");
|
||||
const reasonSearch = useColumnSearch<ReportRecord>(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 <Table
|
||||
size="small"
|
||||
columns={[
|
||||
{
|
||||
title: "Player",
|
||||
key: "player",
|
||||
render: (_, report) => 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) => <Popconfirm
|
||||
title="Delete this report?"
|
||||
onConfirm={() => {
|
||||
control.send(new ReportDeleteRequest(report.id)).catch(notifyErrorHandler("Error deleting report"));
|
||||
}}
|
||||
>
|
||||
<Button size="small" danger onClick={event => event.stopPropagation()}>Delete</Button>
|
||||
</Popconfirm>,
|
||||
}] : [],
|
||||
]}
|
||||
dataSource={data}
|
||||
loading={!synced}
|
||||
rowKey={report => report.id}
|
||||
pagination={tableState.pagination}
|
||||
onChange={tableState.onChange}
|
||||
/>;
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const account = useAccount();
|
||||
return <PageLayout nav={[{ name: "Reports" }]}>
|
||||
<PageHeader
|
||||
title="Reports"
|
||||
extra={account.hasPermission("exp_reports.report.create") ? <CreateReportButton /> : undefined}
|
||||
/>
|
||||
<ReportsTable />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React, { useCallback, useSyncExternalStore } from "react";
|
||||
import { WebPluginContext } from "@clusterio/web_ui";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "../messages.js";
|
||||
|
||||
import ReportsPage from "./components/ReportsPage";
|
||||
|
||||
let reports: lib.MapSubscriber<messages.ReportUpdatedEvent> | undefined;
|
||||
|
||||
export function useReports() {
|
||||
const subscribe = useCallback((cb: () => void) => reports!.subscribe(cb), []);
|
||||
return useSyncExternalStore(subscribe, () => reports!.getSnapshot());
|
||||
}
|
||||
|
||||
export default function registerPlugin(context: WebPluginContext) {
|
||||
context.control.hooks.pages.attach("exp_reports", () => [
|
||||
{
|
||||
path: "/reports",
|
||||
sidebarName: "Reports",
|
||||
permission: "exp_reports.report.list",
|
||||
content: <ReportsPage />,
|
||||
},
|
||||
]);
|
||||
|
||||
reports = new lib.MapSubscriber(messages.ReportUpdatedEvent, context.control);
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -5,8 +5,8 @@ local test, check, eq, empty = Suite.test, Suite.check, Suite.eq, Suite.empty
|
||||
--- alice is a moderator and bob a regular, with changes sent to the controller
|
||||
local function setup(env)
|
||||
local players = {
|
||||
alice = env.add_player("alice"),
|
||||
bob = env.add_player("bob"),
|
||||
alice = env.add_player{ name = "alice" },
|
||||
bob = env.add_player{ name = "bob" },
|
||||
}
|
||||
env.initialise{
|
||||
env.assignment("alice", { "Moderator" }),
|
||||
|
||||
@@ -5,9 +5,9 @@ local test, check, eq = Suite.test, Suite.check, Suite.eq
|
||||
--- alice and dave are moderators with dave offline, zed has never joined
|
||||
local function setup(env)
|
||||
local players = {
|
||||
alice = env.add_player("alice"),
|
||||
bob = env.add_player("bob"),
|
||||
dave = env.add_player("dave", false),
|
||||
alice = env.add_player{ name = "alice" },
|
||||
bob = env.add_player{ name = "bob" },
|
||||
dave = env.add_player{ name = "dave", connected = false },
|
||||
}
|
||||
env.initialise{
|
||||
env.assignment("alice", { "Moderator" }),
|
||||
|
||||
@@ -5,9 +5,9 @@ local test, check, eq = Suite.test, Suite.check, Suite.eq
|
||||
--- alice is a moderator, bob is a regular, and carol has only the default role
|
||||
local function setup(env)
|
||||
local players = {
|
||||
alice = env.add_player("alice"),
|
||||
bob = env.add_player("bob"),
|
||||
carol = env.add_player("carol"),
|
||||
alice = env.add_player{ name = "alice" },
|
||||
bob = env.add_player{ name = "bob" },
|
||||
carol = env.add_player{ name = "carol" },
|
||||
}
|
||||
env.initialise{
|
||||
env.assignment("alice", { "Moderator" }),
|
||||
|
||||
@@ -8,8 +8,8 @@ local admin_state = {} --- @type table<string, boolean>
|
||||
--- alice is a moderator and carol has only the default role, both connected
|
||||
local function setup(env)
|
||||
local players = {
|
||||
alice = env.add_player("alice"),
|
||||
carol = env.add_player("carol"),
|
||||
alice = env.add_player{ name = "alice" },
|
||||
carol = env.add_player{ name = "carol" },
|
||||
}
|
||||
admin_state = {}
|
||||
env.Roles.define_permission_trigger("exp_scenario.player.admin", function(player, state)
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
--[[-- Commands - Reports
|
||||
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
|
||||
|
||||
--- @param input string
|
||||
--- @param player LuaPlayer
|
||||
--- @return Commands.Status
|
||||
--- @return LuaPlayer | LocalisedString
|
||||
local function reportable_player(input, player)
|
||||
local success, status, result = parse_input(input, player, Commands.types.player)
|
||||
if not success then return status, result end
|
||||
--- @cast result LuaPlayer
|
||||
|
||||
if player_has_permission(input, "exp_scenario.bypass.reports") then
|
||||
return Commands.status.invalid_input{ "exp-commands_reports.player-immune" }
|
||||
elseif player == input then
|
||||
return Commands.status.invalid_input{ "exp-commands_reports.self-report" }
|
||||
else
|
||||
return Commands.status.success(result)
|
||||
end
|
||||
end
|
||||
|
||||
--- Reports a player and notifies admins
|
||||
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)
|
||||
:enable_auto_concatenation()
|
||||
:add_aliases{ "report" }
|
||||
: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
|
||||
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.
|
||||
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
|
||||
end)
|
||||
|
||||
--- Clears all reports from a player or just the report from one player.
|
||||
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)
|
||||
:add_flags{ "admin_only" }
|
||||
: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
|
||||
end)
|
||||
@@ -30,7 +30,6 @@ require("modules/exp_scenario/commands/me")
|
||||
require("modules/exp_scenario/commands/rainbow")
|
||||
require("modules/exp_scenario/commands/ratio")
|
||||
require("modules/exp_scenario/commands/repair")
|
||||
require("modules/exp_scenario/commands/reports")
|
||||
require("modules/exp_scenario/commands/roles")
|
||||
require("modules/exp_scenario/commands/search")
|
||||
require("modules/exp_scenario/commands/spectate")
|
||||
|
||||
@@ -97,40 +97,6 @@ if config.entity_protection then
|
||||
end
|
||||
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 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",
|
||||
color = Colors.yellow,
|
||||
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 = "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)
|
||||
emit_event{
|
||||
title = "Reports Removed",
|
||||
description = "A player has a report 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) },
|
||||
},
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
--- When a player is jailed or unjailed
|
||||
if config.player_jail then
|
||||
local Jail = require("modules/exp_scenario/control/jail")
|
||||
|
||||
@@ -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, "<reports>", "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,
|
||||
}
|
||||
}
|
||||
@@ -161,28 +161,6 @@ description=Repairs entities on your force around you.
|
||||
response-revive=__1__ entities were revived.
|
||||
response-heal=__1__ entities were healed.
|
||||
|
||||
[exp-commands_reports]
|
||||
description-create=Reports a player and notifies moderators
|
||||
description-get=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.
|
||||
description-clear=Clears all reports from a player or just the report from one player.
|
||||
arg-player-create=Player you want to report.
|
||||
arg-player-get=Player to get the reports of.
|
||||
arg-player-clear=Player to clear the reports of.
|
||||
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.
|
||||
description-unassign=Unassigns a role from a player.
|
||||
|
||||
@@ -165,28 +165,6 @@ arg-range=半徑
|
||||
response-revive=__1__ 個建築恢復。
|
||||
response-heal=回滿 __2__ 個建築耐久。
|
||||
|
||||
[exp-commands_reports]
|
||||
description-create=檢舉用戶並通知管理員。
|
||||
description-get=取得用戶的報告清單。如果沒有設定用戶,則列出所有用戶以及有關他們的報告數量。
|
||||
description-clear=清除一名用戶的所有報告或僅清除一名用戶的報告。
|
||||
arg-player-create=用戶
|
||||
arg-player-get=用戶
|
||||
arg-player-clear=用戶
|
||||
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=為用戶指配用戶組。
|
||||
description-unassign=為用戶取消指配用戶組。
|
||||
|
||||
@@ -165,28 +165,6 @@ arg-range=半徑
|
||||
response-revive=__1__ 個建築恢復。
|
||||
response-heal=回滿 __2__ 個建築耐久。
|
||||
|
||||
[exp-commands_reports]
|
||||
description-create=檢舉用戶並通知管理員。
|
||||
description-get=取得用戶的報告清單。如果沒有設定用戶,則列出所有用戶以及有關他們的報告數量。
|
||||
description-clear=清除一名用戶的所有報告或僅清除一名用戶的報告。
|
||||
arg-player-create=用戶
|
||||
arg-player-get=用戶
|
||||
arg-player-clear=用戶
|
||||
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=為用戶指配用戶組。
|
||||
description-unassign=為用戶取消指配用戶組。
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"clusterio": "*",
|
||||
"exp_util": "*",
|
||||
"exp_roles": "*",
|
||||
"exp_reports": "*",
|
||||
"exp_gui": "*",
|
||||
"exp_commands": "*"
|
||||
}
|
||||
|
||||
+10
-4
@@ -100,17 +100,23 @@ function Stubs.new()
|
||||
get_player = function(key) return players[key] end,
|
||||
}, { "player" })
|
||||
|
||||
--- @class Stubs.PlayerParams
|
||||
--- @field name string
|
||||
--- @field connected boolean? Defaults to true
|
||||
--- @field admin boolean? Defaults to false
|
||||
|
||||
--- Add a player to the stubbed game, indexes are assigned in join order
|
||||
--- @param name string
|
||||
--- @param is_connected boolean? Defaults to connected
|
||||
--- @param params Stubs.PlayerParams
|
||||
--- @return Stubs.Player
|
||||
function stubs.add_player(name, is_connected)
|
||||
function stubs.add_player(params)
|
||||
local name = params.name
|
||||
local index = #players_by_index + 1
|
||||
--- @class Stubs.Player
|
||||
local player = Stubs.strict("LuaPlayer " .. name, {
|
||||
name = name,
|
||||
index = index,
|
||||
connected = is_connected ~= false,
|
||||
connected = params.connected ~= false,
|
||||
admin = params.admin == true,
|
||||
valid = true,
|
||||
play_sound = function(opts)
|
||||
stubs.sounds[#stubs.sounds + 1] = name .. ":" .. opts.path
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
{ "path": "./exp_groups/" },
|
||||
{ "path": "./exp_gui/" },
|
||||
{ "path": "./exp_legacy/" },
|
||||
{ "path": "./exp_reports/" },
|
||||
{ "path": "./exp_roles/" },
|
||||
{ "path": "./exp_scenario/" },
|
||||
{ "path": "./exp_server_ups/" },
|
||||
|
||||
Reference in new issue
Block a user