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 =