Address review on the statistics plugin

The plugin is now exp_player_statistics (@expcluster/player-statistics),
with the module, global, permission, route and database directory
renamed to match. The exp-statistics locale section keeps its name.

Every statistic is tracked unconditionally, so the enable flags are gone
from config.lua. Playtime and JoinCount are no longer counted in game:
clusterio already keeps online time and joins for each user, so the
controller fills them in from the user when a player's totals are
requested, and the web page does the same from the user list. They are
not stored or adjusted by the plugin.

The web table has a column picker, a multiple select with check boxes,
Select all and Reset buttons, showing "N statistics selected". The
choice is kept in local storage. The user page shows the statistics of
the player in a grid like the core player stats.

The readme statistics table drops its second title and uses the short
time format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-10-14 23:36:00 +00:00
1 parent 27456e368c
commit ad1c83def8
37 files changed
+515 -418

No files matched your search

@@ -9,7 +9,7 @@ export default async function (context: ControllerPluginContext) {
const databaseDirectory = controller.config.get("controller.database_directory");
const players = new lib.SubscribableDatastore(
...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "exp_statistics", "players.json"),
path.join(databaseDirectory, "exp_player_statistics", "players.json"),
messages.PlayerStatistics.fromJSON.bind(messages.PlayerStatistics),
).bootstrap()
);
@@ -45,9 +45,13 @@ export default async function (context: ControllerPluginContext) {
addDeltas(event.deltas);
});
controller.handle(messages.StatisticsGetRequest, async (request: messages.StatisticsGetRequest) => (
players.get(request.name) ?? new messages.PlayerStatistics(request.name)
));
controller.handle(messages.StatisticsGetRequest, async (request: messages.StatisticsGetRequest) => {
const record = players.get(request.name);
return new messages.PlayerStatistics(request.name, {
...record?.stats,
...messages.userStatistics(controller.users.getByName(request.name)),
}, record?.updatedAtMs);
});
controller.handle(messages.StatisticsListRequest, async () => [...players.values()]);
@@ -3,13 +3,13 @@ import * as messages from "./messages.js";
declare module "@clusterio/lib" {
export interface Permissions {
"exp_statistics.view": never;
"exp_player_statistics.view": never;
}
}
export const plugin: lib.PluginDeclaration = {
name: "exp_statistics",
title: "ExpGaming - Statistics",
name: "exp_player_statistics",
title: "ExpGaming - Player Statistics",
description: "Clusterio plugin keeping player statistics on the controller",
controllerEntrypoint: "./dist/node/controller.js",
@@ -24,7 +24,7 @@ export const plugin: lib.PluginDeclaration = {
permissions: [
{
name: "exp_statistics.view",
name: "exp_player_statistics.view",
title: "View player statistics",
description: "View the statistics of every player and subscribe to their updates",
grantByDefault: true,
@@ -33,6 +33,6 @@ export const plugin: lib.PluginDeclaration = {
webEntrypoint: "./web",
routes: [
"/exp_statistics",
"/exp_player_statistics",
],
};
@@ -24,10 +24,10 @@ export default async function (context: InstancePluginContext) {
while (json.includes(`]${level}]`)) {
level += "=";
}
await luaCall(`exp_statistics.receive_totals(helpers.json_to_table[${level}[${json}]${level}])`);
await luaCall(`exp_player_statistics.receive_totals(helpers.json_to_table[${level}[${json}]${level}])`);
}
instance.server.handle("exp_statistics:update", async (event: IpcStatisticsUpdate) => {
instance.server.handle("exp_player_statistics:update", async (event: IpcStatisticsUpdate) => {
instance.sendTo("controller", new messages.StatisticsUpdateEvent(
event.players.map(player => new messages.StatisticsDelta(player.name, player.stats)),
));
@@ -50,10 +50,10 @@ export default async function (context: InstancePluginContext) {
// Anything still unsent at start came from a save made before the last
// update was sent, so it is dropped rather than counted twice
instance.hooks.start.attach(plugin.name, async () => {
await luaCall("exp_statistics.discard_unsent()");
await luaCall("exp_player_statistics.discard_unsent()");
});
instance.hooks.stop.attach(plugin.name, async () => {
await luaCall("exp_statistics.send_all()");
await luaCall("exp_player_statistics.send_all()");
});
}
@@ -42,6 +42,14 @@ export class PlayerStatistics {
}
}
/** Statistics clusterio already keeps for every user, shown with the others but never added to */
export function userStatistics(user?: Pick<lib.UserDetails, "playerStats">): Record<string, number> {
return {
Playtime: Math.floor((user?.playerStats.onlineTimeMs ?? 0) / 60e3),
JoinCount: user?.playerStats.joinCount ?? 0,
};
}
/** Statistics gained by one player since the last update */
export class StatisticsDelta {
constructor(
@@ -66,7 +74,7 @@ export class StatisticsDelta {
/** Sent by an instance to add to the totals of its players. */
export class StatisticsUpdateEvent {
declare ["constructor"]: typeof StatisticsUpdateEvent;
static plugin = "exp_statistics" as const;
static plugin = "exp_player_statistics" as const;
static type = "event" as const;
static src = "instance" as const;
static dst = "controller" as const;
@@ -91,11 +99,11 @@ export class StatisticsUpdateEvent {
export class PlayerStatisticsUpdatedEvent {
declare ["constructor"]: typeof PlayerStatisticsUpdatedEvent;
static plugin = "exp_statistics" as const;
static plugin = "exp_player_statistics" as const;
static type = "event" as const;
static src = "controller" as const;
static dst = "control" as const;
static permission = "exp_statistics.view" as const;
static permission = "exp_player_statistics.view" as const;
constructor(
public updates: PlayerStatistics[],
@@ -114,14 +122,14 @@ export class PlayerStatisticsUpdatedEvent {
}
}
/** Totals for one player, empty when they have none. */
/** Totals for one player, with Playtime and JoinCount from their clusterio user. */
export class StatisticsGetRequest {
declare ["constructor"]: typeof StatisticsGetRequest;
static plugin = "exp_statistics" as const;
static plugin = "exp_player_statistics" as const;
static type = "request" as const;
static src = ["control", "instance"] as const;
static dst = "controller" as const;
static permission = "exp_statistics.view" as const;
static permission = "exp_player_statistics.view" as const;
static Response = PlayerStatistics;
constructor(
@@ -143,10 +151,10 @@ export class StatisticsGetRequest {
export class StatisticsListRequest {
declare ["constructor"]: typeof StatisticsListRequest;
static plugin = "exp_statistics" as const;
static plugin = "exp_player_statistics" as const;
static type = "request" as const;
static src = ["control", "instance"] as const;
static dst = "controller" as const;
static permission = "exp_statistics.view" as const;
static permission = "exp_player_statistics.view" as const;
static Response = lib.jsonArray(PlayerStatistics);
}
@@ -1,20 +1,10 @@
--[[-- ExpStatistics - Config
Which statistics are tracked, and the order they are shown in.
--[[-- ExpPlayerStatistics - Config
The counted statistics and the order statistics are shown in.
Playtime and JoinCount come from the clusterio user, not from here.
]]
local e = defines.events
return {
MapsPlayed = true, -- Maps a player has joined at least once
Playtime = true, -- Minutes online
AfkTime = true, -- Minutes online after doing nothing for 5 minutes
DistanceTravelled = true, -- Tiles moved while not afk
MachinesRemoved = true, -- Own force entities marked for deconstruction or mined
TreesDestroyed = true, -- Trees marked for deconstruction or mined
OreMined = true, -- Resources mined by hand
DamageDealt = true, -- Damage to entities not on the same force or neutral
Kills = true, -- Entities killed which are not on the same force or neutral
RocketsLaunched = true, -- Counted for every online player on the force
ResearchCompleted = true, -- Counted for every online player on the force
--- Statistics that go up by one each time the event is raised for a player
--- @type table<string, defines.events>
counters = {
@@ -27,12 +17,11 @@ return {
ItemsCrafted = e.on_player_crafted_item,
DeconstructionPlannerUsed = e.on_player_deconstructed_area,
Deaths = e.on_player_died,
JoinCount = e.on_player_joined_game,
TilesRemoved = e.on_player_mined_tile,
CapsulesUsed = e.on_player_used_capsule,
EntityRepaired = e.on_player_repaired_entity,
},
--- The order statistics are shown in, disabled ones are skipped
--- The order statistics are shown in
display_order = {
"Playtime", "AfkTime",
"MapsPlayed", "JoinCount",
@@ -1,4 +1,4 @@
--[[-- ExpStatistics
--[[-- ExpPlayerStatistics
Player statistics kept on the controller.
What each player gains is kept here as unsent until it is sent to the
@@ -10,32 +10,32 @@ Updates which are lost are not sent again. Anything unsent when the server
starts is from a save made before it was sent, so it is dropped rather than
risk counting it twice.
local ExpStatistics = require("modules/exp_statistics")
local ExpPlayerStatistics = require("modules/exp_player_statistics")
ExpStatistics.increment(player, "Kills")
ExpStatistics.get(player, "Kills")
for _, name in ipairs(ExpStatistics.display_order) do end
ExpPlayerStatistics.increment(player, "Kills")
ExpPlayerStatistics.get(player, "Kills")
for _, name in ipairs(ExpPlayerStatistics.display_order) do end
]]
local clusterio_api = require("modules/clusterio/api")
local ExpUtil = require("modules/exp_util")
local Storage = require("modules/exp_util/storage")
local config = require("modules/exp_statistics/config")
local config = require("modules/exp_player_statistics/config")
local format_number = require("util").format_number
--- @alias ExpStatistics.Stats table<string, number>
--- @alias ExpPlayerStatistics.Stats table<string, number>
--- @class ExpStatistics.Totals
--- @class ExpPlayerStatistics.Totals
--- @field name string
--- @field stats ExpStatistics.Stats
--- @field stats ExpPlayerStatistics.Stats
--- @class ExpStatistics
local ExpStatistics = {
--- @class ExpPlayerStatistics
local ExpPlayerStatistics = {
config = config,
--- Enabled statistics in the order they are shown
--- Statistics in the order they are shown
--- @type string[]
display_order = {},
display_order = config.display_order,
--- Shown after the value
--- @type table<string, string>
units = { DistanceTravelled = " tiles" },
@@ -46,14 +46,8 @@ local time_statistics = { Playtime = true, AfkTime = true }
local long_time_format = ExpUtil.format_time_factory_locale{ format = "long", coefficient = 3600, hours = true, minutes = true }
local short_time_format = ExpUtil.format_time_factory_locale{ format = "short", coefficient = 3600, hours = true, minutes = true }
for _, name in ipairs(config.display_order) do
if config[name] == true or config.counters[name] then
ExpStatistics.display_order[#ExpStatistics.display_order + 1] = name
end
end
local unsent = {} --- @type table<string, ExpStatistics.Stats>
local totals = {} --- @type table<string, ExpStatistics.Stats>
local unsent = {} --- @type table<string, ExpPlayerStatistics.Stats>
local totals = {} --- @type table<string, ExpPlayerStatistics.Stats>
Storage.register({ unsent, totals }, function(tbl)
unsent = tbl[1]
totals = tbl[2]
@@ -63,7 +57,7 @@ end)
--- @param player LuaPlayer
--- @param statistic string
--- @param amount number? Defaults to 1
function ExpStatistics.increment(player, statistic, amount)
function ExpPlayerStatistics.increment(player, statistic, amount)
local name = player.name
local stats = unsent[name]
if not stats then
@@ -77,7 +71,7 @@ end
--- @param player LuaPlayer | string
--- @param statistic string
--- @return number
function ExpStatistics.get(player, statistic)
function ExpPlayerStatistics.get(player, statistic)
local name = type(player) == "string" and player or player.name
local received = totals[name]
local pending = unsent[name]
@@ -89,7 +83,7 @@ end
--- @param value number
--- @param short boolean? Use the short time format
--- @return LocalisedString
function ExpStatistics.format(statistic, value, short)
function ExpPlayerStatistics.format(statistic, value, short)
if time_statistics[statistic] then
return short and short_time_format(value) or long_time_format(value)
end
@@ -98,7 +92,7 @@ end
--- Move the unsent statistics of a player into their totals
--- @param name string
--- @return ExpStatistics.Stats?
--- @return ExpPlayerStatistics.Stats?
local function take_unsent(name)
local stats = unsent[name]
if not stats then return nil end
@@ -117,45 +111,45 @@ end
--- Send what a player gained to the controller
--- @param name string
function ExpStatistics.send(name)
function ExpPlayerStatistics.send(name)
local stats = take_unsent(name)
if stats then
clusterio_api.send_json("exp_statistics:update", { players = { { name = name, stats = stats } } })
clusterio_api.send_json("exp_player_statistics:update", { players = { { name = name, stats = stats } } })
end
end
--- Send what every player gained to the controller, in one update
function ExpStatistics.send_all()
function ExpPlayerStatistics.send_all()
local players, count = {}, 0
for name in pairs(unsent) do
count = count + 1
players[count] = { name = name, stats = take_unsent(name) }
end
if count > 0 then
clusterio_api.send_json("exp_statistics:update", { players = players })
clusterio_api.send_json("exp_player_statistics:update", { players = players })
end
end
--- Send what a player gained and forget them, called when they leave
--- @param name string
function ExpStatistics.send_and_forget(name)
ExpStatistics.send(name)
function ExpPlayerStatistics.send_and_forget(name)
ExpPlayerStatistics.send(name)
totals[name] = nil
end
--- Called by the instance plugin over rcon when the server starts
function ExpStatistics.discard_unsent()
function ExpPlayerStatistics.discard_unsent()
for name in pairs(unsent) do
unsent[name] = nil
end
end
--- Called by the instance plugin over rcon when a player joins
--- @param payload ExpStatistics.Totals
function ExpStatistics.receive_totals(payload)
--- @param payload ExpPlayerStatistics.Totals
function ExpPlayerStatistics.receive_totals(payload)
local player = game.get_player(payload.name)
if not player or not player.connected then return end
totals[payload.name] = payload.stats
end
return ExpStatistics
return ExpPlayerStatistics
+171
View File
@@ -0,0 +1,171 @@
--[[-- ExpPlayerStatistics - Events
Tracks the statistics, and sends them to the controller.
]]
local ExpPlayerStatistics = require("modules/exp_player_statistics/control")
local config = ExpPlayerStatistics.config
local increment = ExpPlayerStatistics.increment
local floor = math.floor
local afk_required = 5 * 3600 -- 5 minutes
local send_interval = 5 * 3600 -- 5 minutes
--- @type table<defines.events, fun(event: EventData)>
local events = {}
--- Add a handler, chained after any other for the same event
--- @param event_id defines.events
--- @param handler fun(event: EventData)
local function add(event_id, handler)
local existing = events[event_id]
if existing then
events[event_id] = function(event)
existing(event)
handler(event)
end
else
events[event_id] = handler
end
end
--- Get the player of an event if they are online
--- @param event { player_index: uint? }
--- @return LuaPlayer?
local function get_online_player(event)
if not event.player_index then return nil end
local player = game.players[event.player_index]
if not player or not player.valid or not player.connected then return nil end
return player
end
--- @param event EventData.on_player_left_game
add(defines.events.on_player_left_game, function(event)
local player = game.players[event.player_index]
ExpPlayerStatistics.send_and_forget(player.name)
end)
--- Count a minute of afk time for every player who has been afk long enough
local function count_afk_time()
if game.tick == 0 then return end
for _, player in pairs(game.connected_players) do
if player.afk_time > afk_required then increment(player, "AfkTime") end
end
end
--- @param event EventData.on_player_created
add(defines.events.on_player_created, function(event)
increment(game.players[event.player_index], "MapsPlayed")
end)
--- @param event EventData.on_player_changed_position
add(defines.events.on_player_changed_position, function(event)
local player = get_online_player(event)
if not player or player.afk_time > afk_required then return end
increment(player, "DistanceTravelled")
end)
--- @param event EventData.on_marked_for_deconstruction | EventData.on_player_mined_entity
local function on_removed(event)
local player = get_online_player(event)
if not player then return end
local entity = event.entity
if not entity.valid then return end
if entity.type == "resource" then
increment(player, "OreMined")
elseif entity.type == "tree" then
increment(player, "TreesDestroyed")
elseif entity.force == player.force then
increment(player, "MachinesRemoved")
end
end
add(defines.events.on_marked_for_deconstruction, on_removed)
add(defines.events.on_player_mined_entity, on_removed)
--- Vehicles which credit their driver, or failing that their passenger, with what they hit
local crewed_vehicles = { car = true, ["spider-vehicle"] = true, locomotive = true }
--- Get the connected player behind the cause of damage or a death, nil for turrets, trains without a crew and the like
--- @param cause LuaEntity?
--- @return LuaPlayer?
local function get_cause_player(cause)
if not cause or not cause.valid then return nil end
local occupant = cause --- @type LuaEntity | LuaPlayer
if crewed_vehicles[cause.type] then
local crew = cause.get_driver() or cause.get_passenger()
if not crew then return nil end
occupant = crew
end
local player --- @type LuaPlayer?
if occupant.object_name == "LuaPlayer" then
player = occupant --[[@as LuaPlayer]]
elseif occupant.type == "character" then
player = occupant.player
end
if not player or not player.valid or not player.connected then return nil end
return player
end
--- Check that damage or a death counts against an entity, it must be hostile to the player
--- @param entity LuaEntity
--- @param player LuaPlayer
--- @return boolean
local function is_hostile(entity, player)
return entity.valid and entity.force ~= player.force and entity.force.name ~= "neutral"
end
--- @param event EventData.on_entity_damaged
add(defines.events.on_entity_damaged, function(event)
local player = get_cause_player(event.cause)
if not player or not is_hostile(event.entity, player) then return end
increment(player, "DamageDealt", floor(event.final_damage_amount))
end)
--- @param event EventData.on_entity_died
add(defines.events.on_entity_died, function(event)
local player = get_cause_player(event.cause)
if not player or not is_hostile(event.entity, player) then return end
increment(player, "Kills")
end)
--- Increment a statistic for every online player on a force
--- @param force LuaForce?
--- @param statistic string
local function increment_force(force, statistic)
if not force or not force.valid then return end
for _, player in pairs(force.connected_players) do
increment(player, statistic)
end
end
--- @param event EventData.on_rocket_launched
add(defines.events.on_rocket_launched, function(event)
local silo = event.rocket_silo
if not silo or not silo.valid then return end
increment_force(silo.force --[[@as LuaForce]], "RocketsLaunched")
end)
--- @param event EventData.on_research_finished
add(defines.events.on_research_finished, function(event)
local research = event.research
if event.by_script or not research or not research.valid then return end
increment_force(research.force, "ResearchCompleted")
end)
for statistic, event_id in pairs(config.counters) do
add(event_id, function(event)
local player = get_online_player(event)
if player then increment(player, statistic) end
end)
end
return {
events = events,
on_nth_tick = {
[3600] = count_afk_time,
[send_interval] = ExpPlayerStatistics.send_all,
},
}
+4
View File
@@ -0,0 +1,4 @@
--- @diagnostic disable: global-in-non-module
-- Access using `/sc exp_player_statistics.foo()`
exp_player_statistics = require("modules/exp_player_statistics/control")
@@ -1,5 +1,5 @@
{
"name": "exp_statistics",
"name": "exp_player_statistics",
"load": [
"events.lua"
],
@@ -0,0 +1,2 @@
-- Access the exports from other modules using require("modules/exp_player_statistics")
return require("modules/exp_player_statistics/control")
@@ -1,5 +1,5 @@
{
"name": "@expcluster/statistics",
"name": "@expcluster/player-statistics",
"version": "7.0.1",
"description": "Clusterio plugin keeping player statistics on the controller",
"author": "Cooldude2606 <https://github.com/Cooldude2606>",
@@ -44,7 +44,7 @@ async function startPlugin(t2, databaseDirectory = t2.testdir()) {
subscriptionHandle(Class, handler);
};
await entrypoint({ plugin: { name: "exp_statistics" }, controller, logger });
await entrypoint({ plugin: { name: "exp_player_statistics" }, controller, logger });
const delta = (name, stats) => new messages.StatisticsDelta(name, stats);
return {
controller,
@@ -54,6 +54,10 @@ async function startPlugin(t2, databaseDirectory = t2.testdir()) {
),
get: name => handlers.get(messages.StatisticsGetRequest)(new messages.StatisticsGetRequest(name), fromInstance),
list: () => handlers.get(messages.StatisticsListRequest)(new messages.StatisticsListRequest(), fromInstance),
/** The stored totals, without the values get adds from the clusterio user */
stored: async name => (await handlers.get(messages.StatisticsListRequest)(
new messages.StatisticsListRequest(), fromInstance,
)).find(player => player.id === name)?.stats,
subscription: lastRequestTimeMs => handlers.get(messages.PlayerStatisticsUpdatedEvent)({ lastRequestTimeMs }),
};
}
@@ -61,40 +65,51 @@ async function startPlugin(t2, databaseDirectory = t2.testdir()) {
t.test("entrypoint attaches to save and shutdown", async t2 => {
const { controller } = await startPlugin(t2);
for (const hook of ["save", "shutdown"]) {
t2.ok([...controller.hooks[hook].attached].includes("exp_statistics"), `attached to ${hook}`);
t2.ok([...controller.hooks[hook].attached].includes("exp_player_statistics"), `attached to ${hook}`);
}
});
t.test("controller plugin", t2 => {
t2.test("StatisticsUpdateEvent adds the deltas to the totals", async t3 => {
const { update, get } = await startPlugin(t3);
await update(["alice", { Kills: 2, Playtime: 5 }], ["bob", { Deaths: 1 }]);
const { update, stored } = await startPlugin(t3);
await update(["alice", { Kills: 2, AfkTime: 5 }], ["bob", { Deaths: 1 }]);
await update(["alice", { Kills: 3, Deaths: 1 }]);
t3.strictSame((await get("alice")).stats, { Kills: 5, Playtime: 5, Deaths: 1 }, "deltas are summed per stat");
t3.strictSame((await get("bob")).stats, { Deaths: 1 });
t3.strictSame(await stored("alice"), { Kills: 5, AfkTime: 5, Deaths: 1 }, "deltas are summed per stat");
t3.strictSame(await stored("bob"), { Deaths: 1 });
});
t2.test("StatisticsGetRequest adds Playtime and JoinCount from the clusterio user", async t3 => {
const { controller, update, get } = await startPlugin(t3);
const user = controller.users.getOrCreateUser("alice");
user.instanceStats.set(1, lib.PlayerStats.fromJSON({ join_count: 3, online_time_ms: 125.5 * 60e3 }));
user.recalculatePlayerStats();
await update(["alice", { Kills: 1 }]);
t3.strictSame((await get("alice")).stats, { Kills: 1, Playtime: 125, JoinCount: 3 });
t3.strictSame((await get("bob")).stats, { Playtime: 0, JoinCount: 0 }, "no user and no record");
});
t2.test("StatisticsUpdateEvent merges deltas for the same player in one update", async t3 => {
const { update, get, state } = await startPlugin(t3);
const { update, stored, state } = await startPlugin(t3);
await update(["alice", { Kills: 1 }], ["alice", { Kills: 2 }]);
t3.strictSame((await get("alice")).stats, { Kills: 3 });
t3.strictSame(await stored("alice"), { Kills: 3 });
t3.equal(state.broadcasts.length, 1, "one broadcast");
t3.strictSame(state.broadcasts[0].updates.map(player => player.id), ["alice"], "with the player once");
});
t2.test("StatisticsUpdateEvent ignores values which are not finite or zero", async t3 => {
const { update, get } = await startPlugin(t3);
const { update, stored } = await startPlugin(t3);
await update(["alice", { Kills: 1, Deaths: 0, Bad: Infinity, Worse: NaN }]);
t3.strictSame((await get("alice")).stats, { Kills: 1 });
t3.strictSame(await stored("alice"), { Kills: 1 });
});
t2.test("StatisticsGetRequest gives an empty record for an unknown player", async t3 => {
const { get, list } = await startPlugin(t3);
const record = await get("carol");
t3.equal(record.id, "carol");
t3.strictSame(record.stats, {});
t3.strictSame(record.stats, { Playtime: 0, JoinCount: 0 });
t3.strictSame(await list(), [], "nothing is stored");
});
@@ -119,7 +134,7 @@ t.test("controller plugin", t2 => {
await first.controller.hooks.save.invoke();
const second = await startPlugin(t3, directory);
t3.strictSame((await second.get("alice")).stats, { Kills: 4 });
t3.strictSame(await second.stored("alice"), { Kills: 4 });
});
t2.end();
@@ -127,11 +142,11 @@ t.test("controller plugin", t2 => {
t.test("permissions", async t2 => {
const names = pluginDeclaration.permissions.map(permission => permission.name);
t2.strictSame(names, ["exp_statistics.view"]);
t2.strictSame(names, ["exp_player_statistics.view"]);
for (const Message of [
messages.StatisticsGetRequest, messages.StatisticsListRequest, messages.PlayerStatisticsUpdatedEvent,
]) {
t2.equal(Message.permission, "exp_statistics.view", `${Message.name} needs view`);
t2.equal(Message.permission, "exp_player_statistics.view", `${Message.name} needs view`);
}
t2.equal(messages.StatisticsUpdateEvent.permission, null, "updates only come from instances");
t2.strictSame(messages.StatisticsUpdateEvent.src, "instance");
@@ -54,18 +54,19 @@ async function startPlugin(t2, { status = "running", stats = { Kills: 3 } } = {}
state.sent.length = 0;
const pluginLogger = { ...logger, warn: message => state.warnings.push(message) };
await entrypoint({ plugin: { name: "exp_statistics" }, instance, host: {}, logger: pluginLogger });
return { instance, state, updateIPC: event => ipc.get("exp_statistics:update")(event) };
await entrypoint({ plugin: { name: "exp_player_statistics" }, instance, host: {}, logger: pluginLogger });
return { instance, state, updateIPC: event => ipc.get("exp_player_statistics:update")(event) };
}
/** The payload of a recorded receive_totals command. */
function decodeTotals(command) {
const match = command.match(/^\/sc exp_statistics\.receive_totals\(helpers\.json_to_table\[(=+)\[(.*)\]\1\]\)$/s);
const pattern = /^\/sc exp_player_statistics\.receive_totals\(helpers\.json_to_table\[(=+)\[(.*)\]\1\]\)$/s;
const match = command.match(pattern);
return JSON.parse(match[2]);
}
t.test("instance plugin", t2 => {
t2.test("exp_statistics:update is forwarded to the controller", async t3 => {
t2.test("exp_player_statistics:update is forwarded to the controller", async t3 => {
const { state, updateIPC } = await startPlugin(t3);
await updateIPC({ players: [{ name: "alice", stats: { Kills: 1 } }, { name: "bob", stats: { Deaths: 2 } }] });
@@ -123,8 +124,8 @@ t.test("instance plugin", t2 => {
await instance.hooks.start.invoke();
await instance.hooks.stop.invoke();
t3.strictSame(state.rcons, [
"/sc exp_statistics.discard_unsent()",
"/sc exp_statistics.send_all()",
"/sc exp_player_statistics.discard_unsent()",
"/sc exp_player_statistics.send_all()",
]);
});
File renamed without changes.
@@ -1,4 +1,4 @@
local Suite = ... --- @type Suite<ExpStatistics.TestEnv>
local Suite = ... --- @type Suite<ExpPlayerStatistics.TestEnv>
Suite.test("increment() adds to the unsent statistics", function(env)
local Stats = env.Statistics
@@ -20,7 +20,7 @@ Suite.test("send_all() sends every player with changes in one update", function(
Stats.send_all()
Suite.eq(#env.sent, 1, "one update")
Suite.eq(assert(env.sent[1]).channel, "exp_statistics:update", "on the update channel")
Suite.eq(assert(env.sent[1]).channel, "exp_player_statistics:update", "on the update channel")
local players = assert(env.sent[1]).data.players
table.sort(players, function(a, b) return a.name < b.name end)
Suite.eq(players, {
@@ -53,7 +53,7 @@ Suite.test("send() sends one player", function(env)
Stats.increment(alice, "Kills")
Stats.increment(bob, "Kills")
Stats.send("alice")
Suite.eq(env.sent, { { channel = "exp_statistics:update", data = { players = { { name = "alice", stats = { Kills = 1 } } } } } }, "only that player")
Suite.eq(env.sent, { { channel = "exp_player_statistics:update", data = { players = { { name = "alice", stats = { Kills = 1 } } } } } }, "only that player")
env.reset_log()
Stats.send("carol")
@@ -24,23 +24,23 @@ local event_names = {
"on_player_used_capsule", "on_player_repaired_entity",
}
--- @class ExpStatistics.TestEnv : Stubs
--- @field Statistics ExpStatistics A fresh copy of the statistics module
--- @class ExpPlayerStatistics.TestEnv : Stubs
--- @field Statistics ExpPlayerStatistics A fresh copy of the statistics module
--- @field handlers { events: table<number, fun(event: table)>, on_nth_tick: table<number, fun()> } From events.lua
--- @field force fun(name: string): table A force stub with the online players added to it
--- @field raise fun(name: string, event: table) Call the handler of an event by its name
--- @field add_player fun(params: ExpStatistics.TestPlayerParams): ExpStatistics.TestPlayer
--- @field add_player fun(params: ExpPlayerStatistics.TestPlayerParams): ExpPlayerStatistics.TestPlayer
--- @class ExpStatistics.TestPlayerParams : Stubs.PlayerParams
--- @class ExpPlayerStatistics.TestPlayerParams : Stubs.PlayerParams
--- @field afk_time number? Defaults to 0
--- @field force string? Defaults to "player"
--- @class ExpStatistics.TestPlayer : Stubs.Player
--- @class ExpPlayerStatistics.TestPlayer : Stubs.Player
--- @field afk_time number
--- @field force table
return Framework.suite(function(env)
--- @cast env ExpStatistics.TestEnv
--- @cast env ExpPlayerStatistics.TestEnv
local events = {}
for index, name in ipairs(event_names) do
events[name] = 10 + index
@@ -61,10 +61,10 @@ return Framework.suite(function(env)
},
}
env.extend_requires{
["modules/exp_statistics/config"] = assert(loadfile(plugin_root .. "/module/config.lua"))(),
["modules/exp_player_statistics/config"] = assert(loadfile(plugin_root .. "/module/config.lua"))(),
}
env.Statistics = assert(loadfile(plugin_root .. "/module/control.lua"))() --- @type ExpStatistics
env.extend_requires{ ["modules/exp_statistics/control"] = env.Statistics }
env.Statistics = assert(loadfile(plugin_root .. "/module/control.lua"))() --- @type ExpPlayerStatistics
env.extend_requires{ ["modules/exp_player_statistics/control"] = env.Statistics }
env.handlers = assert(loadfile(plugin_root .. "/module/events.lua"))()
local forces = {}
@@ -79,7 +79,7 @@ return Framework.suite(function(env)
local add_player = env.add_player
function env.add_player(params)
local player = add_player(params) --[[@as ExpStatistics.TestPlayer]]
local player = add_player(params) --[[@as ExpPlayerStatistics.TestPlayer]]
local force = env.force(params.force or "player")
rawset(player, "afk_time", params.afk_time or 0)
rawset(player, "force", force)
@@ -1,4 +1,4 @@
local Suite = ... --- @type Suite<ExpStatistics.TestEnv>
local Suite = ... --- @type Suite<ExpPlayerStatistics.TestEnv>
Suite.test("counters go up by one for online players", function(env)
local Stats = env.Statistics
@@ -21,7 +21,7 @@ Suite.test("a player is counted once per map", function(env)
Suite.eq(env.Statistics.get(alice, "MapsPlayed"), 1, "maps played")
end)
Suite.test("playtime and afk time go up each minute", function(env)
Suite.test("afk time goes up each minute, playtime comes from clusterio", function(env)
local Stats = env.Statistics
local alice = env.add_player{ name = "alice" }
local bob = env.add_player{ name = "bob", afk_time = 6 * 3600 }
@@ -30,7 +30,7 @@ Suite.test("playtime and afk time go up each minute", function(env)
tick()
tick()
Suite.eq(Stats.get(alice, "Playtime"), 2, "playtime")
Suite.eq(Stats.get(alice, "Playtime"), 0, "playtime is not counted")
Suite.eq(Stats.get(alice, "AfkTime"), 0, "not afk")
Suite.eq(Stats.get(bob, "AfkTime"), 2, "afk")
end)
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,114 @@
import React, { useMemo, useState } from "react";
import { Button, Checkbox, Divider, Select, Space, Table } from "antd";
import type { ColumnsType } from "antd/es/table";
import { PageHeader, PageLayout, useColumnSearch, useTableQueryState, useUsers } from "@clusterio/web_ui";
import { PlayerStatistics, userStatistics } from "../../messages.js";
import { usePlayerStatistics } from "..";
import { formatValue, statistics } from "../statistics";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
const storageKey = "exp_player_statistics.columns";
const defaultColumns = [
"Playtime", "AfkTime", "MapsPlayed", "JoinCount", "ChatMessages",
"RocketsLaunched", "ResearchCompleted", "MachinesBuilt", "Kills", "Deaths",
];
function loadColumns() {
try {
const stored = JSON.parse(localStorage.getItem(storageKey) ?? "null");
if (Array.isArray(stored)) {
return stored.filter(name => statistics.some(([statistic]) => statistic === name));
}
} catch { /* fall back to the defaults */ }
return defaultColumns;
}
export default function StatisticsPage() {
const [players, synced] = usePlayerStatistics();
const [users] = useUsers();
const [columns, setColumnsState] = useState<string[]>(loadColumns);
const tableState = useTableQueryState<PlayerStatistics>({
namespace: "statistics",
defaultSortKey: "Playtime",
defaultSortOrder: "descend",
pagination: { defaultPageSize: 50 },
});
const playerSearch = useColumnSearch<PlayerStatistics>(tableState, "player", player => player.id, "Search players");
function setColumns(names: string[]) {
// Keep the display order whatever order they were picked in
const ordered = statistics.map(([name]) => name).filter(name => names.includes(name));
setColumnsState(ordered);
localStorage.setItem(storageKey, JSON.stringify(ordered));
}
const rows = useMemo(() => [...players.values()].map(player => new PlayerStatistics(
player.id,
{ ...player.stats, ...userStatistics(users.get(player.id.toLowerCase())) },
player.updatedAtMs,
)), [players, users]);
const tableColumns: ColumnsType<PlayerStatistics> = [
{
title: "Player",
key: "player",
fixed: "left",
render: (_, player) => player.id,
sorter: (a, b) => strcmp(a.id, b.id),
sortOrder: tableState.sortOrder("player"),
filteredValue: tableState.filteredValue("player"),
...playerSearch,
},
...statistics.filter(([name]) => columns.includes(name)).map(
([name, title]): ColumnsType<PlayerStatistics>[number] => ({
title,
key: name,
align: "right",
render: (_, player) => formatValue(name, player.stats[name] ?? 0),
sorter: (a, b) => (a.stats[name] ?? 0) - (b.stats[name] ?? 0),
sortDirections: ["descend", "ascend"],
sortOrder: tableState.sortOrder(name),
})
),
];
const columnSelect = <Select
mode="multiple"
style={{ minWidth: 220 }}
value={columns}
onChange={setColumns}
options={statistics.map(([value, label]) => ({ value, label }))}
maxTagCount={0}
maxTagPlaceholder={() => `${columns.length} statistics selected`}
placeholder="No statistics selected"
menuItemSelectedIcon={null}
optionRender={option => <Checkbox checked={columns.includes(option.value as string)}>
{option.label}
</Checkbox>}
popupRender={menu => <>
{menu}
<Divider style={{ margin: "4px 0" }} />
<Space style={{ padding: "0 8px 4px" }}>
<Button size="small" onClick={() => setColumns(statistics.map(([name]) => name))}>Select all</Button>
<Button size="small" onClick={() => setColumns(defaultColumns)}>Reset</Button>
</Space>
</>}
/>;
return <PageLayout nav={[{ name: "Statistics" }]}>
<PageHeader title="Statistics" extra={columnSelect} />
<Table
size="small"
columns={tableColumns}
dataSource={rows}
loading={!synced}
rowKey={player => player.id}
pagination={tableState.pagination}
onChange={tableState.onChange}
scroll={{ x: "max-content" }}
/>
</PageLayout>;
}
@@ -0,0 +1,37 @@
import React, { useContext, useEffect, useState } from "react";
import { Descriptions } from "antd";
import { ControlContext, PluginExtensionProps, SectionHeader, notifyErrorHandler, useAccount } from "@clusterio/web_ui";
import { PlayerStatistics, StatisticsGetRequest } from "../../messages.js";
import { formatValue, statistics } from "../statistics";
/** The statistics of a player, shown on their user page. */
export default function UserStatistics(props: PluginExtensionProps<"UserViewPage">) {
const control = useContext(ControlContext);
const account = useAccount();
const canView = Boolean(account.hasPermission("exp_player_statistics.view"));
const name = props.user.name;
const [record, setRecord] = useState<PlayerStatistics>();
useEffect(() => {
if (canView) {
control.send(new StatisticsGetRequest(name))
.then(setRecord)
.catch(notifyErrorHandler("Error loading player statistics"));
}
}, [name, canView]);
if (!canView || !record) {
return null;
}
return <>
<SectionHeader title="Statistics" />
<Descriptions size="small" bordered column={{ xs: 1, sm: 2, md: 2, lg: 2, xl: 2, xxl: 2 }}>
{statistics.map(([statistic, title]) => <Descriptions.Item key={statistic} label={title}>
{formatValue(statistic, record.stats[statistic] ?? 0)}
</Descriptions.Item>)}
</Descriptions>
</>;
}
@@ -5,6 +5,7 @@ import * as lib from "@clusterio/lib";
import * as messages from "../messages.js";
import StatisticsPage from "./components/StatisticsPage";
import UserStatistics from "./components/UserStatistics";
let players: lib.MapSubscriber<messages.PlayerStatisticsUpdatedEvent> | undefined;
@@ -14,11 +15,15 @@ export function usePlayerStatistics() {
}
export default function registerPlugin(context: WebPluginContext) {
context.control.hooks.pages.attach("exp_statistics", () => [
context.control.hooks.extensionComponents.attach("exp_player_statistics", () => ({
UserViewPage: UserStatistics,
}));
context.control.hooks.pages.attach("exp_player_statistics", () => [
{
path: "/exp_statistics",
path: "/exp_player_statistics",
sidebarName: "Statistics",
permission: "exp_statistics.view",
permission: "exp_player_statistics.view",
content: <StatisticsPage />,
},
]);
+40
View File
@@ -0,0 +1,40 @@
/** Same order and names as module/config.lua and module/locale/en.cfg */
export const statistics: [name: string, title: string][] = [
["Playtime", "Playtime"],
["AfkTime", "AFK Time"],
["MapsPlayed", "Maps Played"],
["JoinCount", "Join Count"],
["ChatMessages", "Messages"],
["CommandsUsed", "Commands"],
["RocketsLaunched", "Rockets Launched"],
["ResearchCompleted", "Research Completed"],
["MachinesBuilt", "Machines Built"],
["MachinesRemoved", "Machines Removed"],
["TilesBuilt", "Tiles Placed"],
["TilesRemoved", "Tiles Removed"],
["TreesDestroyed", "Trees Destroyed"],
["OreMined", "Ore Mined"],
["ItemsCrafted", "Items Crafted"],
["ItemsPickedUp", "Items Picked Up"],
["Kills", "Kills"],
["Deaths", "Deaths"],
["DamageDealt", "Damage Dealt"],
["DistanceTravelled", "Distance Travelled"],
["CapsulesUsed", "Capsules Used"],
["EntityRepaired", "Machines Repaired"],
["DeconstructionPlannerUsed", "Decon Planner Used"],
["MapTagsMade", "Map Tags Created"],
];
/** Measured in minutes */
const timeStatistics = new Set(["Playtime", "AfkTime"]);
export function formatValue(name: string, value: number) {
if (timeStatistics.has(name)) {
return `${Math.floor(value / 60)}h ${value % 60}m`;
}
if (name === "DistanceTravelled") {
return `${value.toLocaleString()} tiles`;
}
return value.toLocaleString();
}
@@ -13,8 +13,8 @@ module.exports = (env = {}) => merge(common(env), {
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "exp_statistics",
library: { type: "window", name: "plugin_exp_statistics" },
name: "exp_player_statistics",
library: { type: "window", name: "plugin_exp_player_statistics" },
exposes: {
"./": "./index.ts",
"./package.json": "./package.json",
+7 -7
View File
@@ -6,7 +6,7 @@ local ExpUtil = require("modules/exp_util")
local Gui = require("modules/exp_gui")
local ElementsExtra = require("modules/exp_scenario/gui/elements")
local Roles = require("modules/exp_roles")
local ExpStatistics = require("modules/exp_statistics")
local ExpPlayerStatistics = require("modules/exp_player_statistics")
--- @class ExpGui_PlayerStats.elements
local Elements = {}
@@ -26,7 +26,7 @@ local short_time_zero, format_number_zero = short_time_format(0), format_number_
--- @param default number
--- @return number
local function get_stat(player, statistic, default)
local value = ExpStatistics.get(player, statistic)
local value = ExpPlayerStatistics.get(player, statistic)
return value == 0 and default or value
end
@@ -113,14 +113,14 @@ Elements.player_stats_table = Gui.define("player_stats/data_table")
local labels = {}
-- Add all standalone stats
for _, stat_name in ipairs(ExpStatistics.display_order) do
for _, stat_name in ipairs(ExpPlayerStatistics.display_order) do
Elements.table_label(data_table, {
caption = { "exp-statistics." .. stat_name },
tooltip = { "exp-statistics." .. stat_name .. "-tooltip" },
width = 135,
})
labels[stat_name] = Elements.table_label(data_table, {
caption = { "exp-gui_readme.data-format", ExpStatistics.format(stat_name, 0, true), ExpStatistics.units[stat_name] or "" },
caption = { "exp-gui_readme.data-format", ExpPlayerStatistics.format(stat_name, 0, true), ExpPlayerStatistics.units[stat_name] or "" },
tooltip = { "exp-statistics." .. stat_name .. "-tooltip" },
width = 105,
})
@@ -151,9 +151,9 @@ function Elements.player_stats_table.refresh(data_table, player)
local labels = Elements.player_stats_table.data[data_table]
-- Update all standalone stats
for _, stat_name in ipairs(ExpStatistics.display_order) do
local value = ExpStatistics.format(stat_name, ExpStatistics.get(player, stat_name), true)
labels[stat_name].caption = { "exp-gui_readme.data-format", value, ExpStatistics.units[stat_name] or "" }
for _, stat_name in ipairs(ExpPlayerStatistics.display_order) do
local value = ExpPlayerStatistics.format(stat_name, ExpPlayerStatistics.get(player, stat_name), true)
labels[stat_name].caption = { "exp-gui_readme.data-format", value, ExpPlayerStatistics.units[stat_name] or "" }
end
-- Update all computed stats
+7 -5
View File
@@ -6,7 +6,7 @@ local ExpUtil = require("modules/exp_util")
local Gui = require("modules/exp_gui")
local Roles = require("modules/exp_roles")
local Commands = require("modules/exp_commands")
local ExpStatistics = require("modules/exp_statistics")
local ExpPlayerStatistics = require("modules/exp_player_statistics")
local ExpServerList = require("modules/exp_server_list")
local community = require("modules/exp_scenario/config/community_details").config
@@ -471,12 +471,14 @@ define_tab(
container.add{ type = "flow" }
local scroll_pane = Elements.title_table_scroll(container)
local statistics = Elements.title_table(scroll_pane, 250, { "exp-gui_readme.data-tab" }, 4)
for _, name in ipairs(ExpStatistics.display_order) do
local value = ExpStatistics.format(name, ExpStatistics.get(player_name, name))
local statistics = scroll_pane.add{ type = "table", column_count = 4, style = "bordered_table" }
statistics.style.cell_padding = 0
statistics.style.horizontally_stretchable = true
for _, name in ipairs(ExpPlayerStatistics.display_order) do
local value = ExpPlayerStatistics.format(name, ExpPlayerStatistics.get(player_name, name), true)
local tooltip = { "exp-statistics." .. name .. "-tooltip" }
Gui.elements.centered_label(statistics, 150, { "exp-statistics." .. name }, tooltip)
Gui.elements.centered_label(statistics, 130, { "exp-gui_readme.data-format", value, ExpStatistics.units[name] or "" }, tooltip)
Gui.elements.centered_label(statistics, 130, { "exp-gui_readme.data-format", value, ExpPlayerStatistics.units[name] or "" }, tooltip)
end
return container
+1 -1
View File
@@ -12,7 +12,7 @@
"exp_roles": "*",
"exp_reports": "*",
"exp_server_list": "*",
"exp_statistics": "*",
"exp_player_statistics": "*",
"exp_gui": "*",
"exp_commands": "*"
}
+1 -1
View File
@@ -52,7 +52,7 @@
"@expcluster/permission-groups": "workspace:^",
"@expcluster/roles": "workspace:^",
"@expcluster/server-list": "workspace:^",
"@expcluster/statistics": "workspace:^",
"@expcluster/player-statistics": "workspace:^",
"@sinclair/typebox": "catalog:"
},
"publishConfig": {
-188
View File
@@ -1,188 +0,0 @@
--[[-- ExpStatistics - Events
Tracks the statistics enabled in config.lua, and sends them to the controller.
]]
local ExpStatistics = require("modules/exp_statistics/control")
local config = ExpStatistics.config
local increment = ExpStatistics.increment
local floor = math.floor
local afk_required = 5 * 3600 -- 5 minutes
local send_interval = 5 * 3600 -- 5 minutes
--- @type table<defines.events, fun(event: EventData)>
local events = {}
--- Add a handler, chained after any other for the same event
--- @param event_id defines.events
--- @param handler fun(event: EventData)
local function add(event_id, handler)
local existing = events[event_id]
if existing then
events[event_id] = function(event)
existing(event)
handler(event)
end
else
events[event_id] = handler
end
end
--- Get the player of an event if they are online
--- @param event { player_index: uint? }
--- @return LuaPlayer?
local function get_online_player(event)
if not event.player_index then return nil end
local player = game.players[event.player_index]
if not player or not player.valid or not player.connected then return nil end
return player
end
--- @param event EventData.on_player_left_game
add(defines.events.on_player_left_game, function(event)
local player = game.players[event.player_index]
ExpStatistics.send_and_forget(player.name)
end)
--- @type table<number, fun()>
local on_nth_tick = {
[send_interval] = ExpStatistics.send_all,
}
if config.MapsPlayed then
--- @param event EventData.on_player_created
add(defines.events.on_player_created, function(event)
increment(game.players[event.player_index], "MapsPlayed")
end)
end
if config.Playtime or config.AfkTime then
on_nth_tick[3600] = function()
if game.tick == 0 then return end
for _, player in pairs(game.connected_players) do
if config.Playtime then increment(player, "Playtime") end
if config.AfkTime and player.afk_time > afk_required then increment(player, "AfkTime") end
end
end
end
if config.DistanceTravelled then
--- @param event EventData.on_player_changed_position
add(defines.events.on_player_changed_position, function(event)
local player = get_online_player(event)
if not player or player.afk_time > afk_required then return end
increment(player, "DistanceTravelled")
end)
end
if config.MachinesRemoved or config.TreesDestroyed or config.OreMined then
--- @param event EventData.on_marked_for_deconstruction | EventData.on_player_mined_entity
local function on_removed(event)
local player = get_online_player(event)
if not player then return end
local entity = event.entity
if not entity.valid then return end
if entity.type == "resource" then
if config.OreMined then increment(player, "OreMined") end
elseif entity.type == "tree" then
if config.TreesDestroyed then increment(player, "TreesDestroyed") end
elseif entity.force == player.force then
if config.MachinesRemoved then increment(player, "MachinesRemoved") end
end
end
add(defines.events.on_marked_for_deconstruction, on_removed)
add(defines.events.on_player_mined_entity, on_removed)
end
--- Vehicles which credit their driver, or failing that their passenger, with what they hit
local crewed_vehicles = { car = true, ["spider-vehicle"] = true, locomotive = true }
--- Get the connected player behind the cause of damage or a death, nil for turrets, trains without a crew and the like
--- @param cause LuaEntity?
--- @return LuaPlayer?
local function get_cause_player(cause)
if not cause or not cause.valid then return nil end
local occupant = cause --- @type LuaEntity | LuaPlayer
if crewed_vehicles[cause.type] then
local crew = cause.get_driver() or cause.get_passenger()
if not crew then return nil end
occupant = crew
end
local player --- @type LuaPlayer?
if occupant.object_name == "LuaPlayer" then
player = occupant --[[@as LuaPlayer]]
elseif occupant.type == "character" then
player = occupant.player
end
if not player or not player.valid or not player.connected then return nil end
return player
end
--- Check that damage or a death counts against an entity, it must be hostile to the player
--- @param entity LuaEntity
--- @param player LuaPlayer
--- @return boolean
local function is_hostile(entity, player)
return entity.valid and entity.force ~= player.force and entity.force.name ~= "neutral"
end
if config.DamageDealt then
--- @param event EventData.on_entity_damaged
add(defines.events.on_entity_damaged, function(event)
local player = get_cause_player(event.cause)
if not player or not is_hostile(event.entity, player) then return end
increment(player, "DamageDealt", floor(event.final_damage_amount))
end)
end
if config.Kills then
--- @param event EventData.on_entity_died
add(defines.events.on_entity_died, function(event)
local player = get_cause_player(event.cause)
if not player or not is_hostile(event.entity, player) then return end
increment(player, "Kills")
end)
end
--- Increment a statistic for every online player on a force
--- @param force LuaForce?
--- @param statistic string
local function increment_force(force, statistic)
if not force or not force.valid then return end
for _, player in pairs(force.connected_players) do
increment(player, statistic)
end
end
if config.RocketsLaunched then
--- @param event EventData.on_rocket_launched
add(defines.events.on_rocket_launched, function(event)
local silo = event.rocket_silo
if not silo or not silo.valid then return end
increment_force(silo.force --[[@as LuaForce]], "RocketsLaunched")
end)
end
if config.ResearchCompleted then
--- @param event EventData.on_research_finished
add(defines.events.on_research_finished, function(event)
local research = event.research
if event.by_script or not research or not research.valid then return end
increment_force(research.force, "ResearchCompleted")
end)
end
for statistic, event_id in pairs(config.counters) do
add(event_id, function(event)
local player = get_online_player(event)
if player then increment(player, statistic) end
end)
end
return {
events = events,
on_nth_tick = on_nth_tick,
}
-4
View File
@@ -1,4 +0,0 @@
--- @diagnostic disable: global-in-non-module
-- Access using `/sc exp_statistics.foo()`
exp_statistics = require("modules/exp_statistics/control")
-2
View File
@@ -1,2 +0,0 @@
-- Access the exports from other modules using require("modules/exp_statistics")
return require("modules/exp_statistics/control")
@@ -1,95 +0,0 @@
import React from "react";
import { Table } from "antd";
import type { ColumnsType } from "antd/es/table";
import { PageHeader, PageLayout, useColumnSearch, useTableQueryState } from "@clusterio/web_ui";
import { PlayerStatistics } from "../../messages.js";
import { usePlayerStatistics } from "..";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
/** Same order and names as module/config.lua and module/locale/en.cfg */
const statistics: [name: string, title: string][] = [
["Playtime", "Playtime"],
["AfkTime", "AFK Time"],
["MapsPlayed", "Maps Played"],
["JoinCount", "Join Count"],
["ChatMessages", "Messages"],
["CommandsUsed", "Commands"],
["RocketsLaunched", "Rockets Launched"],
["ResearchCompleted", "Research Completed"],
["MachinesBuilt", "Machines Built"],
["MachinesRemoved", "Machines Removed"],
["TilesBuilt", "Tiles Placed"],
["TilesRemoved", "Tiles Removed"],
["TreesDestroyed", "Trees Destroyed"],
["OreMined", "Ore Mined"],
["ItemsCrafted", "Items Crafted"],
["ItemsPickedUp", "Items Picked Up"],
["Kills", "Kills"],
["Deaths", "Deaths"],
["DamageDealt", "Damage Dealt"],
["DistanceTravelled", "Distance Travelled"],
["CapsulesUsed", "Capsules Used"],
["EntityRepaired", "Machines Repaired"],
["DeconstructionPlannerUsed", "Decon Planner Used"],
["MapTagsMade", "Map Tags Created"],
];
/** Measured in minutes */
const timeStatistics = new Set(["Playtime", "AfkTime"]);
function formatValue(name: string, value: number) {
if (timeStatistics.has(name)) {
return `${Math.floor(value / 60)}h ${value % 60}m`;
}
return value.toLocaleString();
}
export default function StatisticsPage() {
const [players, synced] = usePlayerStatistics();
const tableState = useTableQueryState<PlayerStatistics>({
namespace: "statistics",
defaultSortKey: "Playtime",
defaultSortOrder: "descend",
pagination: { defaultPageSize: 50 },
});
const playerSearch = useColumnSearch<PlayerStatistics>(tableState, "player", player => player.id, "Search players");
const columns: ColumnsType<PlayerStatistics> = [
{
title: "Player",
key: "player",
fixed: "left",
render: (_, player) => player.id,
sorter: (a, b) => strcmp(a.id, b.id),
sortOrder: tableState.sortOrder("player"),
filteredValue: tableState.filteredValue("player"),
...playerSearch,
},
...statistics.map(([name, title]): ColumnsType<PlayerStatistics>[number] => ({
title,
key: name,
align: "right",
render: (_, player) => formatValue(name, player.stats[name] ?? 0),
sorter: (a, b) => (a.stats[name] ?? 0) - (b.stats[name] ?? 0),
sortDirections: ["descend", "ascend"],
sortOrder: tableState.sortOrder(name),
})),
];
return <PageLayout nav={[{ name: "Statistics" }]}>
<PageHeader title="Statistics" />
<Table
size="small"
columns={columns}
dataSource={[...players.values()]}
loading={!synced}
rowKey={player => player.id}
pagination={tableState.pagination}
onChange={tableState.onChange}
scroll={{ x: "max-content" }}
/>
</PageLayout>;
}
+1 -1
View File
@@ -12,7 +12,7 @@
{ "path": "./exp_scenario/" },
{ "path": "./exp_server_list/" },
{ "path": "./exp_server_ups/" },
{ "path": "./exp_statistics/" },
{ "path": "./exp_player_statistics/" },
{ "path": "./exp_util/" },
],
}