From d1b22dd4e7c2e9f9917fcb8d18b0eef5444e7427 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:49:30 +0000 Subject: [PATCH] Add exp_statistics plugin for player statistics Player statistics move out of exp_legacy into their own plugin, with the totals kept on the controller instead of the PlayerData datastore, which was never synced anywhere. The lua module tracks the same statistics with the same rules. Gains are kept per player as unsent in storage and sent over IPC every 5 minutes for every player with changes, and for one player when they leave. Sent values move into the totals the controller gave when the player joined, so get() is the total on every server either way. Nothing is resent. The instance plugin sends everything left when the server stops and drops anything unsent when it starts. A leftover can only come from a save made before it was sent, such as the autosave loaded after a crash, so dropping it loses a few minutes at worst but never counts twice. The controller adds the deltas to a record per player name in database/exp_statistics/players.json. Instances fetch a player's totals on join and hand them to lua over rcon. The web UI gets a sortable table of every player at /exp_statistics, behind exp_statistics.view. Co-Authored-By: Claude Opus 5.5 --- exp_statistics/controller.ts | 60 ++++++ exp_statistics/index.ts | 38 ++++ exp_statistics/instance.ts | 59 ++++++ exp_statistics/messages.ts | 152 ++++++++++++++ exp_statistics/module/config.lua | 50 +++++ exp_statistics/module/control.lua | 161 +++++++++++++++ exp_statistics/module/events.lua | 188 ++++++++++++++++++ exp_statistics/module/globals.lua | 4 + exp_statistics/module/locale/en.cfg | 67 +++++++ exp_statistics/module/locale/zh-CN.cfg | 67 +++++++ exp_statistics/module/locale/zh-TW.cfg | 67 +++++++ exp_statistics/module/module.json | 13 ++ exp_statistics/module/module_exports.lua | 2 + exp_statistics/package.json | 61 ++++++ exp_statistics/test/controller.test.js | 145 ++++++++++++++ exp_statistics/test/instance.test.js | 132 ++++++++++++ exp_statistics/test/module.test.js | 13 ++ exp_statistics/test/module/control.lua | 120 +++++++++++ exp_statistics/test/module/env.lua | 101 ++++++++++ exp_statistics/test/module/events.lua | 133 +++++++++++++ exp_statistics/tsconfig.browser.json | 4 + exp_statistics/tsconfig.json | 7 + exp_statistics/tsconfig.node.json | 5 + .../web/components/StatisticsPage.tsx | 95 +++++++++ exp_statistics/web/index.tsx | 27 +++ exp_statistics/webpack.config.cjs | 34 ++++ tsconfig.json | 1 + 27 files changed, 1806 insertions(+) create mode 100644 exp_statistics/controller.ts create mode 100644 exp_statistics/index.ts create mode 100644 exp_statistics/instance.ts create mode 100644 exp_statistics/messages.ts create mode 100644 exp_statistics/module/config.lua create mode 100644 exp_statistics/module/control.lua create mode 100644 exp_statistics/module/events.lua create mode 100644 exp_statistics/module/globals.lua create mode 100644 exp_statistics/module/locale/en.cfg create mode 100644 exp_statistics/module/locale/zh-CN.cfg create mode 100644 exp_statistics/module/locale/zh-TW.cfg create mode 100644 exp_statistics/module/module.json create mode 100644 exp_statistics/module/module_exports.lua create mode 100644 exp_statistics/package.json create mode 100644 exp_statistics/test/controller.test.js create mode 100644 exp_statistics/test/instance.test.js create mode 100644 exp_statistics/test/module.test.js create mode 100644 exp_statistics/test/module/control.lua create mode 100644 exp_statistics/test/module/env.lua create mode 100644 exp_statistics/test/module/events.lua create mode 100644 exp_statistics/tsconfig.browser.json create mode 100644 exp_statistics/tsconfig.json create mode 100644 exp_statistics/tsconfig.node.json create mode 100644 exp_statistics/web/components/StatisticsPage.tsx create mode 100644 exp_statistics/web/index.tsx create mode 100644 exp_statistics/webpack.config.cjs diff --git a/exp_statistics/controller.ts b/exp_statistics/controller.ts new file mode 100644 index 00000000..99a4e83c --- /dev/null +++ b/exp_statistics/controller.ts @@ -0,0 +1,60 @@ +import type { ControllerPluginContext } from "@clusterio/controller"; +import * as lib from "@clusterio/lib"; +import * as messages from "./messages.js"; +import * as path from "node:path"; + +/** Keeps the statistics totals of every player, instances send what was gained since their last update. */ +export default async function (context: ControllerPluginContext) { + const { controller, plugin } = context; + const databaseDirectory = controller.config.get("controller.database_directory"); + const players = new lib.SubscribableDatastore( + ...await new lib.JsonIdDatastoreProvider( + path.join(databaseDirectory, "exp_statistics", "players.json"), + messages.PlayerStatistics.fromJSON.bind(messages.PlayerStatistics), + ).bootstrap() + ); + + function addDeltas(deltas: messages.StatisticsDelta[]) { + const changed = new Map(); + for (const delta of deltas) { + const record = changed.get(delta.name) + ?? players.getMutable(delta.name) + ?? new messages.PlayerStatistics(delta.name); + for (const [name, value] of Object.entries(delta.stats)) { + if (Number.isFinite(value) && value !== 0) { + record.stats[name] = (record.stats[name] ?? 0) + value; + } + } + changed.set(record.id, record); + } + if (changed.size) { + players.setMany([...changed.values()]); + } + } + + players.on("update", updates => { + controller.subscriptions.broadcast(new messages.PlayerStatisticsUpdatedEvent(updates)); + }); + + controller.subscriptions.handle(messages.PlayerStatisticsUpdatedEvent, async request => { + const updates = [...players.values()].filter(player => player.updatedAtMs > request.lastRequestTimeMs); + return updates.length ? new messages.PlayerStatisticsUpdatedEvent(updates) : null; + }); + + controller.handle(messages.StatisticsUpdateEvent, async (event: messages.StatisticsUpdateEvent) => { + addDeltas(event.deltas); + }); + + controller.handle(messages.StatisticsGetRequest, async (request: messages.StatisticsGetRequest) => ( + players.get(request.name) ?? new messages.PlayerStatistics(request.name) + )); + + controller.handle(messages.StatisticsListRequest, async () => [...players.values()]); + + controller.hooks.save.attach(plugin.name, async () => { + await players.save(); + }); + controller.hooks.shutdown.attach(plugin.name, async () => { + await players.save(); + }); +} diff --git a/exp_statistics/index.ts b/exp_statistics/index.ts new file mode 100644 index 00000000..6b9ec3eb --- /dev/null +++ b/exp_statistics/index.ts @@ -0,0 +1,38 @@ +import * as lib from "@clusterio/lib"; +import * as messages from "./messages.js"; + +declare module "@clusterio/lib" { + export interface Permissions { + "exp_statistics.view": never; + } +} + +export const plugin: lib.PluginDeclaration = { + name: "exp_statistics", + title: "ExpGaming - Statistics", + description: "Clusterio plugin keeping player statistics on the controller", + + controllerEntrypoint: "./dist/node/controller.js", + instanceEntrypoint: "./dist/node/instance.js", + + messages: [ + messages.StatisticsUpdateEvent, + messages.PlayerStatisticsUpdatedEvent, + messages.StatisticsGetRequest, + messages.StatisticsListRequest, + ], + + permissions: [ + { + name: "exp_statistics.view", + title: "View player statistics", + description: "View the statistics of every player and subscribe to their updates", + grantByDefault: true, + }, + ], + + webEntrypoint: "./web", + routes: [ + "/exp_statistics", + ], +}; diff --git a/exp_statistics/instance.ts b/exp_statistics/instance.ts new file mode 100644 index 00000000..bd285bb8 --- /dev/null +++ b/exp_statistics/instance.ts @@ -0,0 +1,59 @@ +import type { InstancePluginContext } from "@clusterio/host"; +import * as messages from "./messages.js"; + +/** Sent by the lua side with what each player gained since the last update. */ +export type IpcStatisticsUpdate = { + players: { name: string, stats: Record }[], +}; + +/** + * Forwards the statistics gained in game to the controller, and hands the + * totals of a player to lua when they join, see module/control.lua. + */ +export default async function (context: InstancePluginContext) { + const { instance, plugin, logger } = context; + + async function luaCall(code: string) { + await instance.sendRcon(`/sc ${code}`, true); + } + + async function luaSendTotals(record: messages.PlayerStatistics) { + const json = JSON.stringify({ name: record.id, stats: record.stats }); + // Pick a long bracket the json does not contain + let level = "="; + while (json.includes(`]${level}]`)) { + level += "="; + } + await luaCall(`exp_statistics.receive_totals(helpers.json_to_table[${level}[${json}]${level}])`); + } + + instance.server.handle("exp_statistics:update", async (event: IpcStatisticsUpdate) => { + instance.sendTo("controller", new messages.StatisticsUpdateEvent( + event.players.map(player => new messages.StatisticsDelta(player.name, player.stats)), + )); + }); + + instance.hooks.playerEvent.attach(plugin.name, async event => { + if (event.type !== "join") { + return; + } + try { + const record = await instance.sendTo("controller", new messages.StatisticsGetRequest(event.name)); + if (instance.status === "running") { + await luaSendTotals(record); + } + } catch (err: any) { + logger.warn(`Failed to send the statistics of ${event.name}: ${err.message}`); + } + }); + + // 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()"); + }); + + instance.hooks.stop.attach(plugin.name, async () => { + await luaCall("exp_statistics.send_all()"); + }); +} diff --git a/exp_statistics/messages.ts b/exp_statistics/messages.ts new file mode 100644 index 00000000..b7be099f --- /dev/null +++ b/exp_statistics/messages.ts @@ -0,0 +1,152 @@ +import * as lib from "@clusterio/lib"; +import { Type, Static } from "@sinclair/typebox"; + +const StatsSchema = Type.Record(Type.String(), Type.Number()); + +/** Totals for one player across every instance, keyed by player name. */ +export class PlayerStatistics { + constructor( + public id: string, + /** Statistic name to total, missing statistics are zero */ + public stats: Record = {}, + public updatedAtMs: number = 0, + public isDeleted: boolean = false, + ) {} + + static jsonSchema = Type.Object({ + id: Type.String(), + stats: StatsSchema, + updated_at_ms: Type.Optional(Type.Number()), + is_deleted: Type.Optional(Type.Boolean()), + }); + + toJSON() { + const json: Static = { + id: this.id, + stats: this.stats, + }; + + if (this.updatedAtMs) { + json.updated_at_ms = this.updatedAtMs; + } + + if (this.isDeleted) { + json.is_deleted = true; + } + + return json; + } + + static fromJSON(json: Static) { + return new this(json.id, { ...json.stats }, json.updated_at_ms ?? 0, json.is_deleted ?? false); + } +} + +/** Statistics gained by one player since the last update */ +export class StatisticsDelta { + constructor( + public name: string, + public stats: Record, + ) {} + + static jsonSchema = Type.Object({ + name: Type.String(), + stats: StatsSchema, + }); + + toJSON() { + return { name: this.name, stats: this.stats }; + } + + static fromJSON(json: Static) { + return new this(json.name, json.stats); + } +} + +/** 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 type = "event" as const; + static src = "instance" as const; + static dst = "controller" as const; + static permission = null; + + constructor( + public deltas: StatisticsDelta[], + ) {} + + static jsonSchema = Type.Object({ + deltas: Type.Array(StatisticsDelta.jsonSchema), + }); + + toJSON() { + return { deltas: this.deltas.map(delta => delta.toJSON()) }; + } + + static fromJSON(json: Static) { + return new this(json.deltas.map(delta => StatisticsDelta.fromJSON(delta))); + } +} + +export class PlayerStatisticsUpdatedEvent { + declare ["constructor"]: typeof PlayerStatisticsUpdatedEvent; + static plugin = "exp_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; + + constructor( + public updates: PlayerStatistics[], + ) {} + + static jsonSchema = Type.Object({ + updates: Type.Array(PlayerStatistics.jsonSchema), + }); + + toJSON() { + return { updates: this.updates.map(player => player.toJSON()) }; + } + + static fromJSON(json: Static) { + return new this(json.updates.map(player => PlayerStatistics.fromJSON(player))); + } +} + +/** Totals for one player, empty when they have none. */ +export class StatisticsGetRequest { + declare ["constructor"]: typeof StatisticsGetRequest; + static plugin = "exp_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 Response = PlayerStatistics; + + constructor( + public name: string, + ) {} + + static jsonSchema = Type.Object({ + name: Type.String(), + }); + + toJSON() { + return { name: this.name }; + } + + static fromJSON(json: Static) { + return new this(json.name); + } +} + +export class StatisticsListRequest { + declare ["constructor"]: typeof StatisticsListRequest; + static plugin = "exp_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 Response = lib.jsonArray(PlayerStatistics); +} diff --git a/exp_statistics/module/config.lua b/exp_statistics/module/config.lua new file mode 100644 index 00000000..381a73e2 --- /dev/null +++ b/exp_statistics/module/config.lua @@ -0,0 +1,50 @@ +--[[-- ExpStatistics - Config +Which statistics are tracked, and the order they are shown in. +]] + +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 = { + MachinesBuilt = e.on_built_entity, + MapTagsMade = e.on_chart_tag_added, + ChatMessages = e.on_console_chat, + CommandsUsed = e.on_console_command, + ItemsPickedUp = e.on_picked_up_item, + TilesBuilt = e.on_player_built_tile, + 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 + display_order = { + "Playtime", "AfkTime", + "MapsPlayed", "JoinCount", + "ChatMessages", "CommandsUsed", + "RocketsLaunched", "ResearchCompleted", + "MachinesBuilt", "MachinesRemoved", + "TilesBuilt", "TilesRemoved", + "TreesDestroyed", "OreMined", + "ItemsCrafted", "ItemsPickedUp", + "Kills", "Deaths", + "DamageDealt", "DistanceTravelled", + "CapsulesUsed", "EntityRepaired", + "DeconstructionPlannerUsed", "MapTagsMade", + }, +} diff --git a/exp_statistics/module/control.lua b/exp_statistics/module/control.lua new file mode 100644 index 00000000..6d3b000d --- /dev/null +++ b/exp_statistics/module/control.lua @@ -0,0 +1,161 @@ +--[[-- ExpStatistics +Player statistics kept on the controller. + +What each player gains is kept here as unsent until it is sent to the +controller, every 5 minutes and when the player leaves. Once sent it is added +to the totals the controller gave when the player joined, so get() is the +total across every server either way. + +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") + +ExpStatistics.increment(player, "Kills") +ExpStatistics.get(player, "Kills") +for _, name in ipairs(ExpStatistics.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 format_number = require("util").format_number + +--- @alias ExpStatistics.Stats table + +--- @class ExpStatistics.Totals +--- @field name string +--- @field stats ExpStatistics.Stats + +--- @class ExpStatistics +local ExpStatistics = { + config = config, + --- Enabled statistics in the order they are shown + --- @type string[] + display_order = {}, + --- Shown after the value + --- @type table + units = { DistanceTravelled = " tiles" }, +} + +--- Measured in minutes and shown as a time +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 +Storage.register({ unsent, totals }, function(tbl) + unsent = tbl[1] + totals = tbl[2] +end) + +--- Add to a statistic of a player +--- @param player LuaPlayer +--- @param statistic string +--- @param amount number? Defaults to 1 +function ExpStatistics.increment(player, statistic, amount) + local name = player.name + local stats = unsent[name] + if not stats then + stats = {} + unsent[name] = stats + end + stats[statistic] = (stats[statistic] or 0) + (amount or 1) +end + +--- Get the total of a statistic for a player, on every server +--- @param player LuaPlayer | string +--- @param statistic string +--- @return number +function ExpStatistics.get(player, statistic) + local name = type(player) == "string" and player or player.name + local received = totals[name] + local pending = unsent[name] + return (received and received[statistic] or 0) + (pending and pending[statistic] or 0) +end + +--- Get the value of a statistic as it is shown to players +--- @param statistic string +--- @param value number +--- @param short boolean? Use the short time format +--- @return LocalisedString +function ExpStatistics.format(statistic, value, short) + if time_statistics[statistic] then + return short and short_time_format(value) or long_time_format(value) + end + return format_number(value, false) +end + +--- Move the unsent statistics of a player into their totals +--- @param name string +--- @return ExpStatistics.Stats? +local function take_unsent(name) + local stats = unsent[name] + if not stats then return nil end + unsent[name] = nil + + local received = totals[name] + if not received then + received = {} + totals[name] = received + end + for statistic, value in pairs(stats) do + received[statistic] = (received[statistic] or 0) + value + end + return stats +end + +--- Send what a player gained to the controller +--- @param name string +function ExpStatistics.send(name) + local stats = take_unsent(name) + if stats then + clusterio_api.send_json("exp_statistics:update", { players = { { name = name, stats = stats } } }) + end +end + +--- Send what every player gained to the controller, in one update +function ExpStatistics.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 }) + 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) + totals[name] = nil +end + +--- Called by the instance plugin over rcon when the server starts +function ExpStatistics.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) + 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 diff --git a/exp_statistics/module/events.lua b/exp_statistics/module/events.lua new file mode 100644 index 00000000..6262d5ad --- /dev/null +++ b/exp_statistics/module/events.lua @@ -0,0 +1,188 @@ +--[[-- 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 +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 +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, +} diff --git a/exp_statistics/module/globals.lua b/exp_statistics/module/globals.lua new file mode 100644 index 00000000..b470f0bd --- /dev/null +++ b/exp_statistics/module/globals.lua @@ -0,0 +1,4 @@ +--- @diagnostic disable: global-in-non-module + +-- Access using `/sc exp_statistics.foo()` +exp_statistics = require("modules/exp_statistics/control") diff --git a/exp_statistics/module/locale/en.cfg b/exp_statistics/module/locale/en.cfg new file mode 100644 index 00000000..bf31f0b8 --- /dev/null +++ b/exp_statistics/module/locale/en.cfg @@ -0,0 +1,67 @@ +[exp-statistics] +MapsPlayed=Maps Played +MapsPlayed-tooltip=The number of unique maps you have played on +JoinCount=Join Count +JoinCount-tooltip=The number of times you have joined our servers +Playtime=Playtime +Playtime-tooltip=The amount of time you have spent on our servers +AfkTime=AFK Time +AfkTime-tooltip=The amount of time you have been AFK on our servers +ChatMessages=Messages +ChatMessages-tooltip=The number of messages you have sent in chat +CommandsUsed=Commands +CommandsUsed-tooltip=The number of commands you have used +RocketsLaunched=Rockets Launched +RocketsLaunched-tooltip=The number of rockets launched while you were online +ResearchCompleted=Research Completed +ResearchCompleted-tooltip=The number of research projects completed while you were online +MachinesBuilt=Machines Built +MachinesBuilt-tooltip=The number of machines you have built +MachinesRemoved=Machines Removed +MachinesRemoved-tooltip=The number of machines you have removed +TilesBuilt=Tiles Placed +TilesBuilt-tooltip=The number of tiles you have placed +TilesRemoved=Tiles Removed +TilesRemoved-tooltip=The number of tiles you have removed +TreesDestroyed=Trees Destroyed +TreesDestroyed-tooltip=The number of trees you have destroyed +OreMined=Ore Mined +OreMined-tooltip=The amount of ore you have mined +ItemsCrafted=Items Crafted +ItemsCrafted-tooltip=The number of items you have crafted +ItemsPickedUp=Items Picked Up +ItemsPickedUp-tooltip=The number of items you have picked up +Kills=Kills +Kills-tooltip=The number of biters and biter bases you have squished +Deaths=Deaths +Deaths-tooltip=The number of times you have died +DamageDealt=Damage Dealt +DamageDealt-tooltip=The amount of damage you have dealt to other forces +DistanceTravelled=Distance Travelled +DistanceTravelled-tooltip=The total distance in tiles that you have travelled +CapsulesUsed=Capsules Used +CapsulesUsed-tooltip=The number of capsules you have used +EntityRepaired=Machines Repaired +EntityRepaired-tooltip=The number of machines which you have repaired +DeconstructionPlannerUsed=Decon Planner Used +DeconstructionPlannerUsed-tooltip=The number of times you have used the deconstruction planner +MapTagsMade=Map Tags Created +MapTagsMade-tooltip=The number of map tags you have created +DamageDeathRatio=Damage Death Ratio +DamageDeathRatio-tooltip=Damage over Death +KillDeathRatio=Kill Death Ratio +KillDeathRatio-tooltip=Kill over Death +SessionTime=Session Time +SessionTime-tooltip=Session Time +BuildRatio=Build Ratio +BuildRatio-tooltip=Build over Remove +RocketPerHour=Rocket Per Hour +RocketPerHour-tooltip=Rocket Launched Over Play Time in Hour +TreeKillPerMinute=Tree Kill Per Minute +TreeKillPerMinute-tooltip=Tree Destroyed Per Minute +NetPlayTime=Net Play Time +NetPlayTime-tooltip=Net Play Time +AFKTimeRatio=AFK Time Ratio +AFKTimeRatio-tooltip=AFK Time over Play Time +Locale=Locale +Locale-tooltip=The language of player interface diff --git a/exp_statistics/module/locale/zh-CN.cfg b/exp_statistics/module/locale/zh-CN.cfg new file mode 100644 index 00000000..4b021ef8 --- /dev/null +++ b/exp_statistics/module/locale/zh-CN.cfg @@ -0,0 +1,67 @@ +[exp-statistics] +MapsPlayed=地圖遊玩次數 +MapsPlayed-tooltip=你在本服務器遊玩過的地圖數 +JoinCount=登入次數 +JoinCount-tooltip=你在本服務器遊玩的次數 +Playtime=遊玩時間 +Playtime-tooltip=你在本服務器遊玩的時間 +AfkTime=掛機時間 +AfkTime-tooltip=你在本服務器掛機的時間 +ChatMessages=傳送信息次數 +ChatMessages-tooltip=你在本服務器發送信息的次數 +CommandsUsed=使用指令次數 +CommandsUsed-tooltip=你在本服務器使用指令的次數 +RocketsLaunched=火箭發射次數 +RocketsLaunched-tooltip=你在本服務器在線時所發射過火箭的次數 +ResearchCompleted=研究次數 +ResearchCompleted-tooltip=你在本服務器在線時完成研究的次數 +MachinesBuilt=機器建造次數 +MachinesBuilt-tooltip=你在本服務器建造過機器的次數 +MachinesRemoved=機器拆除次數 +MachinesRemoved-tooltip=你在本服務器拆除過機器的次數 +TilesBuilt=地磚建造次數 +TilesBuilt-tooltip=你在本服務器建造過地磚的次數 +TilesRemoved=地磚拆除次數 +TilesRemoved-tooltip=你在本服務器拆除過地磚的次數 +TreesDestroyed=樹木拆除次數 +TreesDestroyed-tooltip=你在本服務器拆除過樹木的次數 +OreMined=挖礦次數 +OreMined-tooltip=你在本服務器挖掘礦的次數 +ItemsCrafted=物品合成數 +ItemsCrafted-tooltip=你在本服務器合成過物品的次數 +ItemsPickedUp=物品拾起數 +ItemsPickedUp-tooltip=你在本服務器起過物品的次數 +Kills=撃殺數 +Kills-tooltip=你在本服務器殺過的敵人數量 +Deaths=死亡次數 +Deaths-tooltip=你在本服務器死過的數量 +DamageDealt=傷害量 +DamageDealt-tooltip=你在本伺服器對敵人所造成的傷害量 +DistanceTravelled=移動距離 +DistanceTravelled-tooltip=你在本服務器的總移動距離 +CapsulesUsed=膠囊使用次數 +CapsulesUsed-tooltip=你在本服務器使用過膠囊的次數 +EntityRepaired=機器維修次數 +EntityRepaired-tooltip=你在本服務器維修過機器的次數 +DeconstructionPlannerUsed=拆除規劃器使用次數 +DeconstructionPlannerUsed-tooltip=你在本服務器使用過拆除規劃器的次數 +MapTagsMade=地圖標籤數量 +MapTagsMade-tooltip=你在本服務器放過的地圖標籤數量 +DamageDeathRatio=傷害死亡比 +DamageDeathRatio-tooltip=傷害除以死亡 +KillDeathRatio=撃殺死亡比 +KillDeathRatio-tooltip=撃殺除以死亡 +SessionTime=平均遊玩時間 +SessionTime-tooltip=平均遊玩時間 +BuildRatio=建築比 +BuildRatio-tooltip=建築次數除以拆除次數 +RocketPerHour=每小時火箭發射次數 +RocketPerHour-tooltip=火箭發射次數除以遊玩時間 +TreeKillPerMinute=每分鐘樹木拆除數 +TreeKillPerMinute-tooltip=樹木拆除數除以遊玩時間 +NetPlayTime=淨遊玩時間 +NetPlayTime-tooltip=遊玩時間減掛機時間 +AFKTimeRatio=掛機時間比 +AFKTimeRatio-tooltip=掛機時間除以遊玩時間 +Locale=語言 +Locale-tooltip=用戶介面語言 diff --git a/exp_statistics/module/locale/zh-TW.cfg b/exp_statistics/module/locale/zh-TW.cfg new file mode 100644 index 00000000..4b021ef8 --- /dev/null +++ b/exp_statistics/module/locale/zh-TW.cfg @@ -0,0 +1,67 @@ +[exp-statistics] +MapsPlayed=地圖遊玩次數 +MapsPlayed-tooltip=你在本服務器遊玩過的地圖數 +JoinCount=登入次數 +JoinCount-tooltip=你在本服務器遊玩的次數 +Playtime=遊玩時間 +Playtime-tooltip=你在本服務器遊玩的時間 +AfkTime=掛機時間 +AfkTime-tooltip=你在本服務器掛機的時間 +ChatMessages=傳送信息次數 +ChatMessages-tooltip=你在本服務器發送信息的次數 +CommandsUsed=使用指令次數 +CommandsUsed-tooltip=你在本服務器使用指令的次數 +RocketsLaunched=火箭發射次數 +RocketsLaunched-tooltip=你在本服務器在線時所發射過火箭的次數 +ResearchCompleted=研究次數 +ResearchCompleted-tooltip=你在本服務器在線時完成研究的次數 +MachinesBuilt=機器建造次數 +MachinesBuilt-tooltip=你在本服務器建造過機器的次數 +MachinesRemoved=機器拆除次數 +MachinesRemoved-tooltip=你在本服務器拆除過機器的次數 +TilesBuilt=地磚建造次數 +TilesBuilt-tooltip=你在本服務器建造過地磚的次數 +TilesRemoved=地磚拆除次數 +TilesRemoved-tooltip=你在本服務器拆除過地磚的次數 +TreesDestroyed=樹木拆除次數 +TreesDestroyed-tooltip=你在本服務器拆除過樹木的次數 +OreMined=挖礦次數 +OreMined-tooltip=你在本服務器挖掘礦的次數 +ItemsCrafted=物品合成數 +ItemsCrafted-tooltip=你在本服務器合成過物品的次數 +ItemsPickedUp=物品拾起數 +ItemsPickedUp-tooltip=你在本服務器起過物品的次數 +Kills=撃殺數 +Kills-tooltip=你在本服務器殺過的敵人數量 +Deaths=死亡次數 +Deaths-tooltip=你在本服務器死過的數量 +DamageDealt=傷害量 +DamageDealt-tooltip=你在本伺服器對敵人所造成的傷害量 +DistanceTravelled=移動距離 +DistanceTravelled-tooltip=你在本服務器的總移動距離 +CapsulesUsed=膠囊使用次數 +CapsulesUsed-tooltip=你在本服務器使用過膠囊的次數 +EntityRepaired=機器維修次數 +EntityRepaired-tooltip=你在本服務器維修過機器的次數 +DeconstructionPlannerUsed=拆除規劃器使用次數 +DeconstructionPlannerUsed-tooltip=你在本服務器使用過拆除規劃器的次數 +MapTagsMade=地圖標籤數量 +MapTagsMade-tooltip=你在本服務器放過的地圖標籤數量 +DamageDeathRatio=傷害死亡比 +DamageDeathRatio-tooltip=傷害除以死亡 +KillDeathRatio=撃殺死亡比 +KillDeathRatio-tooltip=撃殺除以死亡 +SessionTime=平均遊玩時間 +SessionTime-tooltip=平均遊玩時間 +BuildRatio=建築比 +BuildRatio-tooltip=建築次數除以拆除次數 +RocketPerHour=每小時火箭發射次數 +RocketPerHour-tooltip=火箭發射次數除以遊玩時間 +TreeKillPerMinute=每分鐘樹木拆除數 +TreeKillPerMinute-tooltip=樹木拆除數除以遊玩時間 +NetPlayTime=淨遊玩時間 +NetPlayTime-tooltip=遊玩時間減掛機時間 +AFKTimeRatio=掛機時間比 +AFKTimeRatio-tooltip=掛機時間除以遊玩時間 +Locale=語言 +Locale-tooltip=用戶介面語言 diff --git a/exp_statistics/module/module.json b/exp_statistics/module/module.json new file mode 100644 index 00000000..be0eb2e2 --- /dev/null +++ b/exp_statistics/module/module.json @@ -0,0 +1,13 @@ +{ + "name": "exp_statistics", + "load": [ + "events.lua" + ], + "require": [ + "globals.lua" + ], + "dependencies": { + "clusterio": "*", + "exp_util": "*" + } +} diff --git a/exp_statistics/module/module_exports.lua b/exp_statistics/module/module_exports.lua new file mode 100644 index 00000000..f5ab723b --- /dev/null +++ b/exp_statistics/module/module_exports.lua @@ -0,0 +1,2 @@ +-- Access the exports from other modules using require("modules/exp_statistics") +return require("modules/exp_statistics/control") diff --git a/exp_statistics/package.json b/exp_statistics/package.json new file mode 100644 index 00000000..988ff0fa --- /dev/null +++ b/exp_statistics/package.json @@ -0,0 +1,61 @@ +{ + "name": "@expcluster/statistics", + "version": "7.0.1", + "description": "Clusterio plugin keeping player statistics on the controller", + "author": "Cooldude2606 ", + "license": "MIT", + "repository": "explosivegaming/ExpCluster", + "type": "module", + "main": "dist/node/index.js", + "scripts": { + "prepare": "tsc --build && webpack-cli --env production", + "test": "tap --type-strip-only --disable-coverage --allow-empty-coverage test/*.test.js", + "coverage": "tap --coverage-report=text test/*.test.js" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", + "@clusterio/lib": "workspace:^" + }, + "peerDependenciesMeta": { + "@clusterio/controller": { + "optional": true + }, + "@clusterio/host": { + "optional": true + } + }, + "devDependencies": { + "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", + "@clusterio/lib": "workspace:^", + "@clusterio/web_ui": "workspace:^", + "@types/node": "catalog:", + "@types/react": "catalog:", + "antd": "catalog:", + "fengari": "^0.1.5", + "react": "catalog:", + "react-dom": "catalog:", + "react-router-dom": "catalog:", + "tap": "^21.1.0", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" + }, + "dependencies": { + "@sinclair/typebox": "catalog:", + "@expcluster/lib_util": "workspace:^" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "clusterio", + "clusterio-plugin", + "factorio" + ] +} diff --git a/exp_statistics/test/controller.test.js b/exp_statistics/test/controller.test.js new file mode 100644 index 00000000..e077fa38 --- /dev/null +++ b/exp_statistics/test/controller.test.js @@ -0,0 +1,145 @@ +import t from "tap"; +import * as lib from "@clusterio/lib"; +import { Controller } from "@clusterio/controller"; +import entrypoint from "../dist/node/controller.js"; +import * as messages from "../dist/node/messages.js"; +import { plugin as pluginDeclaration } from "../dist/node/index.js"; + +// The controller validates message classes against the link registry +for (const Message of pluginDeclaration.messages) { + lib.Link.register(Message); +} + +const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} }; + +// Silence the singleton logger to avoid polluting the test output +lib.logger.silent = true; +t.after(() => { + lib.logger.silent = false; +}); + +const fromInstance = lib.Address.fromShorthand({ instanceId: 1 }); + +/** Run the plugin on a real controller, which is side effect free while not started, returns its handlers. */ +async function startPlugin(t2, databaseDirectory = t2.testdir()) { + const controllerConfig = new lib.ControllerConfig("controller", { + "controller.database_directory": databaseDirectory, + }); + const controller = new Controller(logger, [], controllerConfig); + + const state = { broadcasts: [] }; + controller.subscriptions.broadcast = event => { + state.broadcasts.push(event); + }; + + const handlers = new Map(); + const handle = controller.handle.bind(controller); + controller.handle = (Class, handler) => { + handlers.set(Class, handler); + handle(Class, handler); + }; + const subscriptionHandle = controller.subscriptions.handle.bind(controller.subscriptions); + controller.subscriptions.handle = (Class, handler) => { + handlers.set(Class, handler); + subscriptionHandle(Class, handler); + }; + + await entrypoint({ plugin: { name: "exp_statistics" }, controller, logger }); + const delta = (name, stats) => new messages.StatisticsDelta(name, stats); + return { + controller, + state, + update: (...deltas) => handlers.get(messages.StatisticsUpdateEvent)( + new messages.StatisticsUpdateEvent(deltas.map(([name, stats]) => delta(name, stats))), fromInstance, + ), + get: name => handlers.get(messages.StatisticsGetRequest)(new messages.StatisticsGetRequest(name), fromInstance), + list: () => handlers.get(messages.StatisticsListRequest)(new messages.StatisticsListRequest(), fromInstance), + subscription: lastRequestTimeMs => handlers.get(messages.PlayerStatisticsUpdatedEvent)({ lastRequestTimeMs }), + }; +} + +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}`); + } +}); + +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 }]); + 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 }); + }); + + t2.test("StatisticsUpdateEvent merges deltas for the same player in one update", async t3 => { + const { update, get, state } = await startPlugin(t3); + await update(["alice", { Kills: 1 }], ["alice", { Kills: 2 }]); + + t3.strictSame((await get("alice")).stats, { 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); + await update(["alice", { Kills: 1, Deaths: 0, Bad: Infinity, Worse: NaN }]); + t3.strictSame((await get("alice")).stats, { 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(await list(), [], "nothing is stored"); + }); + + t2.test("StatisticsListRequest lists every player", async t3 => { + const { update, list } = await startPlugin(t3); + await update(["alice", { Kills: 1 }], ["bob", { Kills: 2 }]); + t3.strictSame((await list()).map(player => player.id).sort(), ["alice", "bob"]); + }); + + t2.test("subscribing replays only newer records", async t3 => { + const { update, subscription } = await startPlugin(t3); + await update(["alice", { Kills: 1 }]); + const all = await subscription(0); + t3.strictSame(all.updates.map(player => player.id), ["alice"]); + t3.equal(await subscription(all.updates[0].updatedAtMs), null, "nothing newer"); + }); + + t2.test("totals are saved and loaded", async t3 => { + const directory = t3.testdir(); + const first = await startPlugin(t3, directory); + await first.update(["alice", { Kills: 4 }]); + await first.controller.hooks.save.invoke(); + + const second = await startPlugin(t3, directory); + t3.strictSame((await second.get("alice")).stats, { Kills: 4 }); + }); + + t2.end(); +}); + +t.test("permissions", async t2 => { + const names = pluginDeclaration.permissions.map(permission => permission.name); + t2.strictSame(names, ["exp_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(messages.StatisticsUpdateEvent.permission, null, "updates only come from instances"); + t2.strictSame(messages.StatisticsUpdateEvent.src, "instance"); +}); + +t.test("messages round trip through json", async t2 => { + const record = new messages.PlayerStatistics("alice", { Kills: 1 }, 10); + t2.strictSame(messages.PlayerStatistics.fromJSON(JSON.parse(JSON.stringify(record))), record); + const event = new messages.StatisticsUpdateEvent([new messages.StatisticsDelta("bob", { Deaths: 2 })]); + t2.strictSame(messages.StatisticsUpdateEvent.fromJSON(JSON.parse(JSON.stringify(event))), event); +}); diff --git a/exp_statistics/test/instance.test.js b/exp_statistics/test/instance.test.js new file mode 100644 index 00000000..a9bc86e6 --- /dev/null +++ b/exp_statistics/test/instance.test.js @@ -0,0 +1,132 @@ +import t from "tap"; +import * as lib from "@clusterio/lib"; +import { Instance } from "@clusterio/host"; +import entrypoint from "../dist/node/instance.js"; +import { plugin as pluginDeclaration } from "../dist/node/index.js"; +import * as messages from "../dist/node/messages.js"; + +// The instance validates message classes against the link registry +for (const Message of pluginDeclaration.messages) { + lib.Link.register(Message); +} + +const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} }; + +class TestConnector extends lib.BaseConnector { + constructor() { + super(lib.Address.fromShorthand({ instanceId: 1 }), lib.Address.fromShorthand({ hostId: 1 })); + this.valid = true; + this.connected = true; + this.hasSession = true; + } + + send() {} +} + +/** Run the plugin on a real instance with spies on what leaves it, returns the IPC handlers it registered. */ +async function startPlugin(t2, { status = "running", stats = { Kills: 3 } } = {}) { + const instanceConfig = new lib.InstanceConfig("host"); + instanceConfig.set("instance.id", 1); + instanceConfig.set("instance.name", "test"); + + const instance = new Instance( + { assignGamePort: () => 1 }, new TestConnector(), t2.testdir(), "factorioDir", instanceConfig + ); + + const state = { sent: [], rcons: [], warnings: [] }; + const ipc = new Map(); + instance.server = { + handle: (name, handler) => ipc.set(name, handler), + }; + instance.sendRcon = async command => { + state.rcons.push(command); + return ""; + }; + instance.sendTo = async (dst, message) => { + state.sent.push(message); + if (message instanceof messages.StatisticsGetRequest) { + return new messages.PlayerStatistics(message.name, stats); + } + return undefined; + }; + + instance.notifyStatus(status); + 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) }; +} + +/** 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); + return JSON.parse(match[2]); +} + +t.test("instance plugin", t2 => { + t2.test("exp_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 } }] }); + + t3.equal(state.sent.length, 1, "one event"); + const event = state.sent[0]; + t3.ok(event instanceof messages.StatisticsUpdateEvent); + t3.strictSame(event.deltas.map(delta => [delta.name, delta.stats]), [ + ["alice", { Kills: 1 }], + ["bob", { Deaths: 2 }], + ]); + }); + + t2.test("a player joining is sent their totals", async t3 => { + const { instance, state } = await startPlugin(t3); + await instance.hooks.playerEvent.invoke({ type: "join", name: "alice" }); + + const request = state.sent[0]; + t3.ok(request instanceof messages.StatisticsGetRequest); + t3.equal(request.name, "alice"); + t3.equal(state.rcons.length, 1); + t3.strictSame(decodeTotals(state.rcons[0]), { name: "alice", stats: { Kills: 3 } }); + }); + + t2.test("the long bracket is not in the json", async t3 => { + const { instance, state } = await startPlugin(t3, { stats: { "]=]": 1 } }); + await instance.hooks.playerEvent.invoke({ type: "join", name: "alice" }); + t3.ok(state.rcons[0].includes("[==["), "a longer bracket is used"); + t3.strictSame(decodeTotals(state.rcons[0]).stats, { "]=]": 1 }); + }); + + t2.test("leaving and other player events send nothing", async t3 => { + const { instance, state } = await startPlugin(t3); + await instance.hooks.playerEvent.invoke({ type: "leave", name: "alice", reason: "quit" }); + await instance.hooks.playerEvent.invoke({ type: "promote", name: "alice" }); + t3.strictSame(state.sent, []); + t3.strictSame(state.rcons, []); + }); + + t2.test("totals are not sent once the instance is no longer running", async t3 => { + const { instance, state } = await startPlugin(t3, { status: "stopping" }); + await instance.hooks.playerEvent.invoke({ type: "join", name: "alice" }); + t3.strictSame(state.rcons, []); + }); + + t2.test("a failed request is logged", async t3 => { + const { instance, state } = await startPlugin(t3); + instance.sendTo = async () => { throw new Error("controller gone"); }; + await instance.hooks.playerEvent.invoke({ type: "join", name: "alice" }); + t3.strictSame(state.rcons, []); + t3.match(state.warnings[0], /alice: controller gone/); + }); + + t2.test("start drops what was unsent and stop sends the rest", async t3 => { + const { instance, state } = await startPlugin(t3); + await instance.hooks.start.invoke(); + await instance.hooks.stop.invoke(); + t3.strictSame(state.rcons, [ + "/sc exp_statistics.discard_unsent()", + "/sc exp_statistics.send_all()", + ]); + }); + + t2.end(); +}); diff --git a/exp_statistics/test/module.test.js b/exp_statistics/test/module.test.js new file mode 100644 index 00000000..1c81fdc1 --- /dev/null +++ b/exp_statistics/test/module.test.js @@ -0,0 +1,13 @@ +import path from "node:path"; +import t from "tap"; +import { reportLuaTests } from "../../test/lua/runner.js"; + +const envFile = path.join(import.meta.dirname, "module", "env.lua"); + +// Each file runs in its own lua state, and each test in a fresh environment +t.test("lua module", t2 => { + for (const file of ["control.lua", "events.lua"]) { + t2.test(file, t3 => reportLuaTests(t3, envFile, path.join(import.meta.dirname, "module", file))); + } + t2.end(); +}); diff --git a/exp_statistics/test/module/control.lua b/exp_statistics/test/module/control.lua new file mode 100644 index 00000000..a2fec8f2 --- /dev/null +++ b/exp_statistics/test/module/control.lua @@ -0,0 +1,120 @@ +local Suite = ... --- @type Suite + +Suite.test("increment() adds to the unsent statistics", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + Stats.increment(alice, "Kills") + Stats.increment(alice, "Kills", 4) + Suite.eq(Stats.get(alice, "Kills"), 5, "by one, then by the amount") + Suite.eq(Stats.get("alice", "Kills"), 5, "by player name") + Suite.eq(Stats.get(alice, "Deaths"), 0, "missing statistics are zero") +end) + +Suite.test("send_all() sends every player with changes in one update", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local bob = env.add_player{ name = "bob" } + env.add_player{ name = "carol" } + Stats.increment(alice, "Kills", 2) + Stats.increment(bob, "Deaths") + Stats.send_all() + + Suite.eq(#env.sent, 1, "one update") + Suite.eq(assert(env.sent[1]).channel, "exp_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, { + { name = "alice", stats = { Kills = 2 } }, + { name = "bob", stats = { Deaths = 1 } }, + }, "players without changes are left out") +end) + +Suite.test("sending clears the unsent statistics but keeps the total", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + Stats.increment(alice, "Kills", 2) + Stats.send_all() + Suite.eq(Stats.get(alice, "Kills"), 2, "moved into the totals") + + env.reset_log() + Stats.send_all() + Suite.empty(env.sent, "nothing is sent twice") + + Stats.increment(alice, "Kills") + Stats.send_all() + Suite.eq(assert(env.sent[1]).data.players, { { name = "alice", stats = { Kills = 1 } } }, "only the new delta is sent") + Suite.eq(Stats.get(alice, "Kills"), 3, "the total includes it") +end) + +Suite.test("send() sends one player", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local bob = env.add_player{ name = "bob" } + 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") + + env.reset_log() + Stats.send("carol") + Suite.empty(env.sent, "nothing is sent for a player without changes") +end) + +Suite.test("receive_totals() adds the unsent statistics to the totals", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + Stats.increment(alice, "Kills", 2) + Stats.receive_totals{ name = "alice", stats = { Kills = 10, Deaths = 1 } } + Suite.eq(Stats.get(alice, "Kills"), 12, "received plus unsent") + Suite.eq(Stats.get(alice, "Deaths"), 1, "received only") + + Stats.send_all() + Suite.eq(assert(env.sent[1]).data.players[1].stats, { Kills = 2 }, "the received totals are not sent back") + Suite.eq(Stats.get(alice, "Kills"), 12, "the total is unchanged by sending") +end) + +Suite.test("receive_totals() ignores players who are not online", function(env) + local Stats = env.Statistics + env.add_player{ name = "bob", connected = false } + Stats.receive_totals{ name = "bob", stats = { Kills = 10 } } + Stats.receive_totals{ name = "nobody", stats = { Kills = 10 } } + Suite.eq(Stats.get("bob", "Kills"), 0, "offline") + Suite.eq(Stats.get("nobody", "Kills"), 0, "never joined") +end) + +Suite.test("send_and_forget() sends then drops the totals", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + Stats.receive_totals{ name = "alice", stats = { Kills = 10 } } + Stats.increment(alice, "Kills") + Stats.send_and_forget("alice") + Suite.eq(assert(env.sent[1]).data.players[1].stats, { Kills = 1 }, "the unsent delta is sent") + Suite.eq(Stats.get(alice, "Kills"), 0, "nothing is kept") +end) + +Suite.test("discard_unsent() drops what was not sent", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + Stats.receive_totals{ name = "alice", stats = { Kills = 10 } } + Stats.increment(alice, "Kills") + Stats.discard_unsent() + Suite.eq(Stats.get(alice, "Kills"), 10, "only the totals remain") + Stats.send_all() + Suite.empty(env.sent, "nothing to send") +end) + +Suite.test("format() shows times and numbers", function(env) + local Stats = env.Statistics + Suite.eq(Stats.format("Playtime", 90), { "time-long", 90 }, "long time") + Suite.eq(Stats.format("AfkTime", 90, true), { "time-short", 90 }, "short time") + Suite.eq(Stats.format("Kills", 7), "7", "number") + Suite.eq(Stats.units.DistanceTravelled, " tiles", "distance has a unit") +end) + +Suite.test("display_order lists every enabled statistic", function(env) + local order = env.Statistics.display_order + Suite.eq(#order, 24, "all enabled by default") + Suite.eq(order[1], "Playtime", "in the configured order") +end) + +return Suite.run() diff --git a/exp_statistics/test/module/env.lua b/exp_statistics/test/module/env.lua new file mode 100644 index 00000000..670ed69b --- /dev/null +++ b/exp_statistics/test/module/env.lua @@ -0,0 +1,101 @@ +--[[-- Test environment for the statistics module +Extends the shared stubs with fresh copies of module/control.lua and +module/events.lua, stubs of the exp_util functions they use, and players with +the fields the trackers read. + +Run as a chunk by test/lua/runner.js, receiving the shared folder. The suite +returned here becomes `...` in each test file. +]] + +local shared_root = ... --- @type string +local source = assert(debug.getinfo(1, "S")).source:gsub("\\", "/") +local plugin_root = assert(source:match("^@(.*)/test/module/env%.lua$")) + +local Framework = assert(loadfile(shared_root .. "/framework.lua"))() --- @type Framework + +--- Every event the module listens to, ids start after the shared stubs +local event_names = { + "on_player_left_game", "on_player_created", "on_player_changed_position", + "on_marked_for_deconstruction", "on_player_mined_entity", "on_entity_damaged", + "on_entity_died", "on_rocket_launched", "on_research_finished", + "on_built_entity", "on_chart_tag_added", "on_console_chat", "on_console_command", + "on_picked_up_item", "on_player_built_tile", "on_player_crafted_item", + "on_player_deconstructed_area", "on_player_died", "on_player_mined_tile", + "on_player_used_capsule", "on_player_repaired_entity", +} + +--- @class ExpStatistics.TestEnv : Stubs +--- @field Statistics ExpStatistics 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 + +--- @class ExpStatistics.TestPlayerParams : Stubs.PlayerParams +--- @field afk_time number? Defaults to 0 +--- @field force string? Defaults to "player" + +--- @class ExpStatistics.TestPlayer : Stubs.Player +--- @field afk_time number +--- @field force table + +return Framework.suite(function(env) + --- @cast env ExpStatistics.TestEnv + local events = {} + for index, name in ipairs(event_names) do + events[name] = 10 + index + end + env.extend_defines{ events = events } + + env.extend_requires{ + ["modules/exp_util"] = { + format_time_factory_locale = function(opts) + return function(value) return { "time-" .. opts.format, value } end + end, + }, + ["modules/exp_util/storage"] = { + register = function() end, + }, + ["util"] = { + format_number = function(value) return tostring(value) end, + }, + } + env.extend_requires{ + ["modules/exp_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.handlers = assert(loadfile(plugin_root .. "/module/events.lua"))() + + local forces = {} + function env.force(name) + local force = forces[name] + if not force then + force = { name = name, valid = true, connected_players = {} } + forces[name] = force + end + return force + end + + local add_player = env.add_player + function env.add_player(params) + local player = add_player(params) --[[@as ExpStatistics.TestPlayer]] + local force = env.force(params.force or "player") + rawset(player, "afk_time", params.afk_time or 0) + rawset(player, "force", force) + rawset(player, "object_name", "LuaPlayer") + if player.connected then + force.connected_players[#force.connected_players + 1] = player + end + return player + end + + function env.raise(name, event) + local id = (defines.events --[[@as table]])[name] + local handler = env.handlers.events[id] + assert(handler, "no handler for " .. name) + handler(event) + end + + return env +end) diff --git a/exp_statistics/test/module/events.lua b/exp_statistics/test/module/events.lua new file mode 100644 index 00000000..97b0ddd9 --- /dev/null +++ b/exp_statistics/test/module/events.lua @@ -0,0 +1,133 @@ +local Suite = ... --- @type Suite + +Suite.test("counters go up by one for online players", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local bob = env.add_player{ name = "bob", connected = false } + env.raise("on_console_chat", { player_index = alice.index }) + env.raise("on_console_chat", { player_index = alice.index }) + env.raise("on_player_died", { player_index = alice.index }) + env.raise("on_console_chat", { player_index = bob.index }) + env.raise("on_console_chat", {}) + + Suite.eq(Stats.get(alice, "ChatMessages"), 2, "chat") + Suite.eq(Stats.get(alice, "Deaths"), 1, "deaths") + Suite.eq(Stats.get(bob, "ChatMessages"), 0, "offline players are skipped") +end) + +Suite.test("a player is counted once per map", function(env) + local alice = env.add_player{ name = "alice" } + env.raise("on_player_created", { player_index = alice.index }) + Suite.eq(env.Statistics.get(alice, "MapsPlayed"), 1, "maps played") +end) + +Suite.test("playtime and afk time go up each minute", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local bob = env.add_player{ name = "bob", afk_time = 6 * 3600 } + local tick = env.handlers.on_nth_tick[3600] + game.tick = 3600 + tick() + tick() + + Suite.eq(Stats.get(alice, "Playtime"), 2, "playtime") + Suite.eq(Stats.get(alice, "AfkTime"), 0, "not afk") + Suite.eq(Stats.get(bob, "AfkTime"), 2, "afk") +end) + +Suite.test("distance is not counted while afk", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local bob = env.add_player{ name = "bob", afk_time = 6 * 3600 } + env.raise("on_player_changed_position", { player_index = alice.index }) + env.raise("on_player_changed_position", { player_index = bob.index }) + Suite.eq(Stats.get(alice, "DistanceTravelled"), 1, "moving") + Suite.eq(Stats.get(bob, "DistanceTravelled"), 0, "afk") +end) + +Suite.test("removed entities count as ore, trees or machines", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local function entity(type, force) + return { valid = true, type = type, force = force or env.force("neutral") } + end + env.raise("on_player_mined_entity", { player_index = alice.index, entity = entity("resource") }) + env.raise("on_marked_for_deconstruction", { player_index = alice.index, entity = entity("tree") }) + env.raise("on_player_mined_entity", { player_index = alice.index, entity = entity("assembling-machine", alice.force) }) + env.raise("on_player_mined_entity", { player_index = alice.index, entity = entity("assembling-machine", env.force("enemy")) }) + env.raise("on_marked_for_deconstruction", { entity = entity("tree") }) + + Suite.eq(Stats.get(alice, "OreMined"), 1, "ore") + Suite.eq(Stats.get(alice, "TreesDestroyed"), 1, "trees, by players only") + Suite.eq(Stats.get(alice, "MachinesRemoved"), 1, "own force only") +end) + +Suite.test("damage and kills count against hostile entities", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local character = { valid = true, type = "character", object_name = "LuaEntity", player = alice } + local enemy = { valid = true, force = env.force("enemy") } + local ally = { valid = true, force = alice.force } + env.raise("on_entity_damaged", { cause = character, entity = enemy, final_damage_amount = 12.7 }) + env.raise("on_entity_damaged", { cause = character, entity = ally, final_damage_amount = 5 }) + env.raise("on_entity_died", { cause = character, entity = enemy }) + env.raise("on_entity_died", { entity = enemy }) + + Suite.eq(Stats.get(alice, "DamageDealt"), 12, "damage is floored, allies are skipped") + Suite.eq(Stats.get(alice, "Kills"), 1, "kills without a cause are skipped") +end) + +Suite.test("vehicles credit their driver", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local enemy = { valid = true, force = env.force("enemy") } + local car = { + valid = true, type = "car", + get_driver = function() return alice end, + get_passenger = function() return nil end, + } + local empty_train = { + valid = true, type = "locomotive", + get_driver = function() return nil end, + get_passenger = function() return nil end, + } + env.raise("on_entity_died", { cause = car, entity = enemy }) + env.raise("on_entity_died", { cause = empty_train, entity = enemy }) + Suite.eq(Stats.get(alice, "Kills"), 1, "the driver, not the empty train") +end) + +Suite.test("rockets and research count for every online player on the force", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local bob = env.add_player{ name = "bob" } + local carol = env.add_player{ name = "carol", force = "other" } + env.raise("on_rocket_launched", { rocket_silo = { valid = true, force = alice.force } }) + env.raise("on_research_finished", { research = { valid = true, force = alice.force } }) + env.raise("on_research_finished", { by_script = true, research = { valid = true, force = alice.force } }) + + Suite.eq({ Stats.get(alice, "RocketsLaunched"), Stats.get(bob, "RocketsLaunched") }, { 1, 1 }, "rockets") + Suite.eq(Stats.get(alice, "ResearchCompleted"), 1, "research by script is skipped") + Suite.eq(Stats.get(carol, "RocketsLaunched"), 0, "other forces are skipped") +end) + +Suite.test("leaving sends the player and forgets them", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local bob = env.add_player{ name = "bob" } + Stats.increment(alice, "Kills") + Stats.increment(bob, "Kills") + env.raise("on_player_left_game", { player_index = alice.index }) + + Suite.eq(assert(env.sent[1]).data.players, { { name = "alice", stats = { Kills = 1 } } }, "only the player leaving") + Suite.eq(Stats.get(alice, "Kills"), 0, "forgotten") + Suite.eq(Stats.get(bob, "Kills"), 1, "others are kept") +end) + +Suite.test("everyone is sent every 5 minutes", function(env) + local alice = env.add_player{ name = "alice" } + env.Statistics.increment(alice, "Kills") + env.handlers.on_nth_tick[5 * 3600]() + Suite.eq(#env.sent, 1, "one update") +end) + +return Suite.run() diff --git a/exp_statistics/tsconfig.browser.json b/exp_statistics/tsconfig.browser.json new file mode 100644 index 00000000..a621b69f --- /dev/null +++ b/exp_statistics/tsconfig.browser.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.browser.json", + "include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ], +} diff --git a/exp_statistics/tsconfig.json b/exp_statistics/tsconfig.json new file mode 100644 index 00000000..303b8f28 --- /dev/null +++ b/exp_statistics/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.browser.json" } + ] +} diff --git a/exp_statistics/tsconfig.node.json b/exp_statistics/tsconfig.node.json new file mode 100644 index 00000000..3218f2e7 --- /dev/null +++ b/exp_statistics/tsconfig.node.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./**/*.ts"], + "exclude": ["test/*", "./dist/*"], +} diff --git a/exp_statistics/web/components/StatisticsPage.tsx b/exp_statistics/web/components/StatisticsPage.tsx new file mode 100644 index 00000000..77ba9142 --- /dev/null +++ b/exp_statistics/web/components/StatisticsPage.tsx @@ -0,0 +1,95 @@ +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({ + namespace: "statistics", + defaultSortKey: "Playtime", + defaultSortOrder: "descend", + pagination: { defaultPageSize: 50 }, + }); + const playerSearch = useColumnSearch(tableState, "player", player => player.id, "Search players"); + + const columns: 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.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), + })), + ]; + + return + + player.id} + pagination={tableState.pagination} + onChange={tableState.onChange} + scroll={{ x: "max-content" }} + /> + ; +} diff --git a/exp_statistics/web/index.tsx b/exp_statistics/web/index.tsx new file mode 100644 index 00000000..fcfdb8bc --- /dev/null +++ b/exp_statistics/web/index.tsx @@ -0,0 +1,27 @@ +import React, { useCallback, useSyncExternalStore } from "react"; +import { WebPluginContext } from "@clusterio/web_ui"; + +import * as lib from "@clusterio/lib"; +import * as messages from "../messages.js"; + +import StatisticsPage from "./components/StatisticsPage"; + +let players: lib.MapSubscriber | undefined; + +export function usePlayerStatistics() { + const subscribe = useCallback((cb: () => void) => players!.subscribe(cb), []); + return useSyncExternalStore(subscribe, () => players!.getSnapshot()); +} + +export default function registerPlugin(context: WebPluginContext) { + context.control.hooks.pages.attach("exp_statistics", () => [ + { + path: "/exp_statistics", + sidebarName: "Statistics", + permission: "exp_statistics.view", + content: , + }, + ]); + + players = new lib.MapSubscriber(messages.PlayerStatisticsUpdatedEvent, context.control); +} diff --git a/exp_statistics/webpack.config.cjs b/exp_statistics/webpack.config.cjs new file mode 100644 index 00000000..daeda18c --- /dev/null +++ b/exp_statistics/webpack.config.cjs @@ -0,0 +1,34 @@ +"use strict"; +const path = require("path"); +const webpack = require("webpack"); +const { merge } = require("webpack-merge"); + +const common = require("@clusterio/web_ui/webpack.common"); + +module.exports = (env = {}) => merge(common(env), { + context: __dirname, + entry: "./web/index.tsx", + output: { + path: path.resolve(__dirname, "dist", "web"), + }, + plugins: [ + new webpack.container.ModuleFederationPlugin({ + name: "exp_statistics", + library: { type: "window", name: "plugin_exp_statistics" }, + exposes: { + "./": "./index.ts", + "./package.json": "./package.json", + "./web": "./web/index.tsx", + }, + shared: { + "@clusterio/lib": { import: false }, + "@clusterio/web_ui": { import: false }, + "antd": { import: false }, + "react": { import: false }, + "react-dom": { import: false }, + "react-router": { import: false }, + "react-router-dom": { import: false }, + }, + }), + ], +}); diff --git a/tsconfig.json b/tsconfig.json index 4ebe1313..9225e5c9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ { "path": "./exp_scenario/" }, { "path": "./exp_server_list/" }, { "path": "./exp_server_ups/" }, + { "path": "./exp_statistics/" }, { "path": "./exp_util/" }, ], }