diff --git a/exp_legacy/module/config/_file_loader.lua b/exp_legacy/module/config/_file_loader.lua index 79e8688f..3d029c51 100644 --- a/exp_legacy/module/config/_file_loader.lua +++ b/exp_legacy/module/config/_file_loader.lua @@ -5,7 +5,4 @@ -- @config File-Loader return { "expcore.player_data", -- must be loaded first to register event handlers - - --- Data - "modules.data.statistics", } diff --git a/exp_legacy/module/config/statistics.lua b/exp_legacy/module/config/statistics.lua deleted file mode 100644 index c4a4d4a0..00000000 --- a/exp_legacy/module/config/statistics.lua +++ /dev/null @@ -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", - }, -} diff --git a/exp_legacy/module/locale/en.cfg b/exp_legacy/module/locale/en.cfg index 9e85f680..1de56114 100644 --- a/exp_legacy/module/locale/en.cfg +++ b/exp_legacy/module/locale/en.cfg @@ -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. diff --git a/exp_legacy/module/locale/zh-CN.cfg b/exp_legacy/module/locale/zh-CN.cfg index 4d1ce2e2..82f411b8 100644 --- a/exp_legacy/module/locale/zh-CN.cfg +++ b/exp_legacy/module/locale/zh-CN.cfg @@ -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=允許寫入資料。 diff --git a/exp_legacy/module/locale/zh-TW.cfg b/exp_legacy/module/locale/zh-TW.cfg index 4d1ce2e2..82f411b8 100644 --- a/exp_legacy/module/locale/zh-TW.cfg +++ b/exp_legacy/module/locale/zh-TW.cfg @@ -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=允許寫入資料。 diff --git a/exp_legacy/module/modules/data/statistics.lua b/exp_legacy/module/modules/data/statistics.lua deleted file mode 100644 index ac844387..00000000 --- a/exp_legacy/module/modules/data/statistics.lua +++ /dev/null @@ -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 diff --git a/exp_player_statistics/controller.ts b/exp_player_statistics/controller.ts new file mode 100644 index 00000000..21bd1f95 --- /dev/null +++ b/exp_player_statistics/controller.ts @@ -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(); + 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(); + }); +} diff --git a/exp_player_statistics/index.ts b/exp_player_statistics/index.ts new file mode 100644 index 00000000..3af21429 --- /dev/null +++ b/exp_player_statistics/index.ts @@ -0,0 +1,38 @@ +import * as lib from "@clusterio/lib"; +import * as messages from "./messages.js"; + +declare module "@clusterio/lib" { + export interface Permissions { + "exp_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", + ], +}; diff --git a/exp_player_statistics/instance.ts b/exp_player_statistics/instance.ts new file mode 100644 index 00000000..56553c56 --- /dev/null +++ b/exp_player_statistics/instance.ts @@ -0,0 +1,59 @@ +import type { InstancePluginContext } from "@clusterio/host"; +import * as messages from "./messages.js"; + +/** Sent by the lua side with what each player gained since the last update. */ +export type IpcStatisticsUpdate = { + players: { name: string, stats: Record }[], +}; + +/** + * Forwards the statistics gained in game to the controller, and hands the + * totals of a player to lua when they join, see module/control.lua. + */ +export default async function (context: InstancePluginContext) { + const { instance, plugin, logger } = context; + + async function luaCall(code: string) { + await instance.sendRcon(`/sc ${code}`, true); + } + + async function luaSendTotals(record: messages.PlayerStatistics) { + const json = JSON.stringify({ name: record.id, stats: record.stats }); + // Pick a long bracket the json does not contain + let level = "="; + while (json.includes(`]${level}]`)) { + level += "="; + } + await luaCall(`exp_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()"); + }); +} diff --git a/exp_player_statistics/messages.ts b/exp_player_statistics/messages.ts new file mode 100644 index 00000000..ad6f8da3 --- /dev/null +++ b/exp_player_statistics/messages.ts @@ -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 = {}, + public updatedAtMs: number = 0, + public isDeleted: boolean = false, + ) {} + + static jsonSchema = Type.Object({ + id: Type.String(), + stats: StatsSchema, + updated_at_ms: Type.Optional(Type.Number()), + is_deleted: Type.Optional(Type.Boolean()), + }); + + toJSON() { + const json: Static = { + id: this.id, + stats: this.stats, + }; + + if (this.updatedAtMs) { + json.updated_at_ms = this.updatedAtMs; + } + + if (this.isDeleted) { + json.is_deleted = true; + } + + return json; + } + + static fromJSON(json: Static) { + return new this(json.id, { ...json.stats }, json.updated_at_ms ?? 0, json.is_deleted ?? false); + } +} + +/** Statistics clusterio already keeps for every user, shown with the others but never added to */ +export function userStatistics(user?: Pick): Record { + return { + Playtime: Math.floor((user?.playerStats.onlineTimeMs ?? 0) / 60e3), + JoinCount: user?.playerStats.joinCount ?? 0, + }; +} + +/** Statistics gained by one player since the last update */ +export class StatisticsDelta { + constructor( + public name: string, + public stats: Record, + ) {} + + static jsonSchema = Type.Object({ + name: Type.String(), + stats: StatsSchema, + }); + + toJSON() { + return { name: this.name, stats: this.stats }; + } + + static fromJSON(json: Static) { + return new this(json.name, json.stats); + } +} + +/** Sent by an instance to add to the totals of its players. */ +export class StatisticsUpdateEvent { + declare ["constructor"]: typeof StatisticsUpdateEvent; + static plugin = "exp_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) { + 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) { + 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) { + 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); +} diff --git a/exp_player_statistics/module/config.lua b/exp_player_statistics/module/config.lua new file mode 100644 index 00000000..d2725e73 --- /dev/null +++ b/exp_player_statistics/module/config.lua @@ -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 + 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", + }, +} diff --git a/exp_player_statistics/module/control.lua b/exp_player_statistics/module/control.lua new file mode 100644 index 00000000..30946870 --- /dev/null +++ b/exp_player_statistics/module/control.lua @@ -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 + +--- @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 + 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 +local totals = {} --- @type table +Storage.register({ unsent, totals }, function(tbl) + unsent = tbl[1] + totals = tbl[2] +end) + +--- Add to a statistic of a player +--- @param player LuaPlayer +--- @param statistic string +--- @param amount number? Defaults to 1 +function 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 diff --git a/exp_player_statistics/module/events.lua b/exp_player_statistics/module/events.lua new file mode 100644 index 00000000..d7244bc6 --- /dev/null +++ b/exp_player_statistics/module/events.lua @@ -0,0 +1,171 @@ +--[[-- ExpPlayerStatistics - Events +Tracks the statistics, and sends them to the controller. +]] + +local ExpPlayerStatistics = require("modules/exp_player_statistics/control") +local config = ExpPlayerStatistics.config + +local increment = ExpPlayerStatistics.increment +local floor = math.floor +local afk_required = 5 * 3600 -- 5 minutes +local send_interval = 5 * 3600 -- 5 minutes + +--- @type table +local events = {} + +--- Add a handler, chained after any other for the same event +--- @param event_id defines.events +--- @param handler fun(event: EventData) +local function add(event_id, handler) + local existing = events[event_id] + if existing then + events[event_id] = function(event) + existing(event) + handler(event) + end + else + events[event_id] = handler + end +end + +--- Get the player of an event if they are online +--- @param event { player_index: uint? } +--- @return LuaPlayer? +local function get_online_player(event) + if not event.player_index then return nil end + local player = game.players[event.player_index] + if not player or not player.valid or not player.connected then return nil end + return player +end + +--- @param event EventData.on_player_left_game +add(defines.events.on_player_left_game, function(event) + local player = game.players[event.player_index] + ExpPlayerStatistics.send_and_forget(player.name) +end) + +--- Count a minute of afk time for every player who has been afk long enough +local function count_afk_time() + if game.tick == 0 then return end + for _, player in pairs(game.connected_players) do + if player.afk_time > afk_required then increment(player, "AfkTime") end + end +end + + +--- @param event EventData.on_player_created +add(defines.events.on_player_created, function(event) + increment(game.players[event.player_index], "MapsPlayed") +end) + +--- @param event EventData.on_player_changed_position +add(defines.events.on_player_changed_position, function(event) + local player = get_online_player(event) + if not player or player.afk_time > afk_required then return end + increment(player, "DistanceTravelled") +end) + +--- @param event EventData.on_marked_for_deconstruction | EventData.on_player_mined_entity +local function on_removed(event) + local player = get_online_player(event) + if not player then return end + local entity = event.entity + if not entity.valid then return end + if entity.type == "resource" then + increment(player, "OreMined") + elseif entity.type == "tree" then + increment(player, "TreesDestroyed") + elseif entity.force == player.force then + increment(player, "MachinesRemoved") + end +end +add(defines.events.on_marked_for_deconstruction, on_removed) +add(defines.events.on_player_mined_entity, on_removed) + +--- Vehicles which credit their driver, or failing that their passenger, with what they hit +local crewed_vehicles = { car = true, ["spider-vehicle"] = true, locomotive = true } + +--- Get the connected player behind the cause of damage or a death, nil for turrets, trains without a crew and the like +--- @param cause LuaEntity? +--- @return LuaPlayer? +local function get_cause_player(cause) + if not cause or not cause.valid then return nil end + + local occupant = cause --- @type LuaEntity | LuaPlayer + if crewed_vehicles[cause.type] then + local crew = cause.get_driver() or cause.get_passenger() + if not crew then return nil end + occupant = crew + end + + local player --- @type LuaPlayer? + if occupant.object_name == "LuaPlayer" then + player = occupant --[[@as LuaPlayer]] + elseif occupant.type == "character" then + player = occupant.player + end + + if not player or not player.valid or not player.connected then return nil end + return player +end + +--- Check that damage or a death counts against an entity, it must be hostile to the player +--- @param entity LuaEntity +--- @param player LuaPlayer +--- @return boolean +local function is_hostile(entity, player) + return entity.valid and entity.force ~= player.force and entity.force.name ~= "neutral" +end + +--- @param event EventData.on_entity_damaged +add(defines.events.on_entity_damaged, function(event) + local player = get_cause_player(event.cause) + if not player or not is_hostile(event.entity, player) then return end + increment(player, "DamageDealt", floor(event.final_damage_amount)) +end) + +--- @param event EventData.on_entity_died +add(defines.events.on_entity_died, function(event) + local player = get_cause_player(event.cause) + if not player or not is_hostile(event.entity, player) then return end + increment(player, "Kills") +end) + +--- Increment a statistic for every online player on a force +--- @param force LuaForce? +--- @param statistic string +local function increment_force(force, statistic) + if not force or not force.valid then return end + for _, player in pairs(force.connected_players) do + increment(player, statistic) + end +end + +--- @param event EventData.on_rocket_launched +add(defines.events.on_rocket_launched, function(event) + local silo = event.rocket_silo + if not silo or not silo.valid then return end + increment_force(silo.force --[[@as LuaForce]], "RocketsLaunched") +end) + +--- @param event EventData.on_research_finished +add(defines.events.on_research_finished, function(event) + local research = event.research + if event.by_script or not research or not research.valid then return end + increment_force(research.force, "ResearchCompleted") +end) + +for statistic, event_id in pairs(config.counters) do + add(event_id, function(event) + local player = get_online_player(event) + if player then increment(player, statistic) end + end) +end + +return { + events = events, + on_nth_tick = { + [3600] = count_afk_time, + [send_interval] = ExpPlayerStatistics.send_all, + }, +} diff --git a/exp_player_statistics/module/globals.lua b/exp_player_statistics/module/globals.lua new file mode 100644 index 00000000..d7f0c83d --- /dev/null +++ b/exp_player_statistics/module/globals.lua @@ -0,0 +1,4 @@ +--- @diagnostic disable: global-in-non-module + +-- Access using `/sc exp_player_statistics.foo()` +exp_player_statistics = require("modules/exp_player_statistics/control") diff --git a/exp_player_statistics/module/locale/en.cfg b/exp_player_statistics/module/locale/en.cfg new file mode 100644 index 00000000..bf31f0b8 --- /dev/null +++ b/exp_player_statistics/module/locale/en.cfg @@ -0,0 +1,67 @@ +[exp-statistics] +MapsPlayed=Maps Played +MapsPlayed-tooltip=The number of unique maps you have played on +JoinCount=Join Count +JoinCount-tooltip=The number of times you have joined our servers +Playtime=Playtime +Playtime-tooltip=The amount of time you have spent on our servers +AfkTime=AFK Time +AfkTime-tooltip=The amount of time you have been AFK on our servers +ChatMessages=Messages +ChatMessages-tooltip=The number of messages you have sent in chat +CommandsUsed=Commands +CommandsUsed-tooltip=The number of commands you have used +RocketsLaunched=Rockets Launched +RocketsLaunched-tooltip=The number of rockets launched while you were online +ResearchCompleted=Research Completed +ResearchCompleted-tooltip=The number of research projects completed while you were online +MachinesBuilt=Machines Built +MachinesBuilt-tooltip=The number of machines you have built +MachinesRemoved=Machines Removed +MachinesRemoved-tooltip=The number of machines you have removed +TilesBuilt=Tiles Placed +TilesBuilt-tooltip=The number of tiles you have placed +TilesRemoved=Tiles Removed +TilesRemoved-tooltip=The number of tiles you have removed +TreesDestroyed=Trees Destroyed +TreesDestroyed-tooltip=The number of trees you have destroyed +OreMined=Ore Mined +OreMined-tooltip=The amount of ore you have mined +ItemsCrafted=Items Crafted +ItemsCrafted-tooltip=The number of items you have crafted +ItemsPickedUp=Items Picked Up +ItemsPickedUp-tooltip=The number of items you have picked up +Kills=Kills +Kills-tooltip=The number of biters and biter bases you have squished +Deaths=Deaths +Deaths-tooltip=The number of times you have died +DamageDealt=Damage Dealt +DamageDealt-tooltip=The amount of damage you have dealt to other forces +DistanceTravelled=Distance Travelled +DistanceTravelled-tooltip=The total distance in tiles that you have travelled +CapsulesUsed=Capsules Used +CapsulesUsed-tooltip=The number of capsules you have used +EntityRepaired=Machines Repaired +EntityRepaired-tooltip=The number of machines which you have repaired +DeconstructionPlannerUsed=Decon Planner Used +DeconstructionPlannerUsed-tooltip=The number of times you have used the deconstruction planner +MapTagsMade=Map Tags Created +MapTagsMade-tooltip=The number of map tags you have created +DamageDeathRatio=Damage Death Ratio +DamageDeathRatio-tooltip=Damage over Death +KillDeathRatio=Kill Death Ratio +KillDeathRatio-tooltip=Kill over Death +SessionTime=Session Time +SessionTime-tooltip=Session Time +BuildRatio=Build Ratio +BuildRatio-tooltip=Build over Remove +RocketPerHour=Rocket Per Hour +RocketPerHour-tooltip=Rocket Launched Over Play Time in Hour +TreeKillPerMinute=Tree Kill Per Minute +TreeKillPerMinute-tooltip=Tree Destroyed Per Minute +NetPlayTime=Net Play Time +NetPlayTime-tooltip=Net Play Time +AFKTimeRatio=AFK Time Ratio +AFKTimeRatio-tooltip=AFK Time over Play Time +Locale=Locale +Locale-tooltip=The language of player interface diff --git a/exp_player_statistics/module/locale/zh-CN.cfg b/exp_player_statistics/module/locale/zh-CN.cfg new file mode 100644 index 00000000..4b021ef8 --- /dev/null +++ b/exp_player_statistics/module/locale/zh-CN.cfg @@ -0,0 +1,67 @@ +[exp-statistics] +MapsPlayed=地圖遊玩次數 +MapsPlayed-tooltip=你在本服務器遊玩過的地圖數 +JoinCount=登入次數 +JoinCount-tooltip=你在本服務器遊玩的次數 +Playtime=遊玩時間 +Playtime-tooltip=你在本服務器遊玩的時間 +AfkTime=掛機時間 +AfkTime-tooltip=你在本服務器掛機的時間 +ChatMessages=傳送信息次數 +ChatMessages-tooltip=你在本服務器發送信息的次數 +CommandsUsed=使用指令次數 +CommandsUsed-tooltip=你在本服務器使用指令的次數 +RocketsLaunched=火箭發射次數 +RocketsLaunched-tooltip=你在本服務器在線時所發射過火箭的次數 +ResearchCompleted=研究次數 +ResearchCompleted-tooltip=你在本服務器在線時完成研究的次數 +MachinesBuilt=機器建造次數 +MachinesBuilt-tooltip=你在本服務器建造過機器的次數 +MachinesRemoved=機器拆除次數 +MachinesRemoved-tooltip=你在本服務器拆除過機器的次數 +TilesBuilt=地磚建造次數 +TilesBuilt-tooltip=你在本服務器建造過地磚的次數 +TilesRemoved=地磚拆除次數 +TilesRemoved-tooltip=你在本服務器拆除過地磚的次數 +TreesDestroyed=樹木拆除次數 +TreesDestroyed-tooltip=你在本服務器拆除過樹木的次數 +OreMined=挖礦次數 +OreMined-tooltip=你在本服務器挖掘礦的次數 +ItemsCrafted=物品合成數 +ItemsCrafted-tooltip=你在本服務器合成過物品的次數 +ItemsPickedUp=物品拾起數 +ItemsPickedUp-tooltip=你在本服務器起過物品的次數 +Kills=撃殺數 +Kills-tooltip=你在本服務器殺過的敵人數量 +Deaths=死亡次數 +Deaths-tooltip=你在本服務器死過的數量 +DamageDealt=傷害量 +DamageDealt-tooltip=你在本伺服器對敵人所造成的傷害量 +DistanceTravelled=移動距離 +DistanceTravelled-tooltip=你在本服務器的總移動距離 +CapsulesUsed=膠囊使用次數 +CapsulesUsed-tooltip=你在本服務器使用過膠囊的次數 +EntityRepaired=機器維修次數 +EntityRepaired-tooltip=你在本服務器維修過機器的次數 +DeconstructionPlannerUsed=拆除規劃器使用次數 +DeconstructionPlannerUsed-tooltip=你在本服務器使用過拆除規劃器的次數 +MapTagsMade=地圖標籤數量 +MapTagsMade-tooltip=你在本服務器放過的地圖標籤數量 +DamageDeathRatio=傷害死亡比 +DamageDeathRatio-tooltip=傷害除以死亡 +KillDeathRatio=撃殺死亡比 +KillDeathRatio-tooltip=撃殺除以死亡 +SessionTime=平均遊玩時間 +SessionTime-tooltip=平均遊玩時間 +BuildRatio=建築比 +BuildRatio-tooltip=建築次數除以拆除次數 +RocketPerHour=每小時火箭發射次數 +RocketPerHour-tooltip=火箭發射次數除以遊玩時間 +TreeKillPerMinute=每分鐘樹木拆除數 +TreeKillPerMinute-tooltip=樹木拆除數除以遊玩時間 +NetPlayTime=淨遊玩時間 +NetPlayTime-tooltip=遊玩時間減掛機時間 +AFKTimeRatio=掛機時間比 +AFKTimeRatio-tooltip=掛機時間除以遊玩時間 +Locale=語言 +Locale-tooltip=用戶介面語言 diff --git a/exp_player_statistics/module/locale/zh-TW.cfg b/exp_player_statistics/module/locale/zh-TW.cfg new file mode 100644 index 00000000..4b021ef8 --- /dev/null +++ b/exp_player_statistics/module/locale/zh-TW.cfg @@ -0,0 +1,67 @@ +[exp-statistics] +MapsPlayed=地圖遊玩次數 +MapsPlayed-tooltip=你在本服務器遊玩過的地圖數 +JoinCount=登入次數 +JoinCount-tooltip=你在本服務器遊玩的次數 +Playtime=遊玩時間 +Playtime-tooltip=你在本服務器遊玩的時間 +AfkTime=掛機時間 +AfkTime-tooltip=你在本服務器掛機的時間 +ChatMessages=傳送信息次數 +ChatMessages-tooltip=你在本服務器發送信息的次數 +CommandsUsed=使用指令次數 +CommandsUsed-tooltip=你在本服務器使用指令的次數 +RocketsLaunched=火箭發射次數 +RocketsLaunched-tooltip=你在本服務器在線時所發射過火箭的次數 +ResearchCompleted=研究次數 +ResearchCompleted-tooltip=你在本服務器在線時完成研究的次數 +MachinesBuilt=機器建造次數 +MachinesBuilt-tooltip=你在本服務器建造過機器的次數 +MachinesRemoved=機器拆除次數 +MachinesRemoved-tooltip=你在本服務器拆除過機器的次數 +TilesBuilt=地磚建造次數 +TilesBuilt-tooltip=你在本服務器建造過地磚的次數 +TilesRemoved=地磚拆除次數 +TilesRemoved-tooltip=你在本服務器拆除過地磚的次數 +TreesDestroyed=樹木拆除次數 +TreesDestroyed-tooltip=你在本服務器拆除過樹木的次數 +OreMined=挖礦次數 +OreMined-tooltip=你在本服務器挖掘礦的次數 +ItemsCrafted=物品合成數 +ItemsCrafted-tooltip=你在本服務器合成過物品的次數 +ItemsPickedUp=物品拾起數 +ItemsPickedUp-tooltip=你在本服務器起過物品的次數 +Kills=撃殺數 +Kills-tooltip=你在本服務器殺過的敵人數量 +Deaths=死亡次數 +Deaths-tooltip=你在本服務器死過的數量 +DamageDealt=傷害量 +DamageDealt-tooltip=你在本伺服器對敵人所造成的傷害量 +DistanceTravelled=移動距離 +DistanceTravelled-tooltip=你在本服務器的總移動距離 +CapsulesUsed=膠囊使用次數 +CapsulesUsed-tooltip=你在本服務器使用過膠囊的次數 +EntityRepaired=機器維修次數 +EntityRepaired-tooltip=你在本服務器維修過機器的次數 +DeconstructionPlannerUsed=拆除規劃器使用次數 +DeconstructionPlannerUsed-tooltip=你在本服務器使用過拆除規劃器的次數 +MapTagsMade=地圖標籤數量 +MapTagsMade-tooltip=你在本服務器放過的地圖標籤數量 +DamageDeathRatio=傷害死亡比 +DamageDeathRatio-tooltip=傷害除以死亡 +KillDeathRatio=撃殺死亡比 +KillDeathRatio-tooltip=撃殺除以死亡 +SessionTime=平均遊玩時間 +SessionTime-tooltip=平均遊玩時間 +BuildRatio=建築比 +BuildRatio-tooltip=建築次數除以拆除次數 +RocketPerHour=每小時火箭發射次數 +RocketPerHour-tooltip=火箭發射次數除以遊玩時間 +TreeKillPerMinute=每分鐘樹木拆除數 +TreeKillPerMinute-tooltip=樹木拆除數除以遊玩時間 +NetPlayTime=淨遊玩時間 +NetPlayTime-tooltip=遊玩時間減掛機時間 +AFKTimeRatio=掛機時間比 +AFKTimeRatio-tooltip=掛機時間除以遊玩時間 +Locale=語言 +Locale-tooltip=用戶介面語言 diff --git a/exp_player_statistics/module/module.json b/exp_player_statistics/module/module.json new file mode 100644 index 00000000..31cbe2c8 --- /dev/null +++ b/exp_player_statistics/module/module.json @@ -0,0 +1,13 @@ +{ + "name": "exp_player_statistics", + "load": [ + "events.lua" + ], + "require": [ + "globals.lua" + ], + "dependencies": { + "clusterio": "*", + "exp_util": "*" + } +} diff --git a/exp_player_statistics/module/module_exports.lua b/exp_player_statistics/module/module_exports.lua new file mode 100644 index 00000000..7d20698c --- /dev/null +++ b/exp_player_statistics/module/module_exports.lua @@ -0,0 +1,2 @@ +-- Access the exports from other modules using require("modules/exp_player_statistics") +return require("modules/exp_player_statistics/control") diff --git a/exp_player_statistics/package.json b/exp_player_statistics/package.json new file mode 100644 index 00000000..37691e50 --- /dev/null +++ b/exp_player_statistics/package.json @@ -0,0 +1,61 @@ +{ + "name": "@expcluster/player-statistics", + "version": "7.0.1", + "description": "Clusterio plugin keeping player statistics on the controller", + "author": "Cooldude2606 ", + "license": "MIT", + "repository": "explosivegaming/ExpCluster", + "type": "module", + "main": "dist/node/index.js", + "scripts": { + "prepare": "tsc --build && webpack-cli --env production", + "test": "tap --type-strip-only --disable-coverage --allow-empty-coverage test/*.test.js", + "coverage": "tap --coverage-report=text test/*.test.js" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", + "@clusterio/lib": "workspace:^" + }, + "peerDependenciesMeta": { + "@clusterio/controller": { + "optional": true + }, + "@clusterio/host": { + "optional": true + } + }, + "devDependencies": { + "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", + "@clusterio/lib": "workspace:^", + "@clusterio/web_ui": "workspace:^", + "@types/node": "catalog:", + "@types/react": "catalog:", + "antd": "catalog:", + "fengari": "^0.1.5", + "react": "catalog:", + "react-dom": "catalog:", + "react-router-dom": "catalog:", + "tap": "^21.1.0", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" + }, + "dependencies": { + "@sinclair/typebox": "catalog:", + "@expcluster/lib_util": "workspace:^" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "clusterio", + "clusterio-plugin", + "factorio" + ] +} diff --git a/exp_player_statistics/test/controller.test.js b/exp_player_statistics/test/controller.test.js new file mode 100644 index 00000000..a990133a --- /dev/null +++ b/exp_player_statistics/test/controller.test.js @@ -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); +}); diff --git a/exp_player_statistics/test/instance.test.js b/exp_player_statistics/test/instance.test.js new file mode 100644 index 00000000..e7742994 --- /dev/null +++ b/exp_player_statistics/test/instance.test.js @@ -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(); +}); diff --git a/exp_player_statistics/test/module.test.js b/exp_player_statistics/test/module.test.js new file mode 100644 index 00000000..1c81fdc1 --- /dev/null +++ b/exp_player_statistics/test/module.test.js @@ -0,0 +1,13 @@ +import path from "node:path"; +import t from "tap"; +import { reportLuaTests } from "../../test/lua/runner.js"; + +const envFile = path.join(import.meta.dirname, "module", "env.lua"); + +// Each file runs in its own lua state, and each test in a fresh environment +t.test("lua module", t2 => { + for (const file of ["control.lua", "events.lua"]) { + t2.test(file, t3 => reportLuaTests(t3, envFile, path.join(import.meta.dirname, "module", file))); + } + t2.end(); +}); diff --git a/exp_player_statistics/test/module/control.lua b/exp_player_statistics/test/module/control.lua new file mode 100644 index 00000000..e874abbe --- /dev/null +++ b/exp_player_statistics/test/module/control.lua @@ -0,0 +1,120 @@ +local Suite = ... --- @type Suite + +Suite.test("increment() adds to the unsent statistics", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + Stats.increment(alice, "Kills") + Stats.increment(alice, "Kills", 4) + Suite.eq(Stats.get(alice, "Kills"), 5, "by one, then by the amount") + Suite.eq(Stats.get("alice", "Kills"), 5, "by player name") + Suite.eq(Stats.get(alice, "Deaths"), 0, "missing statistics are zero") +end) + +Suite.test("send_all() sends every player with changes in one update", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local bob = env.add_player{ name = "bob" } + env.add_player{ name = "carol" } + Stats.increment(alice, "Kills", 2) + Stats.increment(bob, "Deaths") + Stats.send_all() + + Suite.eq(#env.sent, 1, "one update") + Suite.eq(assert(env.sent[1]).channel, "exp_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() diff --git a/exp_player_statistics/test/module/env.lua b/exp_player_statistics/test/module/env.lua new file mode 100644 index 00000000..03d8e104 --- /dev/null +++ b/exp_player_statistics/test/module/env.lua @@ -0,0 +1,101 @@ +--[[-- Test environment for the statistics module +Extends the shared stubs with fresh copies of module/control.lua and +module/events.lua, stubs of the exp_util functions they use, and players with +the fields the trackers read. + +Run as a chunk by test/lua/runner.js, receiving the shared folder. The suite +returned here becomes `...` in each test file. +]] + +local shared_root = ... --- @type string +local source = assert(debug.getinfo(1, "S")).source:gsub("\\", "/") +local plugin_root = assert(source:match("^@(.*)/test/module/env%.lua$")) + +local Framework = assert(loadfile(shared_root .. "/framework.lua"))() --- @type Framework + +--- Every event the module listens to, ids start after the shared stubs +local event_names = { + "on_player_left_game", "on_player_created", "on_player_changed_position", + "on_marked_for_deconstruction", "on_player_mined_entity", "on_entity_damaged", + "on_entity_died", "on_rocket_launched", "on_research_finished", + "on_built_entity", "on_chart_tag_added", "on_console_chat", "on_console_command", + "on_picked_up_item", "on_player_built_tile", "on_player_crafted_item", + "on_player_deconstructed_area", "on_player_died", "on_player_mined_tile", + "on_player_used_capsule", "on_player_repaired_entity", +} + +--- @class ExpPlayerStatistics.TestEnv : Stubs +--- @field Statistics ExpPlayerStatistics A fresh copy of the statistics module +--- @field handlers { events: table, on_nth_tick: table } From events.lua +--- @field force fun(name: string): table A force stub with the online players added to it +--- @field raise fun(name: string, event: table) Call the handler of an event by its name +--- @field add_player fun(params: 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]])[name] + local handler = env.handlers.events[id] + assert(handler, "no handler for " .. name) + handler(event) + end + + return env +end) diff --git a/exp_player_statistics/test/module/events.lua b/exp_player_statistics/test/module/events.lua new file mode 100644 index 00000000..9638e852 --- /dev/null +++ b/exp_player_statistics/test/module/events.lua @@ -0,0 +1,133 @@ +local Suite = ... --- @type Suite + +Suite.test("counters go up by one for online players", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local bob = env.add_player{ name = "bob", connected = false } + env.raise("on_console_chat", { player_index = alice.index }) + env.raise("on_console_chat", { player_index = alice.index }) + env.raise("on_player_died", { player_index = alice.index }) + env.raise("on_console_chat", { player_index = bob.index }) + env.raise("on_console_chat", {}) + + Suite.eq(Stats.get(alice, "ChatMessages"), 2, "chat") + Suite.eq(Stats.get(alice, "Deaths"), 1, "deaths") + Suite.eq(Stats.get(bob, "ChatMessages"), 0, "offline players are skipped") +end) + +Suite.test("a player is counted once per map", function(env) + local alice = env.add_player{ name = "alice" } + env.raise("on_player_created", { player_index = alice.index }) + Suite.eq(env.Statistics.get(alice, "MapsPlayed"), 1, "maps played") +end) + +Suite.test("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() diff --git a/exp_player_statistics/tsconfig.browser.json b/exp_player_statistics/tsconfig.browser.json new file mode 100644 index 00000000..a621b69f --- /dev/null +++ b/exp_player_statistics/tsconfig.browser.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.browser.json", + "include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ], +} diff --git a/exp_player_statistics/tsconfig.json b/exp_player_statistics/tsconfig.json new file mode 100644 index 00000000..303b8f28 --- /dev/null +++ b/exp_player_statistics/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.browser.json" } + ] +} diff --git a/exp_player_statistics/tsconfig.node.json b/exp_player_statistics/tsconfig.node.json new file mode 100644 index 00000000..3218f2e7 --- /dev/null +++ b/exp_player_statistics/tsconfig.node.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./**/*.ts"], + "exclude": ["test/*", "./dist/*"], +} diff --git a/exp_player_statistics/web/components/StatisticsPage.tsx b/exp_player_statistics/web/components/StatisticsPage.tsx new file mode 100644 index 00000000..9bcb2532 --- /dev/null +++ b/exp_player_statistics/web/components/StatisticsPage.tsx @@ -0,0 +1,114 @@ +import React, { useMemo, useState } from "react"; +import { Button, Checkbox, Divider, Select, Space, Table } from "antd"; +import type { ColumnsType } from "antd/es/table"; + +import { PageHeader, PageLayout, useColumnSearch, useTableQueryState, useUsers } from "@clusterio/web_ui"; + +import { PlayerStatistics, userStatistics } from "../../messages.js"; +import { usePlayerStatistics } from ".."; +import { formatValue, statistics } from "../statistics"; + +const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; + +const storageKey = "exp_player_statistics.columns"; +const defaultColumns = [ + "Playtime", "AfkTime", "MapsPlayed", "JoinCount", "ChatMessages", + "RocketsLaunched", "ResearchCompleted", "MachinesBuilt", "Kills", "Deaths", +]; + +function loadColumns() { + try { + const stored = JSON.parse(localStorage.getItem(storageKey) ?? "null"); + if (Array.isArray(stored)) { + return stored.filter(name => statistics.some(([statistic]) => statistic === name)); + } + } catch { /* fall back to the defaults */ } + return defaultColumns; +} + +export default function StatisticsPage() { + const [players, synced] = usePlayerStatistics(); + const [users] = useUsers(); + const [columns, setColumnsState] = useState(loadColumns); + const tableState = useTableQueryState({ + namespace: "statistics", + defaultSortKey: "Playtime", + defaultSortOrder: "descend", + pagination: { defaultPageSize: 50 }, + }); + const playerSearch = useColumnSearch(tableState, "player", player => player.id, "Search players"); + + function setColumns(names: string[]) { + // Keep the display order whatever order they were picked in + const ordered = statistics.map(([name]) => name).filter(name => names.includes(name)); + setColumnsState(ordered); + localStorage.setItem(storageKey, JSON.stringify(ordered)); + } + + const rows = useMemo(() => [...players.values()].map(player => new PlayerStatistics( + player.id, + { ...player.stats, ...userStatistics(users.get(player.id.toLowerCase())) }, + player.updatedAtMs, + )), [players, users]); + + const tableColumns: ColumnsType = [ + { + title: "Player", + key: "player", + fixed: "left", + render: (_, player) => player.id, + sorter: (a, b) => strcmp(a.id, b.id), + sortOrder: tableState.sortOrder("player"), + filteredValue: tableState.filteredValue("player"), + ...playerSearch, + }, + ...statistics.filter(([name]) => columns.includes(name)).map( + ([name, title]): ColumnsType[number] => ({ + title, + key: name, + align: "right", + render: (_, player) => formatValue(name, player.stats[name] ?? 0), + sorter: (a, b) => (a.stats[name] ?? 0) - (b.stats[name] ?? 0), + sortDirections: ["descend", "ascend"], + sortOrder: tableState.sortOrder(name), + }) + ), + ]; + + const columnSelect =