mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-11 15:54:52 +09:00
Merge pull request #497 from bbassie/feature/statistics-plugin
Move player statistics into an exp_player_statistics plugin
This commit is contained in:
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",
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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=允許寫入資料。
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
@@ -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()");
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,171 @@
|
||||
--[[-- ExpPlayerStatistics - Events
|
||||
Tracks the statistics, and sends them to the controller.
|
||||
]]
|
||||
|
||||
local ExpPlayerStatistics = require("modules/exp_player_statistics/control")
|
||||
local config = ExpPlayerStatistics.config
|
||||
|
||||
local increment = ExpPlayerStatistics.increment
|
||||
local floor = math.floor
|
||||
local afk_required = 5 * 3600 -- 5 minutes
|
||||
local send_interval = 5 * 3600 -- 5 minutes
|
||||
|
||||
--- @type table<defines.events, fun(event: EventData)>
|
||||
local events = {}
|
||||
|
||||
--- Add a handler, chained after any other for the same event
|
||||
--- @param event_id defines.events
|
||||
--- @param handler fun(event: EventData)
|
||||
local function add(event_id, handler)
|
||||
local existing = events[event_id]
|
||||
if existing then
|
||||
events[event_id] = function(event)
|
||||
existing(event)
|
||||
handler(event)
|
||||
end
|
||||
else
|
||||
events[event_id] = handler
|
||||
end
|
||||
end
|
||||
|
||||
--- Get the player of an event if they are online
|
||||
--- @param event { player_index: uint? }
|
||||
--- @return LuaPlayer?
|
||||
local function get_online_player(event)
|
||||
if not event.player_index then return nil end
|
||||
local player = game.players[event.player_index]
|
||||
if not player or not player.valid or not player.connected then return nil end
|
||||
return player
|
||||
end
|
||||
|
||||
--- @param event EventData.on_player_left_game
|
||||
add(defines.events.on_player_left_game, function(event)
|
||||
local player = game.players[event.player_index]
|
||||
ExpPlayerStatistics.send_and_forget(player.name)
|
||||
end)
|
||||
|
||||
--- Count a minute of afk time for every player who has been afk long enough
|
||||
local function count_afk_time()
|
||||
if game.tick == 0 then return end
|
||||
for _, player in pairs(game.connected_players) do
|
||||
if player.afk_time > afk_required then increment(player, "AfkTime") end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
--- @param event EventData.on_player_created
|
||||
add(defines.events.on_player_created, function(event)
|
||||
increment(game.players[event.player_index], "MapsPlayed")
|
||||
end)
|
||||
|
||||
--- @param event EventData.on_player_changed_position
|
||||
add(defines.events.on_player_changed_position, function(event)
|
||||
local player = get_online_player(event)
|
||||
if not player or player.afk_time > afk_required then return end
|
||||
increment(player, "DistanceTravelled")
|
||||
end)
|
||||
|
||||
--- @param event EventData.on_marked_for_deconstruction | EventData.on_player_mined_entity
|
||||
local function on_removed(event)
|
||||
local player = get_online_player(event)
|
||||
if not player then return end
|
||||
local entity = event.entity
|
||||
if not entity.valid then return end
|
||||
if entity.type == "resource" then
|
||||
increment(player, "OreMined")
|
||||
elseif entity.type == "tree" then
|
||||
increment(player, "TreesDestroyed")
|
||||
elseif entity.force == player.force then
|
||||
increment(player, "MachinesRemoved")
|
||||
end
|
||||
end
|
||||
add(defines.events.on_marked_for_deconstruction, on_removed)
|
||||
add(defines.events.on_player_mined_entity, on_removed)
|
||||
|
||||
--- Vehicles which credit their driver, or failing that their passenger, with what they hit
|
||||
local crewed_vehicles = { car = true, ["spider-vehicle"] = true, locomotive = true }
|
||||
|
||||
--- Get the connected player behind the cause of damage or a death, nil for turrets, trains without a crew and the like
|
||||
--- @param cause LuaEntity?
|
||||
--- @return LuaPlayer?
|
||||
local function get_cause_player(cause)
|
||||
if not cause or not cause.valid then return nil end
|
||||
|
||||
local occupant = cause --- @type LuaEntity | LuaPlayer
|
||||
if crewed_vehicles[cause.type] then
|
||||
local crew = cause.get_driver() or cause.get_passenger()
|
||||
if not crew then return nil end
|
||||
occupant = crew
|
||||
end
|
||||
|
||||
local player --- @type LuaPlayer?
|
||||
if occupant.object_name == "LuaPlayer" then
|
||||
player = occupant --[[@as LuaPlayer]]
|
||||
elseif occupant.type == "character" then
|
||||
player = occupant.player
|
||||
end
|
||||
|
||||
if not player or not player.valid or not player.connected then return nil end
|
||||
return player
|
||||
end
|
||||
|
||||
--- Check that damage or a death counts against an entity, it must be hostile to the player
|
||||
--- @param entity LuaEntity
|
||||
--- @param player LuaPlayer
|
||||
--- @return boolean
|
||||
local function is_hostile(entity, player)
|
||||
return entity.valid and entity.force ~= player.force and entity.force.name ~= "neutral"
|
||||
end
|
||||
|
||||
--- @param event EventData.on_entity_damaged
|
||||
add(defines.events.on_entity_damaged, function(event)
|
||||
local player = get_cause_player(event.cause)
|
||||
if not player or not is_hostile(event.entity, player) then return end
|
||||
increment(player, "DamageDealt", floor(event.final_damage_amount))
|
||||
end)
|
||||
|
||||
--- @param event EventData.on_entity_died
|
||||
add(defines.events.on_entity_died, function(event)
|
||||
local player = get_cause_player(event.cause)
|
||||
if not player or not is_hostile(event.entity, player) then return end
|
||||
increment(player, "Kills")
|
||||
end)
|
||||
|
||||
--- Increment a statistic for every online player on a force
|
||||
--- @param force LuaForce?
|
||||
--- @param statistic string
|
||||
local function increment_force(force, statistic)
|
||||
if not force or not force.valid then return end
|
||||
for _, player in pairs(force.connected_players) do
|
||||
increment(player, statistic)
|
||||
end
|
||||
end
|
||||
|
||||
--- @param event EventData.on_rocket_launched
|
||||
add(defines.events.on_rocket_launched, function(event)
|
||||
local silo = event.rocket_silo
|
||||
if not silo or not silo.valid then return end
|
||||
increment_force(silo.force --[[@as LuaForce]], "RocketsLaunched")
|
||||
end)
|
||||
|
||||
--- @param event EventData.on_research_finished
|
||||
add(defines.events.on_research_finished, function(event)
|
||||
local research = event.research
|
||||
if event.by_script or not research or not research.valid then return end
|
||||
increment_force(research.force, "ResearchCompleted")
|
||||
end)
|
||||
|
||||
for statistic, event_id in pairs(config.counters) do
|
||||
add(event_id, function(event)
|
||||
local player = get_online_player(event)
|
||||
if player then increment(player, statistic) end
|
||||
end)
|
||||
end
|
||||
|
||||
return {
|
||||
events = events,
|
||||
on_nth_tick = {
|
||||
[3600] = count_afk_time,
|
||||
[send_interval] = ExpPlayerStatistics.send_all,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
--- @diagnostic disable: global-in-non-module
|
||||
|
||||
-- Access using `/sc exp_player_statistics.foo()`
|
||||
exp_player_statistics = require("modules/exp_player_statistics/control")
|
||||
@@ -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=用戶介面語言
|
||||
@@ -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")
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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()
|
||||
@@ -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" ],
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.browser.json" }
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
</>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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__
|
||||
|
||||
|
||||
@@ -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__
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"exp_roles": "*",
|
||||
"exp_reports": "*",
|
||||
"exp_server_list": "*",
|
||||
"exp_player_statistics": "*",
|
||||
"exp_gui": "*",
|
||||
"exp_commands": "*"
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@expcluster/permission-groups": "workspace:^",
|
||||
"@expcluster/roles": "workspace:^",
|
||||
"@expcluster/server-list": "workspace:^",
|
||||
"@expcluster/player-statistics": "workspace:^",
|
||||
"@sinclair/typebox": "catalog:"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
{ "path": "./exp_scenario/" },
|
||||
{ "path": "./exp_server_list/" },
|
||||
{ "path": "./exp_server_ups/" },
|
||||
{ "path": "./exp_player_statistics/" },
|
||||
{ "path": "./exp_util/" },
|
||||
],
|
||||
}
|
||||
Reference in new issue
Block a user