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