From ad1c83def8316ae838f58a6eedbfad6f5fa37f33 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:36:00 +0000 Subject: [PATCH] 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 --- .../controller.ts | 12 +- .../index.ts | 10 +- .../instance.ts | 8 +- .../messages.ts | 24 ++- .../module/config.lua | 19 +- .../module/control.lua | 64 +++--- exp_player_statistics/module/events.lua | 171 ++++++++++++++++ exp_player_statistics/module/globals.lua | 4 + .../module/locale/en.cfg | 0 .../module/locale/zh-CN.cfg | 0 .../module/locale/zh-TW.cfg | 0 .../module/module.json | 2 +- .../module/module_exports.lua | 2 + .../package.json | 2 +- .../test/controller.test.js | 43 ++-- .../test/instance.test.js | 13 +- .../test/module.test.js | 0 .../test/module/control.lua | 6 +- .../test/module/env.lua | 20 +- .../test/module/events.lua | 6 +- .../tsconfig.browser.json | 0 .../tsconfig.json | 0 .../tsconfig.node.json | 0 .../web/components/StatisticsPage.tsx | 114 +++++++++++ .../web/components/UserStatistics.tsx | 37 ++++ .../web/index.tsx | 11 +- exp_player_statistics/web/statistics.ts | 40 ++++ .../webpack.config.cjs | 4 +- exp_scenario/module/gui/player_stats.lua | 14 +- exp_scenario/module/gui/readme.lua | 12 +- exp_scenario/module/module.json | 2 +- exp_scenario/package.json | 2 +- exp_statistics/module/events.lua | 188 ------------------ exp_statistics/module/globals.lua | 4 - exp_statistics/module/module_exports.lua | 2 - .../web/components/StatisticsPage.tsx | 95 --------- tsconfig.json | 2 +- 37 files changed, 515 insertions(+), 418 deletions(-) rename {exp_statistics => exp_player_statistics}/controller.ts (86%) rename {exp_statistics => exp_player_statistics}/index.ts (80%) rename {exp_statistics => exp_player_statistics}/instance.ts (84%) rename {exp_statistics => exp_player_statistics}/messages.ts (81%) rename {exp_statistics => exp_player_statistics}/module/config.lua (56%) rename {exp_statistics => exp_player_statistics}/module/control.lua (71%) create mode 100644 exp_player_statistics/module/events.lua create mode 100644 exp_player_statistics/module/globals.lua rename {exp_statistics => exp_player_statistics}/module/locale/en.cfg (100%) rename {exp_statistics => exp_player_statistics}/module/locale/zh-CN.cfg (100%) rename {exp_statistics => exp_player_statistics}/module/locale/zh-TW.cfg (100%) rename {exp_statistics => exp_player_statistics}/module/module.json (79%) create mode 100644 exp_player_statistics/module/module_exports.lua rename {exp_statistics => exp_player_statistics}/package.json (97%) rename {exp_statistics => exp_player_statistics}/test/controller.test.js (74%) rename {exp_statistics => exp_player_statistics}/test/instance.test.js (89%) rename {exp_statistics => exp_player_statistics}/test/module.test.js (100%) rename {exp_statistics => exp_player_statistics}/test/module/control.lua (93%) rename {exp_statistics => exp_player_statistics}/test/module/env.lua (82%) rename {exp_statistics => exp_player_statistics}/test/module/events.lua (96%) rename {exp_statistics => exp_player_statistics}/tsconfig.browser.json (100%) rename {exp_statistics => exp_player_statistics}/tsconfig.json (100%) rename {exp_statistics => exp_player_statistics}/tsconfig.node.json (100%) create mode 100644 exp_player_statistics/web/components/StatisticsPage.tsx create mode 100644 exp_player_statistics/web/components/UserStatistics.tsx rename {exp_statistics => exp_player_statistics}/web/index.tsx (70%) create mode 100644 exp_player_statistics/web/statistics.ts rename {exp_statistics => exp_player_statistics}/webpack.config.cjs (88%) delete mode 100644 exp_statistics/module/events.lua delete mode 100644 exp_statistics/module/globals.lua delete mode 100644 exp_statistics/module/module_exports.lua delete mode 100644 exp_statistics/web/components/StatisticsPage.tsx diff --git a/exp_statistics/controller.ts b/exp_player_statistics/controller.ts similarity index 86% rename from exp_statistics/controller.ts rename to exp_player_statistics/controller.ts index 99a4e83c..21bd1f95 100644 --- a/exp_statistics/controller.ts +++ b/exp_player_statistics/controller.ts @@ -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()]); diff --git a/exp_statistics/index.ts b/exp_player_statistics/index.ts similarity index 80% rename from exp_statistics/index.ts rename to exp_player_statistics/index.ts index 6b9ec3eb..3af21429 100644 --- a/exp_statistics/index.ts +++ b/exp_player_statistics/index.ts @@ -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", ], }; diff --git a/exp_statistics/instance.ts b/exp_player_statistics/instance.ts similarity index 84% rename from exp_statistics/instance.ts rename to exp_player_statistics/instance.ts index bd285bb8..56553c56 100644 --- a/exp_statistics/instance.ts +++ b/exp_player_statistics/instance.ts @@ -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()"); }); } diff --git a/exp_statistics/messages.ts b/exp_player_statistics/messages.ts similarity index 81% rename from exp_statistics/messages.ts rename to exp_player_statistics/messages.ts index b7be099f..ad6f8da3 100644 --- a/exp_statistics/messages.ts +++ b/exp_player_statistics/messages.ts @@ -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): Record { + 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); } diff --git a/exp_statistics/module/config.lua b/exp_player_statistics/module/config.lua similarity index 56% rename from exp_statistics/module/config.lua rename to exp_player_statistics/module/config.lua index 381a73e2..d2725e73 100644 --- a/exp_statistics/module/config.lua +++ b/exp_player_statistics/module/config.lua @@ -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 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", diff --git a/exp_statistics/module/control.lua b/exp_player_statistics/module/control.lua similarity index 71% rename from exp_statistics/module/control.lua rename to exp_player_statistics/module/control.lua index 6d3b000d..30946870 100644 --- a/exp_statistics/module/control.lua +++ b/exp_player_statistics/module/control.lua @@ -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 +--- @alias ExpPlayerStatistics.Stats table ---- @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 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 -local totals = {} --- @type table +local unsent = {} --- @type table +local totals = {} --- @type table 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 diff --git a/exp_player_statistics/module/events.lua b/exp_player_statistics/module/events.lua new file mode 100644 index 00000000..d7244bc6 --- /dev/null +++ b/exp_player_statistics/module/events.lua @@ -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 +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, + }, +} diff --git a/exp_player_statistics/module/globals.lua b/exp_player_statistics/module/globals.lua new file mode 100644 index 00000000..d7f0c83d --- /dev/null +++ b/exp_player_statistics/module/globals.lua @@ -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") diff --git a/exp_statistics/module/locale/en.cfg b/exp_player_statistics/module/locale/en.cfg similarity index 100% rename from exp_statistics/module/locale/en.cfg rename to exp_player_statistics/module/locale/en.cfg diff --git a/exp_statistics/module/locale/zh-CN.cfg b/exp_player_statistics/module/locale/zh-CN.cfg similarity index 100% rename from exp_statistics/module/locale/zh-CN.cfg rename to exp_player_statistics/module/locale/zh-CN.cfg diff --git a/exp_statistics/module/locale/zh-TW.cfg b/exp_player_statistics/module/locale/zh-TW.cfg similarity index 100% rename from exp_statistics/module/locale/zh-TW.cfg rename to exp_player_statistics/module/locale/zh-TW.cfg diff --git a/exp_statistics/module/module.json b/exp_player_statistics/module/module.json similarity index 79% rename from exp_statistics/module/module.json rename to exp_player_statistics/module/module.json index be0eb2e2..31cbe2c8 100644 --- a/exp_statistics/module/module.json +++ b/exp_player_statistics/module/module.json @@ -1,5 +1,5 @@ { - "name": "exp_statistics", + "name": "exp_player_statistics", "load": [ "events.lua" ], diff --git a/exp_player_statistics/module/module_exports.lua b/exp_player_statistics/module/module_exports.lua new file mode 100644 index 00000000..7d20698c --- /dev/null +++ b/exp_player_statistics/module/module_exports.lua @@ -0,0 +1,2 @@ +-- Access the exports from other modules using require("modules/exp_player_statistics") +return require("modules/exp_player_statistics/control") diff --git a/exp_statistics/package.json b/exp_player_statistics/package.json similarity index 97% rename from exp_statistics/package.json rename to exp_player_statistics/package.json index 988ff0fa..37691e50 100644 --- a/exp_statistics/package.json +++ b/exp_player_statistics/package.json @@ -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 ", diff --git a/exp_statistics/test/controller.test.js b/exp_player_statistics/test/controller.test.js similarity index 74% rename from exp_statistics/test/controller.test.js rename to exp_player_statistics/test/controller.test.js index e077fa38..a990133a 100644 --- a/exp_statistics/test/controller.test.js +++ b/exp_player_statistics/test/controller.test.js @@ -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"); diff --git a/exp_statistics/test/instance.test.js b/exp_player_statistics/test/instance.test.js similarity index 89% rename from exp_statistics/test/instance.test.js rename to exp_player_statistics/test/instance.test.js index a9bc86e6..e7742994 100644 --- a/exp_statistics/test/instance.test.js +++ b/exp_player_statistics/test/instance.test.js @@ -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()", ]); }); diff --git a/exp_statistics/test/module.test.js b/exp_player_statistics/test/module.test.js similarity index 100% rename from exp_statistics/test/module.test.js rename to exp_player_statistics/test/module.test.js diff --git a/exp_statistics/test/module/control.lua b/exp_player_statistics/test/module/control.lua similarity index 93% rename from exp_statistics/test/module/control.lua rename to exp_player_statistics/test/module/control.lua index a2fec8f2..e874abbe 100644 --- a/exp_statistics/test/module/control.lua +++ b/exp_player_statistics/test/module/control.lua @@ -1,4 +1,4 @@ -local Suite = ... --- @type Suite +local Suite = ... --- @type Suite 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") diff --git a/exp_statistics/test/module/env.lua b/exp_player_statistics/test/module/env.lua similarity index 82% rename from exp_statistics/test/module/env.lua rename to exp_player_statistics/test/module/env.lua index 670ed69b..03d8e104 100644 --- a/exp_statistics/test/module/env.lua +++ b/exp_player_statistics/test/module/env.lua @@ -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, on_nth_tick: table } 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) diff --git a/exp_statistics/test/module/events.lua b/exp_player_statistics/test/module/events.lua similarity index 96% rename from exp_statistics/test/module/events.lua rename to exp_player_statistics/test/module/events.lua index 97b0ddd9..9638e852 100644 --- a/exp_statistics/test/module/events.lua +++ b/exp_player_statistics/test/module/events.lua @@ -1,4 +1,4 @@ -local Suite = ... --- @type Suite +local Suite = ... --- @type Suite 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) diff --git a/exp_statistics/tsconfig.browser.json b/exp_player_statistics/tsconfig.browser.json similarity index 100% rename from exp_statistics/tsconfig.browser.json rename to exp_player_statistics/tsconfig.browser.json diff --git a/exp_statistics/tsconfig.json b/exp_player_statistics/tsconfig.json similarity index 100% rename from exp_statistics/tsconfig.json rename to exp_player_statistics/tsconfig.json diff --git a/exp_statistics/tsconfig.node.json b/exp_player_statistics/tsconfig.node.json similarity index 100% rename from exp_statistics/tsconfig.node.json rename to exp_player_statistics/tsconfig.node.json diff --git a/exp_player_statistics/web/components/StatisticsPage.tsx b/exp_player_statistics/web/components/StatisticsPage.tsx new file mode 100644 index 00000000..9bcb2532 --- /dev/null +++ b/exp_player_statistics/web/components/StatisticsPage.tsx @@ -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(loadColumns); + const tableState = useTableQueryState({ + namespace: "statistics", + defaultSortKey: "Playtime", + defaultSortOrder: "descend", + pagination: { defaultPageSize: 50 }, + }); + const playerSearch = useColumnSearch(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 = [ + { + 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[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 =