Merge pull request #497 from bbassie/feature/statistics-plugin

Move player statistics into an exp_player_statistics plugin
This commit is contained in:
Cooldude2606 authored and GitHub committed 2026-10-15 13:32:44 +01:00
commit 98c3173807
42 files changed
+1946 -658

No files matched your search

@@ -5,7 +5,4 @@
-- @config File-Loader
return {
"expcore.player_data", -- must be loaded first to register event handlers
--- Data
"modules.data.statistics",
}
-46
View File
@@ -1,46 +0,0 @@
--- A list of all tracked statistics and the events which trigger them
-- @config Statistics
local e = defines.events -- order as per lua api as it was easier just to go down the list
return {
MapsPlayed = true, --- @setting MapsPlayed If the number of maps which a player has played should be tracked
Playtime = true, --- @setting Playtime If playtime is tracked for a player, play time measured in minutes
AfkTime = true, --- @setting AfkTime If afk time is tracked for a player, play time measured in minutes, afk is once a player does nothing for 5 minutes
DistanceTravelled = true, --- @setting DistanceTravelled If distance Travelled is checked, only counts if not afk
MachinesRemoved = true, --- @setting MachinesRemoved If removed machines are tracked, includes marked for decon and player mined entity
TreesDestroyed = true, --- @setting OreMined If ore mined is tracked for a player, includes marked for decon and player mined entity but only trees
OreMined = true, --- @setting OreMined If ore mined is tracked for a player, includes player mined entity but only ore
DamageDealt = true, --- @setting DamageDealt If damage dealt is tracked for a player, includes any damage to entities not on the same force or neutral
Kills = true, --- @setting Kills If kills are tracked for a player, includes all kills not on same force or neutral
RocketsLaunched = true, --- @setting RocketsLaunched If the number of rockets launched should be tracked, done for all players on the force
ResearchCompleted = true, --- @setting ResearchCompleted If the number of researches completed should be tracked, done for all players on the force
counters = { --- @setting counters Simple statistics that just go up by one each time an event happens
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,
},
display_order = { --- @setting display_order The order that the statistics should be shown in when in a gui or command
"Playtime", "AfkTime",
"MapsPlayed", "JoinCount",
"ChatMessages", "CommandsUsed",
"RocketsLaunched", "ResearchCompleted",
"MachinesBuilt", "MachinesRemoved",
"TilesBuilt", "TilesRemoved",
"TreesDestroyed", "OreMined",
"ItemsCrafted", "ItemsPickedUp",
"Kills", "Deaths",
"DamageDealt", "DistanceTravelled",
"CapsulesUsed", "EntityRepaired",
"DeconstructionPlannerUsed", "MapTagsMade",
},
}
-66
View File
@@ -19,72 +19,6 @@ Bonus=Player Bonus
Bonus-tooltip=The bonus given to your character
Bonus-value-tooltip=Change by using /bonus
[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
# expcore
[expcore-data]
description-preference=Allows you to set/get your data saving preference.
-66
View File
@@ -19,72 +19,6 @@ Bonus=人物附加屬性
Bonus-tooltip=你的人物附加屬性
Bonus-value-tooltip=使用 /bonus 來更改
[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=掛機時間除以遊玩時間
# expcore
[expcore-data]
description-preference=允許寫入資料。
-66
View File
@@ -19,72 +19,6 @@ Bonus=人物附加屬性
Bonus-tooltip=你的人物附加屬性
Bonus-value-tooltip=使用 /bonus 來更改
[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=掛機時間除以遊玩時間
# expcore
[expcore-data]
description-preference=允許寫入資料。
@@ -1,208 +0,0 @@
local ExpUtil = require("modules/exp_util")
local Event = require("modules/exp_legacy/utils/event") --- @dep utils.event
local Storage = require("modules/exp_util/storage") --- @dep utils.global
local config = require("modules.exp_legacy.config.statistics") --- @dep config.statistics
local floor = math.floor
local afk_required = 5 * 3600 -- 5 minutes
--- Stores players who have been created, required to avoid loss of data
local new_players = {}
Storage.register(new_players, function(tbl)
new_players = tbl
end)
--- Stores the statistics on a player
local PlayerData = require("modules.exp_legacy.expcore.player_data") --- @dep expcore.player_data
local AllPlayerData = PlayerData.All
local Statistics = PlayerData.Statistics
Statistics:set_metadata{
display_order = config.display_order,
}
--- Update your statistics with any which happened before the data was valid
Statistics:on_load(function(player_name, player_statistics)
local existing_data = AllPlayerData:get(player_name)
if existing_data and existing_data.valid then return end
local counters = config.counters
-- Merge all data from before you data loaded
for key, value in pairs(Statistics:get(player_name, {})) do
if config[key] or counters[key] then
if not player_statistics[key] then
player_statistics[key] = value
else
player_statistics[key] = player_statistics[key] + value
end
end
end
-- Increment your maps played if this is your first time on this map
if new_players[player_name] then
new_players[player_name] = nil
local ctn = player_statistics.MapsPlayed
player_statistics["MapsPlayed"] = ctn and ctn + 1 or 1
end
return player_statistics
end)
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 }
--- Add MapsPlayed if it is enabled
if config.MapsPlayed then
Statistics:combine("MapsPlayed")
Event.add(defines.events.on_player_created, function(event)
local player = game.players[event.player_index]
new_players[player.name] = true
end)
end
--- Add Playtime and AfkTime if it is enabled
if config.Playtime or config.AfkTime then
local playtime, afk_time
if config.Playtime then
playtime = Statistics:combine("Playtime")
playtime:set_metadata{ stringify = long_time_format, stringify_short = short_time_format }
end
if config.AfkTime then
afk_time = Statistics:combine("AfkTime")
afk_time:set_metadata{ stringify = long_time_format, stringify_short = short_time_format }
end
Event.on_nth_tick(3600, function()
if game.tick == 0 then return end
for _, player in pairs(game.connected_players) do
if playtime then playtime:increment(player) end
if afk_time and player.afk_time > afk_required then afk_time:increment(player) end
end
end)
end
--- Add DistanceTravelled if it is enabled
if config.DistanceTravelled then
local stat = Statistics:combine("DistanceTravelled")
stat:set_metadata{ unit = " tiles" }
Event.add(defines.events.on_player_changed_position, function(event)
local player = game.players[event.player_index]
if not player.valid or not player.connected or player.afk_time > afk_required then return end
stat:increment(player)
end)
end
--- Add MachinesRemoved and TreesDestroyed and config.OreMined if it is enabled
if config.MachinesRemoved or config.TreesDestroyed or config.OreMined then
local machines, trees, ore
if config.MachinesRemoved then machines = Statistics:combine("MachinesRemoved") end
if config.TreesDestroyed then trees = Statistics:combine("TreesDestroyed") end
if config.OreMined then ore = Statistics:combine("OreMined") end
local function on_event(event)
if not event.player_index then return end -- Check player is valid
local player = game.players[event.player_index]
if not player.valid or not player.connected then return end
local entity = event.entity -- Check entity is valid
if not entity.valid then return end
if entity.type == "resource" then
ore:increment(player)
elseif entity.type == "tree" then
trees:increment(player)
elseif entity.force == player.force then
machines:increment(player)
end
end
Event.add(defines.events.on_marked_for_deconstruction, on_event)
Event.add(defines.events.on_player_mined_entity, on_event)
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
--- Add DamageDealt if it is enabled
if config.DamageDealt then
local stat = Statistics:combine("DamageDealt")
Event.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
stat:increment(player, floor(event.final_damage_amount))
end)
end
--- Add Kills if it is enabled
if config.Kills then
local stat = Statistics:combine("Kills")
Event.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
stat:increment(player)
end)
end
--- Add RocketsLaunched if it is enabled
if config.RocketsLaunched then
local stat = Statistics:combine("RocketsLaunched")
Event.add(defines.events.on_rocket_launched, function(event)
local silo = event.rocket_silo -- Check silo is valid
if not silo or not silo.valid then return end
local force = silo.force -- Check force is valid
if not force or not force.valid then return end
for _, player in pairs(force.connected_players) do
stat:increment(player)
end
end)
end
--- Add RocketsLaunched if it is enabled
if config.ResearchCompleted then
local stat = Statistics:combine("ResearchCompleted")
Event.add(defines.events.on_research_finished, function(event)
local research = event.research -- Check research is valid
if event.by_script or not research or not research.valid then return end
local force = research.force -- Check force is valid
if not force or not force.valid then return end
for _, player in pairs(force.connected_players) do
stat:increment(player)
end
end)
end
--- Add all the remaining statistics from the config
for statistic, event_name in pairs(config.counters) do
local stat = Statistics:combine(statistic)
Event.add(event_name, function(event)
if not event.player_index then return end
local player = game.players[event.player_index]
if not player.valid or not player.connected then return end
stat:increment(player)
end)
end
+64
View File
@@ -0,0 +1,64 @@
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_player_statistics", "players.json"),
messages.PlayerStatistics.fromJSON.bind(messages.PlayerStatistics),
).bootstrap()
);
function addDeltas(deltas: messages.StatisticsDelta[]) {
const changed = new Map<string, messages.PlayerStatistics>();
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) => {
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()]);
controller.hooks.save.attach(plugin.name, async () => {
await players.save();
});
controller.hooks.shutdown.attach(plugin.name, async () => {
await players.save();
});
}
+38
View File
@@ -0,0 +1,38 @@
import * as lib from "@clusterio/lib";
import * as messages from "./messages.js";
declare module "@clusterio/lib" {
export interface Permissions {
"exp_player_statistics.view": never;
}
}
export const plugin: lib.PluginDeclaration = {
name: "exp_player_statistics",
title: "ExpGaming - Player 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_player_statistics.view",
title: "View player statistics",
description: "View the statistics of every player and subscribe to their updates",
grantByDefault: true,
},
],
webEntrypoint: "./web",
routes: [
"/exp_player_statistics",
],
};
+59
View File
@@ -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<string, number> }[],
};
/**
* 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_player_statistics.receive_totals(helpers.json_to_table[${level}[${json}]${level}])`);
}
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)),
));
});
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_player_statistics.discard_unsent()");
});
instance.hooks.stop.attach(plugin.name, async () => {
await luaCall("exp_player_statistics.send_all()");
});
}
+160
View File
@@ -0,0 +1,160 @@
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<string, number> = {},
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<typeof PlayerStatistics.jsonSchema> = {
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<typeof this.jsonSchema>) {
return new this(json.id, { ...json.stats }, json.updated_at_ms ?? 0, json.is_deleted ?? false);
}
}
/** 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(
public name: string,
public stats: Record<string, number>,
) {}
static jsonSchema = Type.Object({
name: Type.String(),
stats: StatsSchema,
});
toJSON() {
return { name: this.name, stats: this.stats };
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
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_player_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<typeof this.jsonSchema>) {
return new this(json.deltas.map(delta => StatisticsDelta.fromJSON(delta)));
}
}
export class PlayerStatisticsUpdatedEvent {
declare ["constructor"]: typeof PlayerStatisticsUpdatedEvent;
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_player_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<typeof this.jsonSchema>) {
return new this(json.updates.map(player => PlayerStatistics.fromJSON(player)));
}
}
/** Totals for one player, with Playtime and JoinCount from their clusterio user. */
export class StatisticsGetRequest {
declare ["constructor"]: typeof StatisticsGetRequest;
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_player_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<typeof this.jsonSchema>) {
return new this(json.name);
}
}
export class StatisticsListRequest {
declare ["constructor"]: typeof StatisticsListRequest;
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_player_statistics.view" as const;
static Response = lib.jsonArray(PlayerStatistics);
}
+39
View File
@@ -0,0 +1,39 @@
--[[-- 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 {
--- Statistics that go up by one each time the event is raised for a player
--- @type table<string, defines.events>
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,
TilesRemoved = e.on_player_mined_tile,
CapsulesUsed = e.on_player_used_capsule,
EntityRepaired = e.on_player_repaired_entity,
},
--- The order statistics are shown in
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",
},
}
+155
View File
@@ -0,0 +1,155 @@
--[[-- ExpPlayerStatistics
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 ExpPlayerStatistics = require("modules/exp_player_statistics")
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_player_statistics/config")
local format_number = require("util").format_number
--- @alias ExpPlayerStatistics.Stats table<string, number>
--- @class ExpPlayerStatistics.Totals
--- @field name string
--- @field stats ExpPlayerStatistics.Stats
--- @class ExpPlayerStatistics
local ExpPlayerStatistics = {
config = config,
--- Statistics in the order they are shown
--- @type string[]
display_order = config.display_order,
--- Shown after the value
--- @type table<string, string>
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 }
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]
end)
--- Add to a statistic of a player
--- @param player LuaPlayer
--- @param statistic string
--- @param amount number? Defaults to 1
function ExpPlayerStatistics.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 ExpPlayerStatistics.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 ExpPlayerStatistics.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 ExpPlayerStatistics.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 ExpPlayerStatistics.send(name)
local stats = take_unsent(name)
if stats then
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 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_player_statistics:update", { players = players })
end
end
--- Send what a player gained and forget them, called when they leave
--- @param name string
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 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 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 ExpPlayerStatistics
+171
View File
@@ -0,0 +1,171 @@
--[[-- ExpPlayerStatistics - Events
Tracks the statistics, and sends them to the controller.
]]
local ExpPlayerStatistics = require("modules/exp_player_statistics/control")
local config = ExpPlayerStatistics.config
local increment = ExpPlayerStatistics.increment
local floor = math.floor
local afk_required = 5 * 3600 -- 5 minutes
local send_interval = 5 * 3600 -- 5 minutes
--- @type table<defines.events, fun(event: EventData)>
local events = {}
--- Add a handler, chained after any other for the same event
--- @param event_id defines.events
--- @param handler fun(event: EventData)
local function add(event_id, handler)
local existing = events[event_id]
if existing then
events[event_id] = function(event)
existing(event)
handler(event)
end
else
events[event_id] = handler
end
end
--- Get the player of an event if they are online
--- @param event { player_index: uint? }
--- @return LuaPlayer?
local function get_online_player(event)
if not event.player_index then return nil end
local player = game.players[event.player_index]
if not player or not player.valid or not player.connected then return nil end
return player
end
--- @param event EventData.on_player_left_game
add(defines.events.on_player_left_game, function(event)
local player = game.players[event.player_index]
ExpPlayerStatistics.send_and_forget(player.name)
end)
--- Count a minute of afk time for every player who has been afk long enough
local function count_afk_time()
if game.tick == 0 then return end
for _, player in pairs(game.connected_players) do
if player.afk_time > afk_required then increment(player, "AfkTime") end
end
end
--- @param event EventData.on_player_created
add(defines.events.on_player_created, function(event)
increment(game.players[event.player_index], "MapsPlayed")
end)
--- @param event EventData.on_player_changed_position
add(defines.events.on_player_changed_position, function(event)
local player = get_online_player(event)
if not player or player.afk_time > afk_required then return end
increment(player, "DistanceTravelled")
end)
--- @param event EventData.on_marked_for_deconstruction | EventData.on_player_mined_entity
local function on_removed(event)
local player = get_online_player(event)
if not player then return end
local entity = event.entity
if not entity.valid then return end
if entity.type == "resource" then
increment(player, "OreMined")
elseif entity.type == "tree" then
increment(player, "TreesDestroyed")
elseif entity.force == player.force then
increment(player, "MachinesRemoved")
end
end
add(defines.events.on_marked_for_deconstruction, on_removed)
add(defines.events.on_player_mined_entity, on_removed)
--- Vehicles which credit their driver, or failing that their passenger, with what they hit
local crewed_vehicles = { car = true, ["spider-vehicle"] = true, locomotive = true }
--- Get the connected player behind the cause of damage or a death, nil for turrets, trains without a crew and the like
--- @param cause LuaEntity?
--- @return LuaPlayer?
local function get_cause_player(cause)
if not cause or not cause.valid then return nil end
local occupant = cause --- @type LuaEntity | LuaPlayer
if crewed_vehicles[cause.type] then
local crew = cause.get_driver() or cause.get_passenger()
if not crew then return nil end
occupant = crew
end
local player --- @type LuaPlayer?
if occupant.object_name == "LuaPlayer" then
player = occupant --[[@as LuaPlayer]]
elseif occupant.type == "character" then
player = occupant.player
end
if not player or not player.valid or not player.connected then return nil end
return player
end
--- Check that damage or a death counts against an entity, it must be hostile to the player
--- @param entity LuaEntity
--- @param player LuaPlayer
--- @return boolean
local function is_hostile(entity, player)
return entity.valid and entity.force ~= player.force and entity.force.name ~= "neutral"
end
--- @param event EventData.on_entity_damaged
add(defines.events.on_entity_damaged, function(event)
local player = get_cause_player(event.cause)
if not player or not is_hostile(event.entity, player) then return end
increment(player, "DamageDealt", floor(event.final_damage_amount))
end)
--- @param event EventData.on_entity_died
add(defines.events.on_entity_died, function(event)
local player = get_cause_player(event.cause)
if not player or not is_hostile(event.entity, player) then return end
increment(player, "Kills")
end)
--- Increment a statistic for every online player on a force
--- @param force LuaForce?
--- @param statistic string
local function increment_force(force, statistic)
if not force or not force.valid then return end
for _, player in pairs(force.connected_players) do
increment(player, statistic)
end
end
--- @param event EventData.on_rocket_launched
add(defines.events.on_rocket_launched, function(event)
local silo = event.rocket_silo
if not silo or not silo.valid then return end
increment_force(silo.force --[[@as LuaForce]], "RocketsLaunched")
end)
--- @param event EventData.on_research_finished
add(defines.events.on_research_finished, function(event)
local research = event.research
if event.by_script or not research or not research.valid then return end
increment_force(research.force, "ResearchCompleted")
end)
for statistic, event_id in pairs(config.counters) do
add(event_id, function(event)
local player = get_online_player(event)
if player then increment(player, statistic) end
end)
end
return {
events = events,
on_nth_tick = {
[3600] = count_afk_time,
[send_interval] = ExpPlayerStatistics.send_all,
},
}
+4
View File
@@ -0,0 +1,4 @@
--- @diagnostic disable: global-in-non-module
-- Access using `/sc exp_player_statistics.foo()`
exp_player_statistics = require("modules/exp_player_statistics/control")
@@ -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
@@ -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=用戶介面語言
@@ -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=用戶介面語言
+13
View File
@@ -0,0 +1,13 @@
{
"name": "exp_player_statistics",
"load": [
"events.lua"
],
"require": [
"globals.lua"
],
"dependencies": {
"clusterio": "*",
"exp_util": "*"
}
}
@@ -0,0 +1,2 @@
-- Access the exports from other modules using require("modules/exp_player_statistics")
return require("modules/exp_player_statistics/control")
+61
View File
@@ -0,0 +1,61 @@
{
"name": "@expcluster/player-statistics",
"version": "7.0.1",
"description": "Clusterio plugin keeping player statistics on the controller",
"author": "Cooldude2606 <https://github.com/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"
]
}
@@ -0,0 +1,160 @@
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_player_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),
/** 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 }),
};
}
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_player_statistics"), `attached to ${hook}`);
}
});
t.test("controller plugin", t2 => {
t2.test("StatisticsUpdateEvent adds the deltas to the totals", async t3 => {
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 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, stored, state } = await startPlugin(t3);
await update(["alice", { Kills: 1 }], ["alice", { Kills: 2 }]);
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, stored } = await startPlugin(t3);
await update(["alice", { Kills: 1, Deaths: 0, Bad: Infinity, Worse: NaN }]);
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, { Playtime: 0, JoinCount: 0 });
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.stored("alice"), { Kills: 4 });
});
t2.end();
});
t.test("permissions", async t2 => {
const names = pluginDeclaration.permissions.map(permission => permission.name);
t2.strictSame(names, ["exp_player_statistics.view"]);
for (const Message of [
messages.StatisticsGetRequest, messages.StatisticsListRequest, messages.PlayerStatisticsUpdatedEvent,
]) {
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");
});
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);
});
+133
View File
@@ -0,0 +1,133 @@
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_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 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_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 } }] });
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_player_statistics.discard_unsent()",
"/sc exp_player_statistics.send_all()",
]);
});
t2.end();
});
+13
View File
@@ -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();
});
@@ -0,0 +1,120 @@
local Suite = ... --- @type Suite<ExpPlayerStatistics.TestEnv>
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_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, {
{ 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_player_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()
+101
View File
@@ -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 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: ExpPlayerStatistics.TestPlayerParams): ExpPlayerStatistics.TestPlayer
--- @class ExpPlayerStatistics.TestPlayerParams : Stubs.PlayerParams
--- @field afk_time number? Defaults to 0
--- @field force string? Defaults to "player"
--- @class ExpPlayerStatistics.TestPlayer : Stubs.Player
--- @field afk_time number
--- @field force table
return Framework.suite(function(env)
--- @cast env ExpPlayerStatistics.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_player_statistics/config"] = assert(loadfile(plugin_root .. "/module/config.lua"))(),
}
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 = {}
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 ExpPlayerStatistics.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<string, number>]])[name]
local handler = env.handlers.events[id]
assert(handler, "no handler for " .. name)
handler(event)
end
return env
end)
@@ -0,0 +1,133 @@
local Suite = ... --- @type Suite<ExpPlayerStatistics.TestEnv>
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("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 }
local tick = env.handlers.on_nth_tick[3600]
game.tick = 3600
tick()
tick()
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)
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()
@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.browser.json",
"include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ],
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.browser.json" }
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../tsconfig.node.json",
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
}
@@ -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>
</>;
}
+32
View File
@@ -0,0 +1,32 @@
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";
import UserStatistics from "./components/UserStatistics";
let players: lib.MapSubscriber<messages.PlayerStatisticsUpdatedEvent> | 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.extensionComponents.attach("exp_player_statistics", () => ({
UserViewPage: UserStatistics,
}));
context.control.hooks.pages.attach("exp_player_statistics", () => [
{
path: "/exp_player_statistics",
sidebarName: "Statistics",
permission: "exp_player_statistics.view",
content: <StatisticsPage />,
},
]);
players = new lib.MapSubscriber(messages.PlayerStatisticsUpdatedEvent, context.control);
}
+40
View File
@@ -0,0 +1,40 @@
/** Same order and names as module/config.lua and module/locale/en.cfg */
export const statistics: [name: string, title: string][] = [
["Playtime", "Playtime"],
["AfkTime", "AFK Time"],
["MapsPlayed", "Maps Played"],
["JoinCount", "Join Count"],
["ChatMessages", "Messages"],
["CommandsUsed", "Commands"],
["RocketsLaunched", "Rockets Launched"],
["ResearchCompleted", "Research Completed"],
["MachinesBuilt", "Machines Built"],
["MachinesRemoved", "Machines Removed"],
["TilesBuilt", "Tiles Placed"],
["TilesRemoved", "Tiles Removed"],
["TreesDestroyed", "Trees Destroyed"],
["OreMined", "Ore Mined"],
["ItemsCrafted", "Items Crafted"],
["ItemsPickedUp", "Items Picked Up"],
["Kills", "Kills"],
["Deaths", "Deaths"],
["DamageDealt", "Damage Dealt"],
["DistanceTravelled", "Distance Travelled"],
["CapsulesUsed", "Capsules Used"],
["EntityRepaired", "Machines Repaired"],
["DeconstructionPlannerUsed", "Decon Planner Used"],
["MapTagsMade", "Map Tags Created"],
];
/** Measured in minutes */
const timeStatistics = new Set(["Playtime", "AfkTime"]);
export function formatValue(name: string, value: number) {
if (timeStatistics.has(name)) {
return `${Math.floor(value / 60)}h ${value % 60}m`;
}
if (name === "DistanceTravelled") {
return `${value.toLocaleString()} tiles`;
}
return value.toLocaleString();
}
+34
View File
@@ -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_player_statistics",
library: { type: "window", name: "plugin_exp_player_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 },
},
}),
],
});
+27 -33
View File
@@ -6,10 +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")
require("modules/exp_legacy/modules/data/statistics")
local PlayerData = require("modules/exp_legacy/expcore/player_data")
local PlayerStats = PlayerData.Statistics
local ExpPlayerStatistics = require("modules/exp_player_statistics")
--- @class ExpGui_PlayerStats.elements
local Elements = {}
@@ -23,54 +20,64 @@ end
local short_time_zero, format_number_zero = short_time_format(0), format_number_2dp(0)
--- Get a statistic of a player, or the default when it is zero
--- @param player LuaPlayer
--- @param statistic string
--- @param default number
--- @return number
local function get_stat(player, statistic, default)
local value = ExpPlayerStatistics.get(player, statistic)
return value == 0 and default or value
end
--- @type table<string, { default: LocalisedString, calculate: fun(player: LuaPlayer): LocalisedString }>
local computed_stats = {
DamageDeathRatio = {
default = format_number_zero,
calculate = function(player)
return format_number_2dp(PlayerStats["DamageDealt"]:get(player, 0) / PlayerStats["Deaths"]:get(player, 1))
return format_number_2dp(get_stat(player, "DamageDealt", 0) / get_stat(player, "Deaths", 1))
end,
},
KillDeathRatio = {
default = format_number_zero,
calculate = function(player)
return format_number_2dp(PlayerStats["Kills"]:get(player, 0) / PlayerStats["Deaths"]:get(player, 1))
return format_number_2dp(get_stat(player, "Kills", 0) / get_stat(player, "Deaths", 1))
end,
},
SessionTime = {
default = short_time_zero,
calculate = function(player)
return short_time_format((PlayerStats["Playtime"]:get(player, 0) - PlayerStats["AfkTime"]:get(player, 0)) / PlayerStats["JoinCount"]:get(player, 1))
return short_time_format((get_stat(player, "Playtime", 0) - get_stat(player, "AfkTime", 0)) / get_stat(player, "JoinCount", 1))
end,
},
BuildRatio = {
default = format_number_zero,
calculate = function(player)
return format_number_2dp(PlayerStats["MachinesBuilt"]:get(player, 0) / PlayerStats["MachinesRemoved"]:get(player, 1))
return format_number_2dp(get_stat(player, "MachinesBuilt", 0) / get_stat(player, "MachinesRemoved", 1))
end,
},
RocketPerHour = {
default = format_number_zero,
calculate = function(player)
return format_number_2dp(PlayerStats["RocketsLaunched"]:get(player, 0) * 60 / PlayerStats["Playtime"]:get(player, 1))
return format_number_2dp(get_stat(player, "RocketsLaunched", 0) * 60 / get_stat(player, "Playtime", 1))
end,
},
TreeKillPerMinute = {
default = format_number_zero,
calculate = function(player)
return format_number_2dp(PlayerStats["TreesDestroyed"]:get(player, 0) / PlayerStats["Playtime"]:get(player, 1))
return format_number_2dp(get_stat(player, "TreesDestroyed", 0) / get_stat(player, "Playtime", 1))
end,
},
NetPlayTime = {
default = short_time_zero,
calculate = function(player)
return short_time_format((PlayerStats["Playtime"]:get(player, 0) - PlayerStats["AfkTime"]:get(player, 0)))
return short_time_format((get_stat(player, "Playtime", 0) - get_stat(player, "AfkTime", 0)))
end,
},
AFKTimeRatio = {
default = format_number_zero,
calculate = function(player)
return format_number_2dp(PlayerStats["AfkTime"]:get(player, 0) * 100 / PlayerStats["Playtime"]:get(player, 1))
return format_number_2dp(get_stat(player, "AfkTime", 0) * 100 / get_stat(player, "Playtime", 1))
end,
},
Locale = {
@@ -106,19 +113,15 @@ Elements.player_stats_table = Gui.define("player_stats/data_table")
local labels = {}
-- Add all standalone stats
for _, stat_name in pairs(PlayerData.Statistics.metadata.display_order) do
local metadata = PlayerData.Statistics[stat_name].metadata
local value = metadata.stringify_short and metadata.stringify_short(0)
or metadata.stringify and metadata.stringify(0)
or format_number(0, false)
for _, stat_name in ipairs(ExpPlayerStatistics.display_order) do
Elements.table_label(data_table, {
caption = metadata.name or { "exp-statistics." .. stat_name },
tooltip = metadata.tooltip or { "exp-statistics." .. stat_name .. "-tooltip" },
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", value, metadata.unit or "" },
tooltip = metadata.value_tooltip or { "exp-statistics." .. stat_name .. "-tooltip" },
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,
})
end
@@ -148,18 +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 pairs(PlayerStats.metadata.display_order) do
local stat = PlayerStats[stat_name]
local metadata = stat.metadata
local value = stat:get(player, 0)
if metadata.stringify_short then
value = metadata.stringify_short(value)
elseif metadata.stringify then
value = metadata.stringify(value)
else
value = format_number(value, false)
end
labels[stat_name].caption = { "exp-gui_readme.data-format", value, metadata.unit 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
+11 -149
View File
@@ -6,11 +6,10 @@ 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 PlayerData = require("modules.exp_legacy.expcore.player_data")
local ExpPlayerStatistics = require("modules/exp_player_statistics")
local ExpServerList = require("modules/exp_server_list")
local community = require("modules/exp_scenario/config/community_details").config
local format_number = require("util").format_number
local format_time = ExpUtil.format_time_factory_locale{ format = "long", days = true, hours = true, minutes = true }
local frame_width = 595
@@ -457,75 +456,14 @@ define_tab(
end) --[[@as any]]
)
--- @class (exact) ExpGui_Readme.elements.readme_data.param table
--- @field scroll_pane LuaGuiElement
--- @field player LuaPlayer
--- @field player_name string
--- @field children table
--- @field title LocalisedString
--- @field locale_prefix string
--- @field default_stringify fun(value: any): string
--- @field columns number
--- @field title_width number
--- @field column_width number
--- @field extra_rows fun(data_table: LuaGuiElement)?
--- Render a player data category
--- @param opts ExpGui_Readme.elements.readme_data.param
local function render_data_category(opts)
local data_table = Elements.title_table(opts.scroll_pane, opts.title_width, opts.title, opts.columns)
if opts.extra_rows then
opts.extra_rows(data_table)
end
for name, child in pairs(opts.children) do
local metadata = child.metadata
if not metadata.permission or Roles.player_has_permission(opts.player, metadata.permission) then
local value = child:get(opts.player_name)
if value ~= nil or metadata.show_always then
if metadata.stringify_short then
value = metadata.stringify_short(value)
elseif metadata.stringify then
value = metadata.stringify(value)
else
value = opts.default_stringify(value)
end
local tooltip = metadata.tooltip or { opts.locale_prefix .. name .. "-tooltip" }
Gui.elements.centered_label(
data_table, 150,
metadata.name or { opts.locale_prefix .. name },
tooltip
)
Gui.elements.centered_label(
data_table, opts.column_width,
{ "exp-gui_readme.data-format", value, metadata.unit or "" },
metadata.value_tooltip or { "?", { opts.locale_prefix .. name .. "-value-tooltip" }, tooltip }
)
end
end
end
end
--- Content area for the player data tab
--- Content area for the statistics tab
define_tab(
{ "exp-gui_readme.data-tab" },
{ "exp-gui_readme.data-tooltip" },
Gui.define("readme/data")
:draw(function(_, parent)
local container = parent.add{ type = "flow", direction = "vertical" }
local player = Gui.get_player(parent)
local player_name = player.name
local enum = PlayerData.PreferenceEnum
local preference = PlayerData.DataSavingPreference:get(player_name)
local preference_meta = PlayerData.DataSavingPreference.metadata
preference = enum[preference]
local player_name = Gui.get_player(parent).name
Gui.elements.title_label(container, title_width, { "exp-gui_readme.data-tab" })
Gui.elements.centered_label(container, frame_width, { "exp-gui_readme.data-general" })
@@ -533,90 +471,14 @@ define_tab(
container.add{ type = "flow" }
local scroll_pane = Elements.title_table_scroll(container)
render_data_category{
scroll_pane = scroll_pane,
player = player,
player_name = player_name,
children = PlayerData.Required.children,
title = { "exp-gui_readme.data-required" },
locale_prefix = "exp-required.",
columns = 2,
title_width = 250,
column_width = 420,
default_stringify = tostring,
extra_rows = function(required)
Gui.elements.centered_label(required, 150, preference_meta.name, preference_meta.tooltip)
Gui.elements.centered_label(required, 420, { "expcore-data.preference-" .. enum[preference] }, preference_meta.value_tooltip)
end,
}
if preference <= enum.Settings then
render_data_category{
scroll_pane = scroll_pane,
player = player,
player_name = player_name,
children = PlayerData.Settings.children,
title = { "exp-gui_readme.data-settings" },
locale_prefix = "exp-settings.",
columns = 2,
title_width = 255,
column_width = 420,
default_stringify = function(value)
return tostring(value or "None set")
end,
}
end
if preference <= enum.Statistics then
render_data_category{
scroll_pane = scroll_pane,
player = player,
player_name = player_name,
children = PlayerData.Statistics.children,
title = { "exp-gui_readme.data-statistics" },
locale_prefix = "exp-statistics.",
columns = 4,
title_width = 250,
column_width = 130,
default_stringify = function(value)
return format_number(value or 0, false)
end,
}
end
local skip = {
DataSavingPreference = true,
Settings = true,
Statistics = true,
Required = true,
}
local count = 0
for _ in pairs(PlayerData.All.children) do
count = count + 1
end
if preference <= enum.All and count > 4 then
local misc = {}
for name, child in pairs(PlayerData.All.children) do
if not skip[name] then
misc[name] = child
end
end
render_data_category{
scroll_pane = scroll_pane,
player = player,
player_name = player_name,
children = misc,
title = { "exp-gui_readme.data-misc" },
locale_prefix = "",
columns = 2,
title_width = 232,
column_width = 420,
default_stringify = tostring,
}
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, ExpPlayerStatistics.units[name] or "" }, tooltip)
end
return container
+3 -7
View File
@@ -365,13 +365,9 @@ backers-board=Board Members and Senior Backers
backers-staff=Staff Members
backers-backers=Sponsors and Supporters
backers-active=Active Players
data-tab=Data
data-tooltip=All of your stored player data
data-general=Our servers will save your player data so that we can sync it between servers. All of your data can be found below. If you wish to save a copy locally then use /save-data. If you want to change what data is saved then use /set-preference.
data-required=Required
data-settings=Settings
data-statistics=Statistics
data-misc=Miscellaneous
data-tab=Statistics
data-tooltip=Your statistics from all of our servers
data-general=These are your statistics from every one of our servers. They are saved when you leave and every 5 minutes.
data-format=__1____2__
[exp-gui_research-milestones]
+1 -7
View File
@@ -355,13 +355,7 @@ backers-board=議員
backers-staff=職員
backers-backers=支持者
backers-active=活躍玩家
data-tab=資料
data-tooltip=所有已存資料
data-general=資料會自動存到各個服務器中。 你也可以用 /save-data 存一份副本。 也可用 /set-preference 來更改喜好。
data-settings=設定
data-statistics=數據
data-required=需要
data-misc=雜項
data-tab=數據
data-format=__1____2__
welcome-title = Welcome to __1__
+1 -7
View File
@@ -355,13 +355,7 @@ backers-board=議員
backers-staff=職員
backers-backers=支持者
backers-active=活躍玩家
data-tab=資料
data-tooltip=所有已存資料
data-general=資料會自動存到各個服務器中。 你也可以用 /save-data 存一份副本。 也可用 /set-preference 來更改喜好。
data-settings=設定
data-statistics=數據
data-required=需要
data-misc=雜項
data-tab=數據
data-format=__1____2__
welcome-title = Welcome to __1__
+1
View File
@@ -12,6 +12,7 @@
"exp_roles": "*",
"exp_reports": "*",
"exp_server_list": "*",
"exp_player_statistics": "*",
"exp_gui": "*",
"exp_commands": "*"
}
+1
View File
@@ -52,6 +52,7 @@
"@expcluster/permission-groups": "workspace:^",
"@expcluster/roles": "workspace:^",
"@expcluster/server-list": "workspace:^",
"@expcluster/player-statistics": "workspace:^",
"@sinclair/typebox": "catalog:"
},
"publishConfig": {
+1
View File
@@ -12,6 +12,7 @@
{ "path": "./exp_scenario/" },
{ "path": "./exp_server_list/" },
{ "path": "./exp_server_ups/" },
{ "path": "./exp_player_statistics/" },
{ "path": "./exp_util/" },
],
}