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:
Cooldude2606 authored and GitHub committed 2026-09-27 00:57:41 +01:00
commit 71d14c1f1a
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
+143
View File
@@ -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}`);
}
}
}
+85
View File
@@ -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",
],
};
+95
View File
@@ -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
);
}
}
+232
View File
@@ -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);
}
}
+62
View File
@@ -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)
+206
View File
@@ -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
+10
View File
@@ -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")
+22
View File
@@ -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.
+22
View File
@@ -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=你不能舉報自己。
+22
View File
@@ -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=你不能舉報自己。
+15
View File
@@ -0,0 +1,15 @@
{
"name": "exp_reports",
"load": [
"commands.lua"
],
"require": [
"globals.lua"
],
"dependencies": {
"clusterio": "*",
"exp_util": "*",
"exp_commands": "*",
"exp_roles": "*"
}
}
+3
View File
@@ -0,0 +1,3 @@
-- Access the exports from other modules using require("modules/exp_reports")
return require("modules/exp_reports/control")
+54
View File
@@ -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"
]
}
+192
View File
@@ -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();
});
+141
View File
@@ -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();
});
+61
View File
@@ -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();
});
+13
View File
@@ -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();
});
+50
View File
@@ -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)
+102
View File
@@ -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()
+34
View File
@@ -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()
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.browser.json",
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.browser.json" }
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../tsconfig.node.json",
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
}
+144
View File
@@ -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>;
}
+27
View File
@@ -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);
}
+34
View File
@@ -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 },
},
}),
],
});
+2 -2
View File
@@ -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" }),
+3 -3
View File
@@ -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" }),
+3 -3
View File
@@ -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" }),
+2 -2
View File
@@ -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)
-110
View File
@@ -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)
-1
View File
@@ -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")
+11 -15
View File
@@ -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,
}
}
-22
View File
@@ -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.
-22
View File
@@ -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=為用戶取消指配用戶組。
-22
View File
@@ -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=為用戶取消指配用戶組。
+1
View File
@@ -9,6 +9,7 @@
"clusterio": "*",
"exp_util": "*",
"exp_roles": "*",
"exp_reports": "*",
"exp_gui": "*",
"exp_commands": "*"
}
+10 -4
View File
@@ -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
+1
View File
@@ -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/" },