From d1b22dd4e7c2e9f9917fcb8d18b0eef5444e7427 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:49:30 +0000 Subject: [PATCH 1/5] Add exp_statistics plugin for player statistics Player statistics move out of exp_legacy into their own plugin, with the totals kept on the controller instead of the PlayerData datastore, which was never synced anywhere. The lua module tracks the same statistics with the same rules. Gains are kept per player as unsent in storage and sent over IPC every 5 minutes for every player with changes, and for one player when they leave. Sent values move into the totals the controller gave when the player joined, so get() is the total on every server either way. Nothing is resent. The instance plugin sends everything left when the server stops and drops anything unsent when it starts. A leftover can only come from a save made before it was sent, such as the autosave loaded after a crash, so dropping it loses a few minutes at worst but never counts twice. The controller adds the deltas to a record per player name in database/exp_statistics/players.json. Instances fetch a player's totals on join and hand them to lua over rcon. The web UI gets a sortable table of every player at /exp_statistics, behind exp_statistics.view. Co-Authored-By: Claude Opus 5.5 --- exp_statistics/controller.ts | 60 ++++++ exp_statistics/index.ts | 38 ++++ exp_statistics/instance.ts | 59 ++++++ exp_statistics/messages.ts | 152 ++++++++++++++ exp_statistics/module/config.lua | 50 +++++ exp_statistics/module/control.lua | 161 +++++++++++++++ exp_statistics/module/events.lua | 188 ++++++++++++++++++ exp_statistics/module/globals.lua | 4 + exp_statistics/module/locale/en.cfg | 67 +++++++ exp_statistics/module/locale/zh-CN.cfg | 67 +++++++ exp_statistics/module/locale/zh-TW.cfg | 67 +++++++ exp_statistics/module/module.json | 13 ++ exp_statistics/module/module_exports.lua | 2 + exp_statistics/package.json | 61 ++++++ exp_statistics/test/controller.test.js | 145 ++++++++++++++ exp_statistics/test/instance.test.js | 132 ++++++++++++ exp_statistics/test/module.test.js | 13 ++ exp_statistics/test/module/control.lua | 120 +++++++++++ exp_statistics/test/module/env.lua | 101 ++++++++++ exp_statistics/test/module/events.lua | 133 +++++++++++++ exp_statistics/tsconfig.browser.json | 4 + exp_statistics/tsconfig.json | 7 + exp_statistics/tsconfig.node.json | 5 + .../web/components/StatisticsPage.tsx | 95 +++++++++ exp_statistics/web/index.tsx | 27 +++ exp_statistics/webpack.config.cjs | 34 ++++ tsconfig.json | 1 + 27 files changed, 1806 insertions(+) create mode 100644 exp_statistics/controller.ts create mode 100644 exp_statistics/index.ts create mode 100644 exp_statistics/instance.ts create mode 100644 exp_statistics/messages.ts create mode 100644 exp_statistics/module/config.lua create mode 100644 exp_statistics/module/control.lua create mode 100644 exp_statistics/module/events.lua create mode 100644 exp_statistics/module/globals.lua create mode 100644 exp_statistics/module/locale/en.cfg create mode 100644 exp_statistics/module/locale/zh-CN.cfg create mode 100644 exp_statistics/module/locale/zh-TW.cfg create mode 100644 exp_statistics/module/module.json create mode 100644 exp_statistics/module/module_exports.lua create mode 100644 exp_statistics/package.json create mode 100644 exp_statistics/test/controller.test.js create mode 100644 exp_statistics/test/instance.test.js create mode 100644 exp_statistics/test/module.test.js create mode 100644 exp_statistics/test/module/control.lua create mode 100644 exp_statistics/test/module/env.lua create mode 100644 exp_statistics/test/module/events.lua create mode 100644 exp_statistics/tsconfig.browser.json create mode 100644 exp_statistics/tsconfig.json create mode 100644 exp_statistics/tsconfig.node.json create mode 100644 exp_statistics/web/components/StatisticsPage.tsx create mode 100644 exp_statistics/web/index.tsx create mode 100644 exp_statistics/webpack.config.cjs diff --git a/exp_statistics/controller.ts b/exp_statistics/controller.ts new file mode 100644 index 00000000..99a4e83c --- /dev/null +++ b/exp_statistics/controller.ts @@ -0,0 +1,60 @@ +import type { ControllerPluginContext } from "@clusterio/controller"; +import * as lib from "@clusterio/lib"; +import * as messages from "./messages.js"; +import * as path from "node:path"; + +/** Keeps the statistics totals of every player, instances send what was gained since their last update. */ +export default async function (context: ControllerPluginContext) { + const { controller, plugin } = context; + const databaseDirectory = controller.config.get("controller.database_directory"); + const players = new lib.SubscribableDatastore( + ...await new lib.JsonIdDatastoreProvider( + path.join(databaseDirectory, "exp_statistics", "players.json"), + messages.PlayerStatistics.fromJSON.bind(messages.PlayerStatistics), + ).bootstrap() + ); + + function addDeltas(deltas: messages.StatisticsDelta[]) { + const changed = new Map(); + for (const delta of deltas) { + const record = changed.get(delta.name) + ?? players.getMutable(delta.name) + ?? new messages.PlayerStatistics(delta.name); + for (const [name, value] of Object.entries(delta.stats)) { + if (Number.isFinite(value) && value !== 0) { + record.stats[name] = (record.stats[name] ?? 0) + value; + } + } + changed.set(record.id, record); + } + if (changed.size) { + players.setMany([...changed.values()]); + } + } + + players.on("update", updates => { + controller.subscriptions.broadcast(new messages.PlayerStatisticsUpdatedEvent(updates)); + }); + + controller.subscriptions.handle(messages.PlayerStatisticsUpdatedEvent, async request => { + const updates = [...players.values()].filter(player => player.updatedAtMs > request.lastRequestTimeMs); + return updates.length ? new messages.PlayerStatisticsUpdatedEvent(updates) : null; + }); + + controller.handle(messages.StatisticsUpdateEvent, async (event: messages.StatisticsUpdateEvent) => { + addDeltas(event.deltas); + }); + + controller.handle(messages.StatisticsGetRequest, async (request: messages.StatisticsGetRequest) => ( + players.get(request.name) ?? new messages.PlayerStatistics(request.name) + )); + + controller.handle(messages.StatisticsListRequest, async () => [...players.values()]); + + controller.hooks.save.attach(plugin.name, async () => { + await players.save(); + }); + controller.hooks.shutdown.attach(plugin.name, async () => { + await players.save(); + }); +} diff --git a/exp_statistics/index.ts b/exp_statistics/index.ts new file mode 100644 index 00000000..6b9ec3eb --- /dev/null +++ b/exp_statistics/index.ts @@ -0,0 +1,38 @@ +import * as lib from "@clusterio/lib"; +import * as messages from "./messages.js"; + +declare module "@clusterio/lib" { + export interface Permissions { + "exp_statistics.view": never; + } +} + +export const plugin: lib.PluginDeclaration = { + name: "exp_statistics", + title: "ExpGaming - Statistics", + description: "Clusterio plugin keeping player statistics on the controller", + + controllerEntrypoint: "./dist/node/controller.js", + instanceEntrypoint: "./dist/node/instance.js", + + messages: [ + messages.StatisticsUpdateEvent, + messages.PlayerStatisticsUpdatedEvent, + messages.StatisticsGetRequest, + messages.StatisticsListRequest, + ], + + permissions: [ + { + name: "exp_statistics.view", + title: "View player statistics", + description: "View the statistics of every player and subscribe to their updates", + grantByDefault: true, + }, + ], + + webEntrypoint: "./web", + routes: [ + "/exp_statistics", + ], +}; diff --git a/exp_statistics/instance.ts b/exp_statistics/instance.ts new file mode 100644 index 00000000..bd285bb8 --- /dev/null +++ b/exp_statistics/instance.ts @@ -0,0 +1,59 @@ +import type { InstancePluginContext } from "@clusterio/host"; +import * as messages from "./messages.js"; + +/** Sent by the lua side with what each player gained since the last update. */ +export type IpcStatisticsUpdate = { + players: { name: string, stats: Record }[], +}; + +/** + * Forwards the statistics gained in game to the controller, and hands the + * totals of a player to lua when they join, see module/control.lua. + */ +export default async function (context: InstancePluginContext) { + const { instance, plugin, logger } = context; + + async function luaCall(code: string) { + await instance.sendRcon(`/sc ${code}`, true); + } + + async function luaSendTotals(record: messages.PlayerStatistics) { + const json = JSON.stringify({ name: record.id, stats: record.stats }); + // Pick a long bracket the json does not contain + let level = "="; + while (json.includes(`]${level}]`)) { + level += "="; + } + await luaCall(`exp_statistics.receive_totals(helpers.json_to_table[${level}[${json}]${level}])`); + } + + instance.server.handle("exp_statistics:update", async (event: IpcStatisticsUpdate) => { + instance.sendTo("controller", new messages.StatisticsUpdateEvent( + event.players.map(player => new messages.StatisticsDelta(player.name, player.stats)), + )); + }); + + instance.hooks.playerEvent.attach(plugin.name, async event => { + if (event.type !== "join") { + return; + } + try { + const record = await instance.sendTo("controller", new messages.StatisticsGetRequest(event.name)); + if (instance.status === "running") { + await luaSendTotals(record); + } + } catch (err: any) { + logger.warn(`Failed to send the statistics of ${event.name}: ${err.message}`); + } + }); + + // Anything still unsent at start came from a save made before the last + // update was sent, so it is dropped rather than counted twice + instance.hooks.start.attach(plugin.name, async () => { + await luaCall("exp_statistics.discard_unsent()"); + }); + + instance.hooks.stop.attach(plugin.name, async () => { + await luaCall("exp_statistics.send_all()"); + }); +} diff --git a/exp_statistics/messages.ts b/exp_statistics/messages.ts new file mode 100644 index 00000000..b7be099f --- /dev/null +++ b/exp_statistics/messages.ts @@ -0,0 +1,152 @@ +import * as lib from "@clusterio/lib"; +import { Type, Static } from "@sinclair/typebox"; + +const StatsSchema = Type.Record(Type.String(), Type.Number()); + +/** Totals for one player across every instance, keyed by player name. */ +export class PlayerStatistics { + constructor( + public id: string, + /** Statistic name to total, missing statistics are zero */ + public stats: Record = {}, + public updatedAtMs: number = 0, + public isDeleted: boolean = false, + ) {} + + static jsonSchema = Type.Object({ + id: Type.String(), + stats: StatsSchema, + updated_at_ms: Type.Optional(Type.Number()), + is_deleted: Type.Optional(Type.Boolean()), + }); + + toJSON() { + const json: Static = { + id: this.id, + stats: this.stats, + }; + + if (this.updatedAtMs) { + json.updated_at_ms = this.updatedAtMs; + } + + if (this.isDeleted) { + json.is_deleted = true; + } + + return json; + } + + static fromJSON(json: Static) { + return new this(json.id, { ...json.stats }, json.updated_at_ms ?? 0, json.is_deleted ?? false); + } +} + +/** Statistics gained by one player since the last update */ +export class StatisticsDelta { + constructor( + public name: string, + public stats: Record, + ) {} + + static jsonSchema = Type.Object({ + name: Type.String(), + stats: StatsSchema, + }); + + toJSON() { + return { name: this.name, stats: this.stats }; + } + + static fromJSON(json: Static) { + return new this(json.name, json.stats); + } +} + +/** Sent by an instance to add to the totals of its players. */ +export class StatisticsUpdateEvent { + declare ["constructor"]: typeof StatisticsUpdateEvent; + static plugin = "exp_statistics" as const; + static type = "event" as const; + static src = "instance" as const; + static dst = "controller" as const; + static permission = null; + + constructor( + public deltas: StatisticsDelta[], + ) {} + + static jsonSchema = Type.Object({ + deltas: Type.Array(StatisticsDelta.jsonSchema), + }); + + toJSON() { + return { deltas: this.deltas.map(delta => delta.toJSON()) }; + } + + static fromJSON(json: Static) { + return new this(json.deltas.map(delta => StatisticsDelta.fromJSON(delta))); + } +} + +export class PlayerStatisticsUpdatedEvent { + declare ["constructor"]: typeof PlayerStatisticsUpdatedEvent; + static plugin = "exp_statistics" as const; + static type = "event" as const; + static src = "controller" as const; + static dst = "control" as const; + static permission = "exp_statistics.view" as const; + + constructor( + public updates: PlayerStatistics[], + ) {} + + static jsonSchema = Type.Object({ + updates: Type.Array(PlayerStatistics.jsonSchema), + }); + + toJSON() { + return { updates: this.updates.map(player => player.toJSON()) }; + } + + static fromJSON(json: Static) { + return new this(json.updates.map(player => PlayerStatistics.fromJSON(player))); + } +} + +/** Totals for one player, empty when they have none. */ +export class StatisticsGetRequest { + declare ["constructor"]: typeof StatisticsGetRequest; + static plugin = "exp_statistics" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = "exp_statistics.view" as const; + static Response = PlayerStatistics; + + constructor( + public name: string, + ) {} + + static jsonSchema = Type.Object({ + name: Type.String(), + }); + + toJSON() { + return { name: this.name }; + } + + static fromJSON(json: Static) { + return new this(json.name); + } +} + +export class StatisticsListRequest { + declare ["constructor"]: typeof StatisticsListRequest; + static plugin = "exp_statistics" as const; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = "exp_statistics.view" as const; + static Response = lib.jsonArray(PlayerStatistics); +} diff --git a/exp_statistics/module/config.lua b/exp_statistics/module/config.lua new file mode 100644 index 00000000..381a73e2 --- /dev/null +++ b/exp_statistics/module/config.lua @@ -0,0 +1,50 @@ +--[[-- ExpStatistics - Config +Which statistics are tracked, and the order they are shown in. +]] + +local e = defines.events +return { + MapsPlayed = true, -- Maps a player has joined at least once + Playtime = true, -- Minutes online + AfkTime = true, -- Minutes online after doing nothing for 5 minutes + DistanceTravelled = true, -- Tiles moved while not afk + MachinesRemoved = true, -- Own force entities marked for deconstruction or mined + TreesDestroyed = true, -- Trees marked for deconstruction or mined + OreMined = true, -- Resources mined by hand + DamageDealt = true, -- Damage to entities not on the same force or neutral + Kills = true, -- Entities killed which are not on the same force or neutral + RocketsLaunched = true, -- Counted for every online player on the force + ResearchCompleted = true, -- Counted for every online player on the force + --- Statistics that go up by one each time the event is raised for a player + --- @type table + counters = { + MachinesBuilt = e.on_built_entity, + MapTagsMade = e.on_chart_tag_added, + ChatMessages = e.on_console_chat, + CommandsUsed = e.on_console_command, + ItemsPickedUp = e.on_picked_up_item, + TilesBuilt = e.on_player_built_tile, + ItemsCrafted = e.on_player_crafted_item, + DeconstructionPlannerUsed = e.on_player_deconstructed_area, + Deaths = e.on_player_died, + JoinCount = e.on_player_joined_game, + TilesRemoved = e.on_player_mined_tile, + CapsulesUsed = e.on_player_used_capsule, + EntityRepaired = e.on_player_repaired_entity, + }, + --- The order statistics are shown in, disabled ones are skipped + display_order = { + "Playtime", "AfkTime", + "MapsPlayed", "JoinCount", + "ChatMessages", "CommandsUsed", + "RocketsLaunched", "ResearchCompleted", + "MachinesBuilt", "MachinesRemoved", + "TilesBuilt", "TilesRemoved", + "TreesDestroyed", "OreMined", + "ItemsCrafted", "ItemsPickedUp", + "Kills", "Deaths", + "DamageDealt", "DistanceTravelled", + "CapsulesUsed", "EntityRepaired", + "DeconstructionPlannerUsed", "MapTagsMade", + }, +} diff --git a/exp_statistics/module/control.lua b/exp_statistics/module/control.lua new file mode 100644 index 00000000..6d3b000d --- /dev/null +++ b/exp_statistics/module/control.lua @@ -0,0 +1,161 @@ +--[[-- ExpStatistics +Player statistics kept on the controller. + +What each player gains is kept here as unsent until it is sent to the +controller, every 5 minutes and when the player leaves. Once sent it is added +to the totals the controller gave when the player joined, so get() is the +total across every server either way. + +Updates which are lost are not sent again. Anything unsent when the server +starts is from a save made before it was sent, so it is dropped rather than +risk counting it twice. + +local ExpStatistics = require("modules/exp_statistics") + +ExpStatistics.increment(player, "Kills") +ExpStatistics.get(player, "Kills") +for _, name in ipairs(ExpStatistics.display_order) do end +]] + +local clusterio_api = require("modules/clusterio/api") +local ExpUtil = require("modules/exp_util") +local Storage = require("modules/exp_util/storage") +local config = require("modules/exp_statistics/config") + +local format_number = require("util").format_number + +--- @alias ExpStatistics.Stats table + +--- @class ExpStatistics.Totals +--- @field name string +--- @field stats ExpStatistics.Stats + +--- @class ExpStatistics +local ExpStatistics = { + config = config, + --- Enabled statistics in the order they are shown + --- @type string[] + display_order = {}, + --- Shown after the value + --- @type table + units = { DistanceTravelled = " tiles" }, +} + +--- Measured in minutes and shown as a time +local time_statistics = { Playtime = true, AfkTime = true } +local long_time_format = ExpUtil.format_time_factory_locale{ format = "long", coefficient = 3600, hours = true, minutes = true } +local short_time_format = ExpUtil.format_time_factory_locale{ format = "short", coefficient = 3600, hours = true, minutes = true } + +for _, name in ipairs(config.display_order) do + if config[name] == true or config.counters[name] then + ExpStatistics.display_order[#ExpStatistics.display_order + 1] = name + end +end + +local unsent = {} --- @type table +local totals = {} --- @type table +Storage.register({ unsent, totals }, function(tbl) + unsent = tbl[1] + totals = tbl[2] +end) + +--- Add to a statistic of a player +--- @param player LuaPlayer +--- @param statistic string +--- @param amount number? Defaults to 1 +function ExpStatistics.increment(player, statistic, amount) + local name = player.name + local stats = unsent[name] + if not stats then + stats = {} + unsent[name] = stats + end + stats[statistic] = (stats[statistic] or 0) + (amount or 1) +end + +--- Get the total of a statistic for a player, on every server +--- @param player LuaPlayer | string +--- @param statistic string +--- @return number +function ExpStatistics.get(player, statistic) + local name = type(player) == "string" and player or player.name + local received = totals[name] + local pending = unsent[name] + return (received and received[statistic] or 0) + (pending and pending[statistic] or 0) +end + +--- Get the value of a statistic as it is shown to players +--- @param statistic string +--- @param value number +--- @param short boolean? Use the short time format +--- @return LocalisedString +function ExpStatistics.format(statistic, value, short) + if time_statistics[statistic] then + return short and short_time_format(value) or long_time_format(value) + end + return format_number(value, false) +end + +--- Move the unsent statistics of a player into their totals +--- @param name string +--- @return ExpStatistics.Stats? +local function take_unsent(name) + local stats = unsent[name] + if not stats then return nil end + unsent[name] = nil + + local received = totals[name] + if not received then + received = {} + totals[name] = received + end + for statistic, value in pairs(stats) do + received[statistic] = (received[statistic] or 0) + value + end + return stats +end + +--- Send what a player gained to the controller +--- @param name string +function ExpStatistics.send(name) + local stats = take_unsent(name) + if stats then + clusterio_api.send_json("exp_statistics:update", { players = { { name = name, stats = stats } } }) + end +end + +--- Send what every player gained to the controller, in one update +function ExpStatistics.send_all() + local players, count = {}, 0 + for name in pairs(unsent) do + count = count + 1 + players[count] = { name = name, stats = take_unsent(name) } + end + if count > 0 then + clusterio_api.send_json("exp_statistics:update", { players = players }) + end +end + +--- Send what a player gained and forget them, called when they leave +--- @param name string +function ExpStatistics.send_and_forget(name) + ExpStatistics.send(name) + totals[name] = nil +end + +--- Called by the instance plugin over rcon when the server starts +function ExpStatistics.discard_unsent() + for name in pairs(unsent) do + unsent[name] = nil + end +end + +--- Called by the instance plugin over rcon when a player joins +--- @param payload ExpStatistics.Totals +function ExpStatistics.receive_totals(payload) + local player = game.get_player(payload.name) + if not player or not player.connected then return end + totals[payload.name] = payload.stats +end + +return ExpStatistics diff --git a/exp_statistics/module/events.lua b/exp_statistics/module/events.lua new file mode 100644 index 00000000..6262d5ad --- /dev/null +++ b/exp_statistics/module/events.lua @@ -0,0 +1,188 @@ +--[[-- ExpStatistics - Events +Tracks the statistics enabled in config.lua, and sends them to the controller. +]] + +local ExpStatistics = require("modules/exp_statistics/control") +local config = ExpStatistics.config + +local increment = ExpStatistics.increment +local floor = math.floor +local afk_required = 5 * 3600 -- 5 minutes +local send_interval = 5 * 3600 -- 5 minutes + +--- @type table +local events = {} + +--- Add a handler, chained after any other for the same event +--- @param event_id defines.events +--- @param handler fun(event: EventData) +local function add(event_id, handler) + local existing = events[event_id] + if existing then + events[event_id] = function(event) + existing(event) + handler(event) + end + else + events[event_id] = handler + end +end + +--- Get the player of an event if they are online +--- @param event { player_index: uint? } +--- @return LuaPlayer? +local function get_online_player(event) + if not event.player_index then return nil end + local player = game.players[event.player_index] + if not player or not player.valid or not player.connected then return nil end + return player +end + +--- @param event EventData.on_player_left_game +add(defines.events.on_player_left_game, function(event) + local player = game.players[event.player_index] + ExpStatistics.send_and_forget(player.name) +end) + +--- @type table +local on_nth_tick = { + [send_interval] = ExpStatistics.send_all, +} + +if config.MapsPlayed then + --- @param event EventData.on_player_created + add(defines.events.on_player_created, function(event) + increment(game.players[event.player_index], "MapsPlayed") + end) +end + +if config.Playtime or config.AfkTime then + on_nth_tick[3600] = function() + if game.tick == 0 then return end + for _, player in pairs(game.connected_players) do + if config.Playtime then increment(player, "Playtime") end + if config.AfkTime and player.afk_time > afk_required then increment(player, "AfkTime") end + end + end +end + +if config.DistanceTravelled then + --- @param event EventData.on_player_changed_position + add(defines.events.on_player_changed_position, function(event) + local player = get_online_player(event) + if not player or player.afk_time > afk_required then return end + increment(player, "DistanceTravelled") + end) +end + +if config.MachinesRemoved or config.TreesDestroyed or config.OreMined then + --- @param event EventData.on_marked_for_deconstruction | EventData.on_player_mined_entity + local function on_removed(event) + local player = get_online_player(event) + if not player then return end + local entity = event.entity + if not entity.valid then return end + if entity.type == "resource" then + if config.OreMined then increment(player, "OreMined") end + elseif entity.type == "tree" then + if config.TreesDestroyed then increment(player, "TreesDestroyed") end + elseif entity.force == player.force then + if config.MachinesRemoved then increment(player, "MachinesRemoved") end + end + end + add(defines.events.on_marked_for_deconstruction, on_removed) + add(defines.events.on_player_mined_entity, on_removed) +end + +--- Vehicles which credit their driver, or failing that their passenger, with what they hit +local crewed_vehicles = { car = true, ["spider-vehicle"] = true, locomotive = true } + +--- Get the connected player behind the cause of damage or a death, nil for turrets, trains without a crew and the like +--- @param cause LuaEntity? +--- @return LuaPlayer? +local function get_cause_player(cause) + if not cause or not cause.valid then return nil end + + local occupant = cause --- @type LuaEntity | LuaPlayer + if crewed_vehicles[cause.type] then + local crew = cause.get_driver() or cause.get_passenger() + if not crew then return nil end + occupant = crew + end + + local player --- @type LuaPlayer? + if occupant.object_name == "LuaPlayer" then + player = occupant --[[@as LuaPlayer]] + elseif occupant.type == "character" then + player = occupant.player + end + + if not player or not player.valid or not player.connected then return nil end + return player +end + +--- Check that damage or a death counts against an entity, it must be hostile to the player +--- @param entity LuaEntity +--- @param player LuaPlayer +--- @return boolean +local function is_hostile(entity, player) + return entity.valid and entity.force ~= player.force and entity.force.name ~= "neutral" +end + +if config.DamageDealt then + --- @param event EventData.on_entity_damaged + add(defines.events.on_entity_damaged, function(event) + local player = get_cause_player(event.cause) + if not player or not is_hostile(event.entity, player) then return end + increment(player, "DamageDealt", floor(event.final_damage_amount)) + end) +end + +if config.Kills then + --- @param event EventData.on_entity_died + add(defines.events.on_entity_died, function(event) + local player = get_cause_player(event.cause) + if not player or not is_hostile(event.entity, player) then return end + increment(player, "Kills") + end) +end + +--- Increment a statistic for every online player on a force +--- @param force LuaForce? +--- @param statistic string +local function increment_force(force, statistic) + if not force or not force.valid then return end + for _, player in pairs(force.connected_players) do + increment(player, statistic) + end +end + +if config.RocketsLaunched then + --- @param event EventData.on_rocket_launched + add(defines.events.on_rocket_launched, function(event) + local silo = event.rocket_silo + if not silo or not silo.valid then return end + increment_force(silo.force --[[@as LuaForce]], "RocketsLaunched") + end) +end + +if config.ResearchCompleted then + --- @param event EventData.on_research_finished + add(defines.events.on_research_finished, function(event) + local research = event.research + if event.by_script or not research or not research.valid then return end + increment_force(research.force, "ResearchCompleted") + end) +end + +for statistic, event_id in pairs(config.counters) do + add(event_id, function(event) + local player = get_online_player(event) + if player then increment(player, statistic) end + end) +end + +return { + events = events, + on_nth_tick = on_nth_tick, +} diff --git a/exp_statistics/module/globals.lua b/exp_statistics/module/globals.lua new file mode 100644 index 00000000..b470f0bd --- /dev/null +++ b/exp_statistics/module/globals.lua @@ -0,0 +1,4 @@ +--- @diagnostic disable: global-in-non-module + +-- Access using `/sc exp_statistics.foo()` +exp_statistics = require("modules/exp_statistics/control") diff --git a/exp_statistics/module/locale/en.cfg b/exp_statistics/module/locale/en.cfg new file mode 100644 index 00000000..bf31f0b8 --- /dev/null +++ b/exp_statistics/module/locale/en.cfg @@ -0,0 +1,67 @@ +[exp-statistics] +MapsPlayed=Maps Played +MapsPlayed-tooltip=The number of unique maps you have played on +JoinCount=Join Count +JoinCount-tooltip=The number of times you have joined our servers +Playtime=Playtime +Playtime-tooltip=The amount of time you have spent on our servers +AfkTime=AFK Time +AfkTime-tooltip=The amount of time you have been AFK on our servers +ChatMessages=Messages +ChatMessages-tooltip=The number of messages you have sent in chat +CommandsUsed=Commands +CommandsUsed-tooltip=The number of commands you have used +RocketsLaunched=Rockets Launched +RocketsLaunched-tooltip=The number of rockets launched while you were online +ResearchCompleted=Research Completed +ResearchCompleted-tooltip=The number of research projects completed while you were online +MachinesBuilt=Machines Built +MachinesBuilt-tooltip=The number of machines you have built +MachinesRemoved=Machines Removed +MachinesRemoved-tooltip=The number of machines you have removed +TilesBuilt=Tiles Placed +TilesBuilt-tooltip=The number of tiles you have placed +TilesRemoved=Tiles Removed +TilesRemoved-tooltip=The number of tiles you have removed +TreesDestroyed=Trees Destroyed +TreesDestroyed-tooltip=The number of trees you have destroyed +OreMined=Ore Mined +OreMined-tooltip=The amount of ore you have mined +ItemsCrafted=Items Crafted +ItemsCrafted-tooltip=The number of items you have crafted +ItemsPickedUp=Items Picked Up +ItemsPickedUp-tooltip=The number of items you have picked up +Kills=Kills +Kills-tooltip=The number of biters and biter bases you have squished +Deaths=Deaths +Deaths-tooltip=The number of times you have died +DamageDealt=Damage Dealt +DamageDealt-tooltip=The amount of damage you have dealt to other forces +DistanceTravelled=Distance Travelled +DistanceTravelled-tooltip=The total distance in tiles that you have travelled +CapsulesUsed=Capsules Used +CapsulesUsed-tooltip=The number of capsules you have used +EntityRepaired=Machines Repaired +EntityRepaired-tooltip=The number of machines which you have repaired +DeconstructionPlannerUsed=Decon Planner Used +DeconstructionPlannerUsed-tooltip=The number of times you have used the deconstruction planner +MapTagsMade=Map Tags Created +MapTagsMade-tooltip=The number of map tags you have created +DamageDeathRatio=Damage Death Ratio +DamageDeathRatio-tooltip=Damage over Death +KillDeathRatio=Kill Death Ratio +KillDeathRatio-tooltip=Kill over Death +SessionTime=Session Time +SessionTime-tooltip=Session Time +BuildRatio=Build Ratio +BuildRatio-tooltip=Build over Remove +RocketPerHour=Rocket Per Hour +RocketPerHour-tooltip=Rocket Launched Over Play Time in Hour +TreeKillPerMinute=Tree Kill Per Minute +TreeKillPerMinute-tooltip=Tree Destroyed Per Minute +NetPlayTime=Net Play Time +NetPlayTime-tooltip=Net Play Time +AFKTimeRatio=AFK Time Ratio +AFKTimeRatio-tooltip=AFK Time over Play Time +Locale=Locale +Locale-tooltip=The language of player interface diff --git a/exp_statistics/module/locale/zh-CN.cfg b/exp_statistics/module/locale/zh-CN.cfg new file mode 100644 index 00000000..4b021ef8 --- /dev/null +++ b/exp_statistics/module/locale/zh-CN.cfg @@ -0,0 +1,67 @@ +[exp-statistics] +MapsPlayed=地圖遊玩次數 +MapsPlayed-tooltip=你在本服務器遊玩過的地圖數 +JoinCount=登入次數 +JoinCount-tooltip=你在本服務器遊玩的次數 +Playtime=遊玩時間 +Playtime-tooltip=你在本服務器遊玩的時間 +AfkTime=掛機時間 +AfkTime-tooltip=你在本服務器掛機的時間 +ChatMessages=傳送信息次數 +ChatMessages-tooltip=你在本服務器發送信息的次數 +CommandsUsed=使用指令次數 +CommandsUsed-tooltip=你在本服務器使用指令的次數 +RocketsLaunched=火箭發射次數 +RocketsLaunched-tooltip=你在本服務器在線時所發射過火箭的次數 +ResearchCompleted=研究次數 +ResearchCompleted-tooltip=你在本服務器在線時完成研究的次數 +MachinesBuilt=機器建造次數 +MachinesBuilt-tooltip=你在本服務器建造過機器的次數 +MachinesRemoved=機器拆除次數 +MachinesRemoved-tooltip=你在本服務器拆除過機器的次數 +TilesBuilt=地磚建造次數 +TilesBuilt-tooltip=你在本服務器建造過地磚的次數 +TilesRemoved=地磚拆除次數 +TilesRemoved-tooltip=你在本服務器拆除過地磚的次數 +TreesDestroyed=樹木拆除次數 +TreesDestroyed-tooltip=你在本服務器拆除過樹木的次數 +OreMined=挖礦次數 +OreMined-tooltip=你在本服務器挖掘礦的次數 +ItemsCrafted=物品合成數 +ItemsCrafted-tooltip=你在本服務器合成過物品的次數 +ItemsPickedUp=物品拾起數 +ItemsPickedUp-tooltip=你在本服務器起過物品的次數 +Kills=撃殺數 +Kills-tooltip=你在本服務器殺過的敵人數量 +Deaths=死亡次數 +Deaths-tooltip=你在本服務器死過的數量 +DamageDealt=傷害量 +DamageDealt-tooltip=你在本伺服器對敵人所造成的傷害量 +DistanceTravelled=移動距離 +DistanceTravelled-tooltip=你在本服務器的總移動距離 +CapsulesUsed=膠囊使用次數 +CapsulesUsed-tooltip=你在本服務器使用過膠囊的次數 +EntityRepaired=機器維修次數 +EntityRepaired-tooltip=你在本服務器維修過機器的次數 +DeconstructionPlannerUsed=拆除規劃器使用次數 +DeconstructionPlannerUsed-tooltip=你在本服務器使用過拆除規劃器的次數 +MapTagsMade=地圖標籤數量 +MapTagsMade-tooltip=你在本服務器放過的地圖標籤數量 +DamageDeathRatio=傷害死亡比 +DamageDeathRatio-tooltip=傷害除以死亡 +KillDeathRatio=撃殺死亡比 +KillDeathRatio-tooltip=撃殺除以死亡 +SessionTime=平均遊玩時間 +SessionTime-tooltip=平均遊玩時間 +BuildRatio=建築比 +BuildRatio-tooltip=建築次數除以拆除次數 +RocketPerHour=每小時火箭發射次數 +RocketPerHour-tooltip=火箭發射次數除以遊玩時間 +TreeKillPerMinute=每分鐘樹木拆除數 +TreeKillPerMinute-tooltip=樹木拆除數除以遊玩時間 +NetPlayTime=淨遊玩時間 +NetPlayTime-tooltip=遊玩時間減掛機時間 +AFKTimeRatio=掛機時間比 +AFKTimeRatio-tooltip=掛機時間除以遊玩時間 +Locale=語言 +Locale-tooltip=用戶介面語言 diff --git a/exp_statistics/module/locale/zh-TW.cfg b/exp_statistics/module/locale/zh-TW.cfg new file mode 100644 index 00000000..4b021ef8 --- /dev/null +++ b/exp_statistics/module/locale/zh-TW.cfg @@ -0,0 +1,67 @@ +[exp-statistics] +MapsPlayed=地圖遊玩次數 +MapsPlayed-tooltip=你在本服務器遊玩過的地圖數 +JoinCount=登入次數 +JoinCount-tooltip=你在本服務器遊玩的次數 +Playtime=遊玩時間 +Playtime-tooltip=你在本服務器遊玩的時間 +AfkTime=掛機時間 +AfkTime-tooltip=你在本服務器掛機的時間 +ChatMessages=傳送信息次數 +ChatMessages-tooltip=你在本服務器發送信息的次數 +CommandsUsed=使用指令次數 +CommandsUsed-tooltip=你在本服務器使用指令的次數 +RocketsLaunched=火箭發射次數 +RocketsLaunched-tooltip=你在本服務器在線時所發射過火箭的次數 +ResearchCompleted=研究次數 +ResearchCompleted-tooltip=你在本服務器在線時完成研究的次數 +MachinesBuilt=機器建造次數 +MachinesBuilt-tooltip=你在本服務器建造過機器的次數 +MachinesRemoved=機器拆除次數 +MachinesRemoved-tooltip=你在本服務器拆除過機器的次數 +TilesBuilt=地磚建造次數 +TilesBuilt-tooltip=你在本服務器建造過地磚的次數 +TilesRemoved=地磚拆除次數 +TilesRemoved-tooltip=你在本服務器拆除過地磚的次數 +TreesDestroyed=樹木拆除次數 +TreesDestroyed-tooltip=你在本服務器拆除過樹木的次數 +OreMined=挖礦次數 +OreMined-tooltip=你在本服務器挖掘礦的次數 +ItemsCrafted=物品合成數 +ItemsCrafted-tooltip=你在本服務器合成過物品的次數 +ItemsPickedUp=物品拾起數 +ItemsPickedUp-tooltip=你在本服務器起過物品的次數 +Kills=撃殺數 +Kills-tooltip=你在本服務器殺過的敵人數量 +Deaths=死亡次數 +Deaths-tooltip=你在本服務器死過的數量 +DamageDealt=傷害量 +DamageDealt-tooltip=你在本伺服器對敵人所造成的傷害量 +DistanceTravelled=移動距離 +DistanceTravelled-tooltip=你在本服務器的總移動距離 +CapsulesUsed=膠囊使用次數 +CapsulesUsed-tooltip=你在本服務器使用過膠囊的次數 +EntityRepaired=機器維修次數 +EntityRepaired-tooltip=你在本服務器維修過機器的次數 +DeconstructionPlannerUsed=拆除規劃器使用次數 +DeconstructionPlannerUsed-tooltip=你在本服務器使用過拆除規劃器的次數 +MapTagsMade=地圖標籤數量 +MapTagsMade-tooltip=你在本服務器放過的地圖標籤數量 +DamageDeathRatio=傷害死亡比 +DamageDeathRatio-tooltip=傷害除以死亡 +KillDeathRatio=撃殺死亡比 +KillDeathRatio-tooltip=撃殺除以死亡 +SessionTime=平均遊玩時間 +SessionTime-tooltip=平均遊玩時間 +BuildRatio=建築比 +BuildRatio-tooltip=建築次數除以拆除次數 +RocketPerHour=每小時火箭發射次數 +RocketPerHour-tooltip=火箭發射次數除以遊玩時間 +TreeKillPerMinute=每分鐘樹木拆除數 +TreeKillPerMinute-tooltip=樹木拆除數除以遊玩時間 +NetPlayTime=淨遊玩時間 +NetPlayTime-tooltip=遊玩時間減掛機時間 +AFKTimeRatio=掛機時間比 +AFKTimeRatio-tooltip=掛機時間除以遊玩時間 +Locale=語言 +Locale-tooltip=用戶介面語言 diff --git a/exp_statistics/module/module.json b/exp_statistics/module/module.json new file mode 100644 index 00000000..be0eb2e2 --- /dev/null +++ b/exp_statistics/module/module.json @@ -0,0 +1,13 @@ +{ + "name": "exp_statistics", + "load": [ + "events.lua" + ], + "require": [ + "globals.lua" + ], + "dependencies": { + "clusterio": "*", + "exp_util": "*" + } +} diff --git a/exp_statistics/module/module_exports.lua b/exp_statistics/module/module_exports.lua new file mode 100644 index 00000000..f5ab723b --- /dev/null +++ b/exp_statistics/module/module_exports.lua @@ -0,0 +1,2 @@ +-- Access the exports from other modules using require("modules/exp_statistics") +return require("modules/exp_statistics/control") diff --git a/exp_statistics/package.json b/exp_statistics/package.json new file mode 100644 index 00000000..988ff0fa --- /dev/null +++ b/exp_statistics/package.json @@ -0,0 +1,61 @@ +{ + "name": "@expcluster/statistics", + "version": "7.0.1", + "description": "Clusterio plugin keeping player statistics on the controller", + "author": "Cooldude2606 ", + "license": "MIT", + "repository": "explosivegaming/ExpCluster", + "type": "module", + "main": "dist/node/index.js", + "scripts": { + "prepare": "tsc --build && webpack-cli --env production", + "test": "tap --type-strip-only --disable-coverage --allow-empty-coverage test/*.test.js", + "coverage": "tap --coverage-report=text test/*.test.js" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", + "@clusterio/lib": "workspace:^" + }, + "peerDependenciesMeta": { + "@clusterio/controller": { + "optional": true + }, + "@clusterio/host": { + "optional": true + } + }, + "devDependencies": { + "@clusterio/controller": "workspace:^", + "@clusterio/host": "workspace:^", + "@clusterio/lib": "workspace:^", + "@clusterio/web_ui": "workspace:^", + "@types/node": "catalog:", + "@types/react": "catalog:", + "antd": "catalog:", + "fengari": "^0.1.5", + "react": "catalog:", + "react-dom": "catalog:", + "react-router-dom": "catalog:", + "tap": "^21.1.0", + "typescript": "catalog:", + "webpack": "catalog:", + "webpack-cli": "catalog:", + "webpack-merge": "catalog:" + }, + "dependencies": { + "@sinclair/typebox": "catalog:", + "@expcluster/lib_util": "workspace:^" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "clusterio", + "clusterio-plugin", + "factorio" + ] +} diff --git a/exp_statistics/test/controller.test.js b/exp_statistics/test/controller.test.js new file mode 100644 index 00000000..e077fa38 --- /dev/null +++ b/exp_statistics/test/controller.test.js @@ -0,0 +1,145 @@ +import t from "tap"; +import * as lib from "@clusterio/lib"; +import { Controller } from "@clusterio/controller"; +import entrypoint from "../dist/node/controller.js"; +import * as messages from "../dist/node/messages.js"; +import { plugin as pluginDeclaration } from "../dist/node/index.js"; + +// The controller validates message classes against the link registry +for (const Message of pluginDeclaration.messages) { + lib.Link.register(Message); +} + +const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} }; + +// Silence the singleton logger to avoid polluting the test output +lib.logger.silent = true; +t.after(() => { + lib.logger.silent = false; +}); + +const fromInstance = lib.Address.fromShorthand({ instanceId: 1 }); + +/** Run the plugin on a real controller, which is side effect free while not started, returns its handlers. */ +async function startPlugin(t2, databaseDirectory = t2.testdir()) { + const controllerConfig = new lib.ControllerConfig("controller", { + "controller.database_directory": databaseDirectory, + }); + const controller = new Controller(logger, [], controllerConfig); + + const state = { broadcasts: [] }; + controller.subscriptions.broadcast = event => { + state.broadcasts.push(event); + }; + + const handlers = new Map(); + const handle = controller.handle.bind(controller); + controller.handle = (Class, handler) => { + handlers.set(Class, handler); + handle(Class, handler); + }; + const subscriptionHandle = controller.subscriptions.handle.bind(controller.subscriptions); + controller.subscriptions.handle = (Class, handler) => { + handlers.set(Class, handler); + subscriptionHandle(Class, handler); + }; + + await entrypoint({ plugin: { name: "exp_statistics" }, controller, logger }); + const delta = (name, stats) => new messages.StatisticsDelta(name, stats); + return { + controller, + state, + update: (...deltas) => handlers.get(messages.StatisticsUpdateEvent)( + new messages.StatisticsUpdateEvent(deltas.map(([name, stats]) => delta(name, stats))), fromInstance, + ), + get: name => handlers.get(messages.StatisticsGetRequest)(new messages.StatisticsGetRequest(name), fromInstance), + list: () => handlers.get(messages.StatisticsListRequest)(new messages.StatisticsListRequest(), fromInstance), + subscription: lastRequestTimeMs => handlers.get(messages.PlayerStatisticsUpdatedEvent)({ lastRequestTimeMs }), + }; +} + +t.test("entrypoint attaches to save and shutdown", async t2 => { + const { controller } = await startPlugin(t2); + for (const hook of ["save", "shutdown"]) { + t2.ok([...controller.hooks[hook].attached].includes("exp_statistics"), `attached to ${hook}`); + } +}); + +t.test("controller plugin", t2 => { + t2.test("StatisticsUpdateEvent adds the deltas to the totals", async t3 => { + const { update, get } = await startPlugin(t3); + await update(["alice", { Kills: 2, Playtime: 5 }], ["bob", { Deaths: 1 }]); + await update(["alice", { Kills: 3, Deaths: 1 }]); + + t3.strictSame((await get("alice")).stats, { Kills: 5, Playtime: 5, Deaths: 1 }, "deltas are summed per stat"); + t3.strictSame((await get("bob")).stats, { Deaths: 1 }); + }); + + t2.test("StatisticsUpdateEvent merges deltas for the same player in one update", async t3 => { + const { update, get, state } = await startPlugin(t3); + await update(["alice", { Kills: 1 }], ["alice", { Kills: 2 }]); + + t3.strictSame((await get("alice")).stats, { Kills: 3 }); + t3.equal(state.broadcasts.length, 1, "one broadcast"); + t3.strictSame(state.broadcasts[0].updates.map(player => player.id), ["alice"], "with the player once"); + }); + + t2.test("StatisticsUpdateEvent ignores values which are not finite or zero", async t3 => { + const { update, get } = await startPlugin(t3); + await update(["alice", { Kills: 1, Deaths: 0, Bad: Infinity, Worse: NaN }]); + t3.strictSame((await get("alice")).stats, { Kills: 1 }); + }); + + t2.test("StatisticsGetRequest gives an empty record for an unknown player", async t3 => { + const { get, list } = await startPlugin(t3); + const record = await get("carol"); + t3.equal(record.id, "carol"); + t3.strictSame(record.stats, {}); + t3.strictSame(await list(), [], "nothing is stored"); + }); + + t2.test("StatisticsListRequest lists every player", async t3 => { + const { update, list } = await startPlugin(t3); + await update(["alice", { Kills: 1 }], ["bob", { Kills: 2 }]); + t3.strictSame((await list()).map(player => player.id).sort(), ["alice", "bob"]); + }); + + t2.test("subscribing replays only newer records", async t3 => { + const { update, subscription } = await startPlugin(t3); + await update(["alice", { Kills: 1 }]); + const all = await subscription(0); + t3.strictSame(all.updates.map(player => player.id), ["alice"]); + t3.equal(await subscription(all.updates[0].updatedAtMs), null, "nothing newer"); + }); + + t2.test("totals are saved and loaded", async t3 => { + const directory = t3.testdir(); + const first = await startPlugin(t3, directory); + await first.update(["alice", { Kills: 4 }]); + await first.controller.hooks.save.invoke(); + + const second = await startPlugin(t3, directory); + t3.strictSame((await second.get("alice")).stats, { Kills: 4 }); + }); + + t2.end(); +}); + +t.test("permissions", async t2 => { + const names = pluginDeclaration.permissions.map(permission => permission.name); + t2.strictSame(names, ["exp_statistics.view"]); + for (const Message of [ + messages.StatisticsGetRequest, messages.StatisticsListRequest, messages.PlayerStatisticsUpdatedEvent, + ]) { + t2.equal(Message.permission, "exp_statistics.view", `${Message.name} needs view`); + } + t2.equal(messages.StatisticsUpdateEvent.permission, null, "updates only come from instances"); + t2.strictSame(messages.StatisticsUpdateEvent.src, "instance"); +}); + +t.test("messages round trip through json", async t2 => { + const record = new messages.PlayerStatistics("alice", { Kills: 1 }, 10); + t2.strictSame(messages.PlayerStatistics.fromJSON(JSON.parse(JSON.stringify(record))), record); + const event = new messages.StatisticsUpdateEvent([new messages.StatisticsDelta("bob", { Deaths: 2 })]); + t2.strictSame(messages.StatisticsUpdateEvent.fromJSON(JSON.parse(JSON.stringify(event))), event); +}); diff --git a/exp_statistics/test/instance.test.js b/exp_statistics/test/instance.test.js new file mode 100644 index 00000000..a9bc86e6 --- /dev/null +++ b/exp_statistics/test/instance.test.js @@ -0,0 +1,132 @@ +import t from "tap"; +import * as lib from "@clusterio/lib"; +import { Instance } from "@clusterio/host"; +import entrypoint from "../dist/node/instance.js"; +import { plugin as pluginDeclaration } from "../dist/node/index.js"; +import * as messages from "../dist/node/messages.js"; + +// The instance validates message classes against the link registry +for (const Message of pluginDeclaration.messages) { + lib.Link.register(Message); +} + +const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} }; + +class TestConnector extends lib.BaseConnector { + constructor() { + super(lib.Address.fromShorthand({ instanceId: 1 }), lib.Address.fromShorthand({ hostId: 1 })); + this.valid = true; + this.connected = true; + this.hasSession = true; + } + + send() {} +} + +/** Run the plugin on a real instance with spies on what leaves it, returns the IPC handlers it registered. */ +async function startPlugin(t2, { status = "running", stats = { Kills: 3 } } = {}) { + const instanceConfig = new lib.InstanceConfig("host"); + instanceConfig.set("instance.id", 1); + instanceConfig.set("instance.name", "test"); + + const instance = new Instance( + { assignGamePort: () => 1 }, new TestConnector(), t2.testdir(), "factorioDir", instanceConfig + ); + + const state = { sent: [], rcons: [], warnings: [] }; + const ipc = new Map(); + instance.server = { + handle: (name, handler) => ipc.set(name, handler), + }; + instance.sendRcon = async command => { + state.rcons.push(command); + return ""; + }; + instance.sendTo = async (dst, message) => { + state.sent.push(message); + if (message instanceof messages.StatisticsGetRequest) { + return new messages.PlayerStatistics(message.name, stats); + } + return undefined; + }; + + instance.notifyStatus(status); + state.sent.length = 0; + + const pluginLogger = { ...logger, warn: message => state.warnings.push(message) }; + await entrypoint({ plugin: { name: "exp_statistics" }, instance, host: {}, logger: pluginLogger }); + return { instance, state, updateIPC: event => ipc.get("exp_statistics:update")(event) }; +} + +/** The payload of a recorded receive_totals command. */ +function decodeTotals(command) { + const match = command.match(/^\/sc exp_statistics\.receive_totals\(helpers\.json_to_table\[(=+)\[(.*)\]\1\]\)$/s); + return JSON.parse(match[2]); +} + +t.test("instance plugin", t2 => { + t2.test("exp_statistics:update is forwarded to the controller", async t3 => { + const { state, updateIPC } = await startPlugin(t3); + await updateIPC({ players: [{ name: "alice", stats: { Kills: 1 } }, { name: "bob", stats: { Deaths: 2 } }] }); + + t3.equal(state.sent.length, 1, "one event"); + const event = state.sent[0]; + t3.ok(event instanceof messages.StatisticsUpdateEvent); + t3.strictSame(event.deltas.map(delta => [delta.name, delta.stats]), [ + ["alice", { Kills: 1 }], + ["bob", { Deaths: 2 }], + ]); + }); + + t2.test("a player joining is sent their totals", async t3 => { + const { instance, state } = await startPlugin(t3); + await instance.hooks.playerEvent.invoke({ type: "join", name: "alice" }); + + const request = state.sent[0]; + t3.ok(request instanceof messages.StatisticsGetRequest); + t3.equal(request.name, "alice"); + t3.equal(state.rcons.length, 1); + t3.strictSame(decodeTotals(state.rcons[0]), { name: "alice", stats: { Kills: 3 } }); + }); + + t2.test("the long bracket is not in the json", async t3 => { + const { instance, state } = await startPlugin(t3, { stats: { "]=]": 1 } }); + await instance.hooks.playerEvent.invoke({ type: "join", name: "alice" }); + t3.ok(state.rcons[0].includes("[==["), "a longer bracket is used"); + t3.strictSame(decodeTotals(state.rcons[0]).stats, { "]=]": 1 }); + }); + + t2.test("leaving and other player events send nothing", async t3 => { + const { instance, state } = await startPlugin(t3); + await instance.hooks.playerEvent.invoke({ type: "leave", name: "alice", reason: "quit" }); + await instance.hooks.playerEvent.invoke({ type: "promote", name: "alice" }); + t3.strictSame(state.sent, []); + t3.strictSame(state.rcons, []); + }); + + t2.test("totals are not sent once the instance is no longer running", async t3 => { + const { instance, state } = await startPlugin(t3, { status: "stopping" }); + await instance.hooks.playerEvent.invoke({ type: "join", name: "alice" }); + t3.strictSame(state.rcons, []); + }); + + t2.test("a failed request is logged", async t3 => { + const { instance, state } = await startPlugin(t3); + instance.sendTo = async () => { throw new Error("controller gone"); }; + await instance.hooks.playerEvent.invoke({ type: "join", name: "alice" }); + t3.strictSame(state.rcons, []); + t3.match(state.warnings[0], /alice: controller gone/); + }); + + t2.test("start drops what was unsent and stop sends the rest", async t3 => { + const { instance, state } = await startPlugin(t3); + await instance.hooks.start.invoke(); + await instance.hooks.stop.invoke(); + t3.strictSame(state.rcons, [ + "/sc exp_statistics.discard_unsent()", + "/sc exp_statistics.send_all()", + ]); + }); + + t2.end(); +}); diff --git a/exp_statistics/test/module.test.js b/exp_statistics/test/module.test.js new file mode 100644 index 00000000..1c81fdc1 --- /dev/null +++ b/exp_statistics/test/module.test.js @@ -0,0 +1,13 @@ +import path from "node:path"; +import t from "tap"; +import { reportLuaTests } from "../../test/lua/runner.js"; + +const envFile = path.join(import.meta.dirname, "module", "env.lua"); + +// Each file runs in its own lua state, and each test in a fresh environment +t.test("lua module", t2 => { + for (const file of ["control.lua", "events.lua"]) { + t2.test(file, t3 => reportLuaTests(t3, envFile, path.join(import.meta.dirname, "module", file))); + } + t2.end(); +}); diff --git a/exp_statistics/test/module/control.lua b/exp_statistics/test/module/control.lua new file mode 100644 index 00000000..a2fec8f2 --- /dev/null +++ b/exp_statistics/test/module/control.lua @@ -0,0 +1,120 @@ +local Suite = ... --- @type Suite + +Suite.test("increment() adds to the unsent statistics", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + Stats.increment(alice, "Kills") + Stats.increment(alice, "Kills", 4) + Suite.eq(Stats.get(alice, "Kills"), 5, "by one, then by the amount") + Suite.eq(Stats.get("alice", "Kills"), 5, "by player name") + Suite.eq(Stats.get(alice, "Deaths"), 0, "missing statistics are zero") +end) + +Suite.test("send_all() sends every player with changes in one update", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local bob = env.add_player{ name = "bob" } + env.add_player{ name = "carol" } + Stats.increment(alice, "Kills", 2) + Stats.increment(bob, "Deaths") + Stats.send_all() + + Suite.eq(#env.sent, 1, "one update") + Suite.eq(assert(env.sent[1]).channel, "exp_statistics:update", "on the update channel") + local players = assert(env.sent[1]).data.players + table.sort(players, function(a, b) return a.name < b.name end) + Suite.eq(players, { + { name = "alice", stats = { Kills = 2 } }, + { name = "bob", stats = { Deaths = 1 } }, + }, "players without changes are left out") +end) + +Suite.test("sending clears the unsent statistics but keeps the total", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + Stats.increment(alice, "Kills", 2) + Stats.send_all() + Suite.eq(Stats.get(alice, "Kills"), 2, "moved into the totals") + + env.reset_log() + Stats.send_all() + Suite.empty(env.sent, "nothing is sent twice") + + Stats.increment(alice, "Kills") + Stats.send_all() + Suite.eq(assert(env.sent[1]).data.players, { { name = "alice", stats = { Kills = 1 } } }, "only the new delta is sent") + Suite.eq(Stats.get(alice, "Kills"), 3, "the total includes it") +end) + +Suite.test("send() sends one player", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local bob = env.add_player{ name = "bob" } + Stats.increment(alice, "Kills") + Stats.increment(bob, "Kills") + Stats.send("alice") + Suite.eq(env.sent, { { channel = "exp_statistics:update", data = { players = { { name = "alice", stats = { Kills = 1 } } } } } }, "only that player") + + env.reset_log() + Stats.send("carol") + Suite.empty(env.sent, "nothing is sent for a player without changes") +end) + +Suite.test("receive_totals() adds the unsent statistics to the totals", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + Stats.increment(alice, "Kills", 2) + Stats.receive_totals{ name = "alice", stats = { Kills = 10, Deaths = 1 } } + Suite.eq(Stats.get(alice, "Kills"), 12, "received plus unsent") + Suite.eq(Stats.get(alice, "Deaths"), 1, "received only") + + Stats.send_all() + Suite.eq(assert(env.sent[1]).data.players[1].stats, { Kills = 2 }, "the received totals are not sent back") + Suite.eq(Stats.get(alice, "Kills"), 12, "the total is unchanged by sending") +end) + +Suite.test("receive_totals() ignores players who are not online", function(env) + local Stats = env.Statistics + env.add_player{ name = "bob", connected = false } + Stats.receive_totals{ name = "bob", stats = { Kills = 10 } } + Stats.receive_totals{ name = "nobody", stats = { Kills = 10 } } + Suite.eq(Stats.get("bob", "Kills"), 0, "offline") + Suite.eq(Stats.get("nobody", "Kills"), 0, "never joined") +end) + +Suite.test("send_and_forget() sends then drops the totals", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + Stats.receive_totals{ name = "alice", stats = { Kills = 10 } } + Stats.increment(alice, "Kills") + Stats.send_and_forget("alice") + Suite.eq(assert(env.sent[1]).data.players[1].stats, { Kills = 1 }, "the unsent delta is sent") + Suite.eq(Stats.get(alice, "Kills"), 0, "nothing is kept") +end) + +Suite.test("discard_unsent() drops what was not sent", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + Stats.receive_totals{ name = "alice", stats = { Kills = 10 } } + Stats.increment(alice, "Kills") + Stats.discard_unsent() + Suite.eq(Stats.get(alice, "Kills"), 10, "only the totals remain") + Stats.send_all() + Suite.empty(env.sent, "nothing to send") +end) + +Suite.test("format() shows times and numbers", function(env) + local Stats = env.Statistics + Suite.eq(Stats.format("Playtime", 90), { "time-long", 90 }, "long time") + Suite.eq(Stats.format("AfkTime", 90, true), { "time-short", 90 }, "short time") + Suite.eq(Stats.format("Kills", 7), "7", "number") + Suite.eq(Stats.units.DistanceTravelled, " tiles", "distance has a unit") +end) + +Suite.test("display_order lists every enabled statistic", function(env) + local order = env.Statistics.display_order + Suite.eq(#order, 24, "all enabled by default") + Suite.eq(order[1], "Playtime", "in the configured order") +end) + +return Suite.run() diff --git a/exp_statistics/test/module/env.lua b/exp_statistics/test/module/env.lua new file mode 100644 index 00000000..670ed69b --- /dev/null +++ b/exp_statistics/test/module/env.lua @@ -0,0 +1,101 @@ +--[[-- Test environment for the statistics module +Extends the shared stubs with fresh copies of module/control.lua and +module/events.lua, stubs of the exp_util functions they use, and players with +the fields the trackers read. + +Run as a chunk by test/lua/runner.js, receiving the shared folder. The suite +returned here becomes `...` in each test file. +]] + +local shared_root = ... --- @type string +local source = assert(debug.getinfo(1, "S")).source:gsub("\\", "/") +local plugin_root = assert(source:match("^@(.*)/test/module/env%.lua$")) + +local Framework = assert(loadfile(shared_root .. "/framework.lua"))() --- @type Framework + +--- Every event the module listens to, ids start after the shared stubs +local event_names = { + "on_player_left_game", "on_player_created", "on_player_changed_position", + "on_marked_for_deconstruction", "on_player_mined_entity", "on_entity_damaged", + "on_entity_died", "on_rocket_launched", "on_research_finished", + "on_built_entity", "on_chart_tag_added", "on_console_chat", "on_console_command", + "on_picked_up_item", "on_player_built_tile", "on_player_crafted_item", + "on_player_deconstructed_area", "on_player_died", "on_player_mined_tile", + "on_player_used_capsule", "on_player_repaired_entity", +} + +--- @class ExpStatistics.TestEnv : Stubs +--- @field Statistics ExpStatistics A fresh copy of the statistics module +--- @field handlers { events: table, on_nth_tick: table } From events.lua +--- @field force fun(name: string): table A force stub with the online players added to it +--- @field raise fun(name: string, event: table) Call the handler of an event by its name +--- @field add_player fun(params: ExpStatistics.TestPlayerParams): ExpStatistics.TestPlayer + +--- @class ExpStatistics.TestPlayerParams : Stubs.PlayerParams +--- @field afk_time number? Defaults to 0 +--- @field force string? Defaults to "player" + +--- @class ExpStatistics.TestPlayer : Stubs.Player +--- @field afk_time number +--- @field force table + +return Framework.suite(function(env) + --- @cast env ExpStatistics.TestEnv + local events = {} + for index, name in ipairs(event_names) do + events[name] = 10 + index + end + env.extend_defines{ events = events } + + env.extend_requires{ + ["modules/exp_util"] = { + format_time_factory_locale = function(opts) + return function(value) return { "time-" .. opts.format, value } end + end, + }, + ["modules/exp_util/storage"] = { + register = function() end, + }, + ["util"] = { + format_number = function(value) return tostring(value) end, + }, + } + env.extend_requires{ + ["modules/exp_statistics/config"] = assert(loadfile(plugin_root .. "/module/config.lua"))(), + } + env.Statistics = assert(loadfile(plugin_root .. "/module/control.lua"))() --- @type ExpStatistics + env.extend_requires{ ["modules/exp_statistics/control"] = env.Statistics } + env.handlers = assert(loadfile(plugin_root .. "/module/events.lua"))() + + local forces = {} + function env.force(name) + local force = forces[name] + if not force then + force = { name = name, valid = true, connected_players = {} } + forces[name] = force + end + return force + end + + local add_player = env.add_player + function env.add_player(params) + local player = add_player(params) --[[@as ExpStatistics.TestPlayer]] + local force = env.force(params.force or "player") + rawset(player, "afk_time", params.afk_time or 0) + rawset(player, "force", force) + rawset(player, "object_name", "LuaPlayer") + if player.connected then + force.connected_players[#force.connected_players + 1] = player + end + return player + end + + function env.raise(name, event) + local id = (defines.events --[[@as table]])[name] + local handler = env.handlers.events[id] + assert(handler, "no handler for " .. name) + handler(event) + end + + return env +end) diff --git a/exp_statistics/test/module/events.lua b/exp_statistics/test/module/events.lua new file mode 100644 index 00000000..97b0ddd9 --- /dev/null +++ b/exp_statistics/test/module/events.lua @@ -0,0 +1,133 @@ +local Suite = ... --- @type Suite + +Suite.test("counters go up by one for online players", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local bob = env.add_player{ name = "bob", connected = false } + env.raise("on_console_chat", { player_index = alice.index }) + env.raise("on_console_chat", { player_index = alice.index }) + env.raise("on_player_died", { player_index = alice.index }) + env.raise("on_console_chat", { player_index = bob.index }) + env.raise("on_console_chat", {}) + + Suite.eq(Stats.get(alice, "ChatMessages"), 2, "chat") + Suite.eq(Stats.get(alice, "Deaths"), 1, "deaths") + Suite.eq(Stats.get(bob, "ChatMessages"), 0, "offline players are skipped") +end) + +Suite.test("a player is counted once per map", function(env) + local alice = env.add_player{ name = "alice" } + env.raise("on_player_created", { player_index = alice.index }) + Suite.eq(env.Statistics.get(alice, "MapsPlayed"), 1, "maps played") +end) + +Suite.test("playtime and afk time go up each minute", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local bob = env.add_player{ name = "bob", afk_time = 6 * 3600 } + local tick = env.handlers.on_nth_tick[3600] + game.tick = 3600 + tick() + tick() + + Suite.eq(Stats.get(alice, "Playtime"), 2, "playtime") + Suite.eq(Stats.get(alice, "AfkTime"), 0, "not afk") + Suite.eq(Stats.get(bob, "AfkTime"), 2, "afk") +end) + +Suite.test("distance is not counted while afk", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local bob = env.add_player{ name = "bob", afk_time = 6 * 3600 } + env.raise("on_player_changed_position", { player_index = alice.index }) + env.raise("on_player_changed_position", { player_index = bob.index }) + Suite.eq(Stats.get(alice, "DistanceTravelled"), 1, "moving") + Suite.eq(Stats.get(bob, "DistanceTravelled"), 0, "afk") +end) + +Suite.test("removed entities count as ore, trees or machines", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local function entity(type, force) + return { valid = true, type = type, force = force or env.force("neutral") } + end + env.raise("on_player_mined_entity", { player_index = alice.index, entity = entity("resource") }) + env.raise("on_marked_for_deconstruction", { player_index = alice.index, entity = entity("tree") }) + env.raise("on_player_mined_entity", { player_index = alice.index, entity = entity("assembling-machine", alice.force) }) + env.raise("on_player_mined_entity", { player_index = alice.index, entity = entity("assembling-machine", env.force("enemy")) }) + env.raise("on_marked_for_deconstruction", { entity = entity("tree") }) + + Suite.eq(Stats.get(alice, "OreMined"), 1, "ore") + Suite.eq(Stats.get(alice, "TreesDestroyed"), 1, "trees, by players only") + Suite.eq(Stats.get(alice, "MachinesRemoved"), 1, "own force only") +end) + +Suite.test("damage and kills count against hostile entities", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local character = { valid = true, type = "character", object_name = "LuaEntity", player = alice } + local enemy = { valid = true, force = env.force("enemy") } + local ally = { valid = true, force = alice.force } + env.raise("on_entity_damaged", { cause = character, entity = enemy, final_damage_amount = 12.7 }) + env.raise("on_entity_damaged", { cause = character, entity = ally, final_damage_amount = 5 }) + env.raise("on_entity_died", { cause = character, entity = enemy }) + env.raise("on_entity_died", { entity = enemy }) + + Suite.eq(Stats.get(alice, "DamageDealt"), 12, "damage is floored, allies are skipped") + Suite.eq(Stats.get(alice, "Kills"), 1, "kills without a cause are skipped") +end) + +Suite.test("vehicles credit their driver", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local enemy = { valid = true, force = env.force("enemy") } + local car = { + valid = true, type = "car", + get_driver = function() return alice end, + get_passenger = function() return nil end, + } + local empty_train = { + valid = true, type = "locomotive", + get_driver = function() return nil end, + get_passenger = function() return nil end, + } + env.raise("on_entity_died", { cause = car, entity = enemy }) + env.raise("on_entity_died", { cause = empty_train, entity = enemy }) + Suite.eq(Stats.get(alice, "Kills"), 1, "the driver, not the empty train") +end) + +Suite.test("rockets and research count for every online player on the force", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local bob = env.add_player{ name = "bob" } + local carol = env.add_player{ name = "carol", force = "other" } + env.raise("on_rocket_launched", { rocket_silo = { valid = true, force = alice.force } }) + env.raise("on_research_finished", { research = { valid = true, force = alice.force } }) + env.raise("on_research_finished", { by_script = true, research = { valid = true, force = alice.force } }) + + Suite.eq({ Stats.get(alice, "RocketsLaunched"), Stats.get(bob, "RocketsLaunched") }, { 1, 1 }, "rockets") + Suite.eq(Stats.get(alice, "ResearchCompleted"), 1, "research by script is skipped") + Suite.eq(Stats.get(carol, "RocketsLaunched"), 0, "other forces are skipped") +end) + +Suite.test("leaving sends the player and forgets them", function(env) + local Stats = env.Statistics + local alice = env.add_player{ name = "alice" } + local bob = env.add_player{ name = "bob" } + Stats.increment(alice, "Kills") + Stats.increment(bob, "Kills") + env.raise("on_player_left_game", { player_index = alice.index }) + + Suite.eq(assert(env.sent[1]).data.players, { { name = "alice", stats = { Kills = 1 } } }, "only the player leaving") + Suite.eq(Stats.get(alice, "Kills"), 0, "forgotten") + Suite.eq(Stats.get(bob, "Kills"), 1, "others are kept") +end) + +Suite.test("everyone is sent every 5 minutes", function(env) + local alice = env.add_player{ name = "alice" } + env.Statistics.increment(alice, "Kills") + env.handlers.on_nth_tick[5 * 3600]() + Suite.eq(#env.sent, 1, "one update") +end) + +return Suite.run() diff --git a/exp_statistics/tsconfig.browser.json b/exp_statistics/tsconfig.browser.json new file mode 100644 index 00000000..a621b69f --- /dev/null +++ b/exp_statistics/tsconfig.browser.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.browser.json", + "include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ], +} diff --git a/exp_statistics/tsconfig.json b/exp_statistics/tsconfig.json new file mode 100644 index 00000000..303b8f28 --- /dev/null +++ b/exp_statistics/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.browser.json" } + ] +} diff --git a/exp_statistics/tsconfig.node.json b/exp_statistics/tsconfig.node.json new file mode 100644 index 00000000..3218f2e7 --- /dev/null +++ b/exp_statistics/tsconfig.node.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./**/*.ts"], + "exclude": ["test/*", "./dist/*"], +} diff --git a/exp_statistics/web/components/StatisticsPage.tsx b/exp_statistics/web/components/StatisticsPage.tsx new file mode 100644 index 00000000..77ba9142 --- /dev/null +++ b/exp_statistics/web/components/StatisticsPage.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { Table } from "antd"; +import type { ColumnsType } from "antd/es/table"; + +import { PageHeader, PageLayout, useColumnSearch, useTableQueryState } from "@clusterio/web_ui"; + +import { PlayerStatistics } from "../../messages.js"; +import { usePlayerStatistics } from ".."; + +const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; + +/** Same order and names as module/config.lua and module/locale/en.cfg */ +const statistics: [name: string, title: string][] = [ + ["Playtime", "Playtime"], + ["AfkTime", "AFK Time"], + ["MapsPlayed", "Maps Played"], + ["JoinCount", "Join Count"], + ["ChatMessages", "Messages"], + ["CommandsUsed", "Commands"], + ["RocketsLaunched", "Rockets Launched"], + ["ResearchCompleted", "Research Completed"], + ["MachinesBuilt", "Machines Built"], + ["MachinesRemoved", "Machines Removed"], + ["TilesBuilt", "Tiles Placed"], + ["TilesRemoved", "Tiles Removed"], + ["TreesDestroyed", "Trees Destroyed"], + ["OreMined", "Ore Mined"], + ["ItemsCrafted", "Items Crafted"], + ["ItemsPickedUp", "Items Picked Up"], + ["Kills", "Kills"], + ["Deaths", "Deaths"], + ["DamageDealt", "Damage Dealt"], + ["DistanceTravelled", "Distance Travelled"], + ["CapsulesUsed", "Capsules Used"], + ["EntityRepaired", "Machines Repaired"], + ["DeconstructionPlannerUsed", "Decon Planner Used"], + ["MapTagsMade", "Map Tags Created"], +]; + +/** Measured in minutes */ +const timeStatistics = new Set(["Playtime", "AfkTime"]); + +function formatValue(name: string, value: number) { + if (timeStatistics.has(name)) { + return `${Math.floor(value / 60)}h ${value % 60}m`; + } + return value.toLocaleString(); +} + +export default function StatisticsPage() { + const [players, synced] = usePlayerStatistics(); + const tableState = useTableQueryState({ + namespace: "statistics", + defaultSortKey: "Playtime", + defaultSortOrder: "descend", + pagination: { defaultPageSize: 50 }, + }); + const playerSearch = useColumnSearch(tableState, "player", player => player.id, "Search players"); + + const columns: ColumnsType = [ + { + title: "Player", + key: "player", + fixed: "left", + render: (_, player) => player.id, + sorter: (a, b) => strcmp(a.id, b.id), + sortOrder: tableState.sortOrder("player"), + filteredValue: tableState.filteredValue("player"), + ...playerSearch, + }, + ...statistics.map(([name, title]): ColumnsType[number] => ({ + title, + key: name, + align: "right", + render: (_, player) => formatValue(name, player.stats[name] ?? 0), + sorter: (a, b) => (a.stats[name] ?? 0) - (b.stats[name] ?? 0), + sortDirections: ["descend", "ascend"], + sortOrder: tableState.sortOrder(name), + })), + ]; + + return + + player.id} + pagination={tableState.pagination} + onChange={tableState.onChange} + scroll={{ x: "max-content" }} + /> + ; +} diff --git a/exp_statistics/web/index.tsx b/exp_statistics/web/index.tsx new file mode 100644 index 00000000..fcfdb8bc --- /dev/null +++ b/exp_statistics/web/index.tsx @@ -0,0 +1,27 @@ +import React, { useCallback, useSyncExternalStore } from "react"; +import { WebPluginContext } from "@clusterio/web_ui"; + +import * as lib from "@clusterio/lib"; +import * as messages from "../messages.js"; + +import StatisticsPage from "./components/StatisticsPage"; + +let players: lib.MapSubscriber | undefined; + +export function usePlayerStatistics() { + const subscribe = useCallback((cb: () => void) => players!.subscribe(cb), []); + return useSyncExternalStore(subscribe, () => players!.getSnapshot()); +} + +export default function registerPlugin(context: WebPluginContext) { + context.control.hooks.pages.attach("exp_statistics", () => [ + { + path: "/exp_statistics", + sidebarName: "Statistics", + permission: "exp_statistics.view", + content: , + }, + ]); + + players = new lib.MapSubscriber(messages.PlayerStatisticsUpdatedEvent, context.control); +} diff --git a/exp_statistics/webpack.config.cjs b/exp_statistics/webpack.config.cjs new file mode 100644 index 00000000..daeda18c --- /dev/null +++ b/exp_statistics/webpack.config.cjs @@ -0,0 +1,34 @@ +"use strict"; +const path = require("path"); +const webpack = require("webpack"); +const { merge } = require("webpack-merge"); + +const common = require("@clusterio/web_ui/webpack.common"); + +module.exports = (env = {}) => merge(common(env), { + context: __dirname, + entry: "./web/index.tsx", + output: { + path: path.resolve(__dirname, "dist", "web"), + }, + plugins: [ + new webpack.container.ModuleFederationPlugin({ + name: "exp_statistics", + library: { type: "window", name: "plugin_exp_statistics" }, + exposes: { + "./": "./index.ts", + "./package.json": "./package.json", + "./web": "./web/index.tsx", + }, + shared: { + "@clusterio/lib": { import: false }, + "@clusterio/web_ui": { import: false }, + "antd": { import: false }, + "react": { import: false }, + "react-dom": { import: false }, + "react-router": { import: false }, + "react-router-dom": { import: false }, + }, + }), + ], +}); diff --git a/tsconfig.json b/tsconfig.json index 4ebe1313..9225e5c9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ { "path": "./exp_scenario/" }, { "path": "./exp_server_list/" }, { "path": "./exp_server_ups/" }, + { "path": "./exp_statistics/" }, { "path": "./exp_util/" }, ], } From 237200e6c81038f3b4687da8f6f0b66445504a90 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:49:30 +0000 Subject: [PATCH 2/5] Use exp_statistics in place of the legacy statistics Remove the tracking and config from exp_legacy along with its exp-statistics locale, which now ships with exp_statistics. The player stats GUI stays in exp_scenario and reads the new module. It uses the online player dropdown from exp_scenario's elements, so moving it would make exp_statistics depend on exp_scenario, and its exp_scenario.gui.playerdata permission is already in the seeded roles. The readme data tab shows the statistics from the new module above the remaining PlayerData.Statistics children, which is only LocalLanguage now. Co-Authored-By: Claude Opus 5.5 --- exp_legacy/module/config/_file_loader.lua | 3 - exp_legacy/module/config/statistics.lua | 46 ---- exp_legacy/module/locale/en.cfg | 66 ------ exp_legacy/module/locale/zh-CN.cfg | 66 ------ exp_legacy/module/locale/zh-TW.cfg | 66 ------ exp_legacy/module/modules/data/statistics.lua | 208 ------------------ exp_scenario/module/gui/player_stats.lua | 60 +++-- exp_scenario/module/gui/readme.lua | 9 + exp_scenario/module/module.json | 1 + exp_scenario/package.json | 1 + 10 files changed, 38 insertions(+), 488 deletions(-) delete mode 100644 exp_legacy/module/config/statistics.lua delete mode 100644 exp_legacy/module/modules/data/statistics.lua 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_scenario/module/gui/player_stats.lua b/exp_scenario/module/gui/player_stats.lua index ca453243..2a08cbc0 100644 --- a/exp_scenario/module/gui/player_stats.lua +++ b/exp_scenario/module/gui/player_stats.lua @@ -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 ExpStatistics = require("modules/exp_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 = ExpStatistics.get(player, statistic) + return value == 0 and default or value +end + --- @type table 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(ExpStatistics.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", ExpStatistics.format(stat_name, 0, true), ExpStatistics.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(ExpStatistics.display_order) do + local value = ExpStatistics.format(stat_name, ExpStatistics.get(player, stat_name), true) + labels[stat_name].caption = { "exp-gui_readme.data-format", value, ExpStatistics.units[stat_name] or "" } end -- Update all computed stats diff --git a/exp_scenario/module/gui/readme.lua b/exp_scenario/module/gui/readme.lua index 19c21eac..a53838f0 100644 --- a/exp_scenario/module/gui/readme.lua +++ b/exp_scenario/module/gui/readme.lua @@ -6,6 +6,7 @@ local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") local Roles = require("modules/exp_roles") local Commands = require("modules/exp_commands") +local ExpStatistics = require("modules/exp_statistics") local PlayerData = require("modules.exp_legacy.expcore.player_data") local ExpServerList = require("modules/exp_server_list") local community = require("modules/exp_scenario/config/community_details").config @@ -582,6 +583,14 @@ define_tab( default_stringify = function(value) return format_number(value or 0, false) end, + extra_rows = function(statistics) + for _, name in ipairs(ExpStatistics.display_order) do + local value = ExpStatistics.format(name, ExpStatistics.get(player_name, name)) + local tooltip = { "exp-statistics." .. name .. "-tooltip" } + Gui.elements.centered_label(statistics, 150, { "exp-statistics." .. name }, tooltip) + Gui.elements.centered_label(statistics, 130, { "exp-gui_readme.data-format", value, ExpStatistics.units[name] or "" }, tooltip) + end + end, } end diff --git a/exp_scenario/module/module.json b/exp_scenario/module/module.json index 85b7a4e0..1c011673 100644 --- a/exp_scenario/module/module.json +++ b/exp_scenario/module/module.json @@ -12,6 +12,7 @@ "exp_roles": "*", "exp_reports": "*", "exp_server_list": "*", + "exp_statistics": "*", "exp_gui": "*", "exp_commands": "*" } diff --git a/exp_scenario/package.json b/exp_scenario/package.json index ab53eb6f..0997b985 100644 --- a/exp_scenario/package.json +++ b/exp_scenario/package.json @@ -52,6 +52,7 @@ "@expcluster/permission-groups": "workspace:^", "@expcluster/roles": "workspace:^", "@expcluster/server-list": "workspace:^", + "@expcluster/statistics": "workspace:^", "@sinclair/typebox": "catalog:" }, "publishConfig": { From 27456e368c93f35a37154166151df19a8346c236 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:09:05 +0000 Subject: [PATCH 3/5] Show only statistics on the readme data tab The tab listed the legacy player data, its required, settings and misc groups and the data saving preference. None of that is synced or relevant any more, so the tab is now Statistics and lists the totals from exp_statistics in display order. Co-Authored-By: Claude Opus 5.5 --- exp_scenario/module/gui/readme.lua | 165 ++------------------------- exp_scenario/module/locale/en.cfg | 10 +- exp_scenario/module/locale/zh-CN.cfg | 8 +- exp_scenario/module/locale/zh-TW.cfg | 8 +- 4 files changed, 13 insertions(+), 178 deletions(-) diff --git a/exp_scenario/module/gui/readme.lua b/exp_scenario/module/gui/readme.lua index a53838f0..6b9e15fb 100644 --- a/exp_scenario/module/gui/readme.lua +++ b/exp_scenario/module/gui/readme.lua @@ -7,11 +7,9 @@ local Gui = require("modules/exp_gui") local Roles = require("modules/exp_roles") local Commands = require("modules/exp_commands") local ExpStatistics = require("modules/exp_statistics") -local PlayerData = require("modules.exp_legacy.expcore.player_data") 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 @@ -458,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" }) @@ -534,98 +471,12 @@ 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, - extra_rows = function(statistics) - for _, name in ipairs(ExpStatistics.display_order) do - local value = ExpStatistics.format(name, ExpStatistics.get(player_name, name)) - local tooltip = { "exp-statistics." .. name .. "-tooltip" } - Gui.elements.centered_label(statistics, 150, { "exp-statistics." .. name }, tooltip) - Gui.elements.centered_label(statistics, 130, { "exp-gui_readme.data-format", value, ExpStatistics.units[name] or "" }, tooltip) - end - 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 = Elements.title_table(scroll_pane, 250, { "exp-gui_readme.data-tab" }, 4) + for _, name in ipairs(ExpStatistics.display_order) do + local value = ExpStatistics.format(name, ExpStatistics.get(player_name, name)) + local tooltip = { "exp-statistics." .. name .. "-tooltip" } + Gui.elements.centered_label(statistics, 150, { "exp-statistics." .. name }, tooltip) + Gui.elements.centered_label(statistics, 130, { "exp-gui_readme.data-format", value, ExpStatistics.units[name] or "" }, tooltip) end return container diff --git a/exp_scenario/module/locale/en.cfg b/exp_scenario/module/locale/en.cfg index a1b22a40..0f3bcba3 100644 --- a/exp_scenario/module/locale/en.cfg +++ b/exp_scenario/module/locale/en.cfg @@ -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] diff --git a/exp_scenario/module/locale/zh-CN.cfg b/exp_scenario/module/locale/zh-CN.cfg index ead737cc..e6fee7d8 100644 --- a/exp_scenario/module/locale/zh-CN.cfg +++ b/exp_scenario/module/locale/zh-CN.cfg @@ -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__ diff --git a/exp_scenario/module/locale/zh-TW.cfg b/exp_scenario/module/locale/zh-TW.cfg index ead737cc..e6fee7d8 100644 --- a/exp_scenario/module/locale/zh-TW.cfg +++ b/exp_scenario/module/locale/zh-TW.cfg @@ -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__ From ad1c83def8316ae838f58a6eedbfad6f5fa37f33 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:36:00 +0000 Subject: [PATCH 4/5] Address review on the statistics plugin The plugin is now exp_player_statistics (@expcluster/player-statistics), with the module, global, permission, route and database directory renamed to match. The exp-statistics locale section keeps its name. Every statistic is tracked unconditionally, so the enable flags are gone from config.lua. Playtime and JoinCount are no longer counted in game: clusterio already keeps online time and joins for each user, so the controller fills them in from the user when a player's totals are requested, and the web page does the same from the user list. They are not stored or adjusted by the plugin. The web table has a column picker, a multiple select with check boxes, Select all and Reset buttons, showing "N statistics selected". The choice is kept in local storage. The user page shows the statistics of the player in a grid like the core player stats. The readme statistics table drops its second title and uses the short time format. Co-Authored-By: Claude Opus 5.5 --- .../controller.ts | 12 +- .../index.ts | 10 +- .../instance.ts | 8 +- .../messages.ts | 24 ++- .../module/config.lua | 19 +- .../module/control.lua | 64 +++--- exp_player_statistics/module/events.lua | 171 ++++++++++++++++ exp_player_statistics/module/globals.lua | 4 + .../module/locale/en.cfg | 0 .../module/locale/zh-CN.cfg | 0 .../module/locale/zh-TW.cfg | 0 .../module/module.json | 2 +- .../module/module_exports.lua | 2 + .../package.json | 2 +- .../test/controller.test.js | 43 ++-- .../test/instance.test.js | 13 +- .../test/module.test.js | 0 .../test/module/control.lua | 6 +- .../test/module/env.lua | 20 +- .../test/module/events.lua | 6 +- .../tsconfig.browser.json | 0 .../tsconfig.json | 0 .../tsconfig.node.json | 0 .../web/components/StatisticsPage.tsx | 114 +++++++++++ .../web/components/UserStatistics.tsx | 37 ++++ .../web/index.tsx | 11 +- exp_player_statistics/web/statistics.ts | 40 ++++ .../webpack.config.cjs | 4 +- exp_scenario/module/gui/player_stats.lua | 14 +- exp_scenario/module/gui/readme.lua | 12 +- exp_scenario/module/module.json | 2 +- exp_scenario/package.json | 2 +- exp_statistics/module/events.lua | 188 ------------------ exp_statistics/module/globals.lua | 4 - exp_statistics/module/module_exports.lua | 2 - .../web/components/StatisticsPage.tsx | 95 --------- tsconfig.json | 2 +- 37 files changed, 515 insertions(+), 418 deletions(-) rename {exp_statistics => exp_player_statistics}/controller.ts (86%) rename {exp_statistics => exp_player_statistics}/index.ts (80%) rename {exp_statistics => exp_player_statistics}/instance.ts (84%) rename {exp_statistics => exp_player_statistics}/messages.ts (81%) rename {exp_statistics => exp_player_statistics}/module/config.lua (56%) rename {exp_statistics => exp_player_statistics}/module/control.lua (71%) create mode 100644 exp_player_statistics/module/events.lua create mode 100644 exp_player_statistics/module/globals.lua rename {exp_statistics => exp_player_statistics}/module/locale/en.cfg (100%) rename {exp_statistics => exp_player_statistics}/module/locale/zh-CN.cfg (100%) rename {exp_statistics => exp_player_statistics}/module/locale/zh-TW.cfg (100%) rename {exp_statistics => exp_player_statistics}/module/module.json (79%) create mode 100644 exp_player_statistics/module/module_exports.lua rename {exp_statistics => exp_player_statistics}/package.json (97%) rename {exp_statistics => exp_player_statistics}/test/controller.test.js (74%) rename {exp_statistics => exp_player_statistics}/test/instance.test.js (89%) rename {exp_statistics => exp_player_statistics}/test/module.test.js (100%) rename {exp_statistics => exp_player_statistics}/test/module/control.lua (93%) rename {exp_statistics => exp_player_statistics}/test/module/env.lua (82%) rename {exp_statistics => exp_player_statistics}/test/module/events.lua (96%) rename {exp_statistics => exp_player_statistics}/tsconfig.browser.json (100%) rename {exp_statistics => exp_player_statistics}/tsconfig.json (100%) rename {exp_statistics => exp_player_statistics}/tsconfig.node.json (100%) create mode 100644 exp_player_statistics/web/components/StatisticsPage.tsx create mode 100644 exp_player_statistics/web/components/UserStatistics.tsx rename {exp_statistics => exp_player_statistics}/web/index.tsx (70%) create mode 100644 exp_player_statistics/web/statistics.ts rename {exp_statistics => exp_player_statistics}/webpack.config.cjs (88%) delete mode 100644 exp_statistics/module/events.lua delete mode 100644 exp_statistics/module/globals.lua delete mode 100644 exp_statistics/module/module_exports.lua delete mode 100644 exp_statistics/web/components/StatisticsPage.tsx diff --git a/exp_statistics/controller.ts b/exp_player_statistics/controller.ts similarity index 86% rename from exp_statistics/controller.ts rename to exp_player_statistics/controller.ts index 99a4e83c..21bd1f95 100644 --- a/exp_statistics/controller.ts +++ b/exp_player_statistics/controller.ts @@ -9,7 +9,7 @@ export default async function (context: ControllerPluginContext) { const databaseDirectory = controller.config.get("controller.database_directory"); const players = new lib.SubscribableDatastore( ...await new lib.JsonIdDatastoreProvider( - path.join(databaseDirectory, "exp_statistics", "players.json"), + path.join(databaseDirectory, "exp_player_statistics", "players.json"), messages.PlayerStatistics.fromJSON.bind(messages.PlayerStatistics), ).bootstrap() ); @@ -45,9 +45,13 @@ export default async function (context: ControllerPluginContext) { addDeltas(event.deltas); }); - controller.handle(messages.StatisticsGetRequest, async (request: messages.StatisticsGetRequest) => ( - players.get(request.name) ?? new messages.PlayerStatistics(request.name) - )); + controller.handle(messages.StatisticsGetRequest, async (request: messages.StatisticsGetRequest) => { + const record = players.get(request.name); + return new messages.PlayerStatistics(request.name, { + ...record?.stats, + ...messages.userStatistics(controller.users.getByName(request.name)), + }, record?.updatedAtMs); + }); controller.handle(messages.StatisticsListRequest, async () => [...players.values()]); diff --git a/exp_statistics/index.ts b/exp_player_statistics/index.ts similarity index 80% rename from exp_statistics/index.ts rename to exp_player_statistics/index.ts index 6b9ec3eb..3af21429 100644 --- a/exp_statistics/index.ts +++ b/exp_player_statistics/index.ts @@ -3,13 +3,13 @@ import * as messages from "./messages.js"; declare module "@clusterio/lib" { export interface Permissions { - "exp_statistics.view": never; + "exp_player_statistics.view": never; } } export const plugin: lib.PluginDeclaration = { - name: "exp_statistics", - title: "ExpGaming - Statistics", + name: "exp_player_statistics", + title: "ExpGaming - Player Statistics", description: "Clusterio plugin keeping player statistics on the controller", controllerEntrypoint: "./dist/node/controller.js", @@ -24,7 +24,7 @@ export const plugin: lib.PluginDeclaration = { permissions: [ { - name: "exp_statistics.view", + name: "exp_player_statistics.view", title: "View player statistics", description: "View the statistics of every player and subscribe to their updates", grantByDefault: true, @@ -33,6 +33,6 @@ export const plugin: lib.PluginDeclaration = { webEntrypoint: "./web", routes: [ - "/exp_statistics", + "/exp_player_statistics", ], }; diff --git a/exp_statistics/instance.ts b/exp_player_statistics/instance.ts similarity index 84% rename from exp_statistics/instance.ts rename to exp_player_statistics/instance.ts index bd285bb8..56553c56 100644 --- a/exp_statistics/instance.ts +++ b/exp_player_statistics/instance.ts @@ -24,10 +24,10 @@ export default async function (context: InstancePluginContext) { while (json.includes(`]${level}]`)) { level += "="; } - await luaCall(`exp_statistics.receive_totals(helpers.json_to_table[${level}[${json}]${level}])`); + await luaCall(`exp_player_statistics.receive_totals(helpers.json_to_table[${level}[${json}]${level}])`); } - instance.server.handle("exp_statistics:update", async (event: IpcStatisticsUpdate) => { + instance.server.handle("exp_player_statistics:update", async (event: IpcStatisticsUpdate) => { instance.sendTo("controller", new messages.StatisticsUpdateEvent( event.players.map(player => new messages.StatisticsDelta(player.name, player.stats)), )); @@ -50,10 +50,10 @@ export default async function (context: InstancePluginContext) { // Anything still unsent at start came from a save made before the last // update was sent, so it is dropped rather than counted twice instance.hooks.start.attach(plugin.name, async () => { - await luaCall("exp_statistics.discard_unsent()"); + await luaCall("exp_player_statistics.discard_unsent()"); }); instance.hooks.stop.attach(plugin.name, async () => { - await luaCall("exp_statistics.send_all()"); + await luaCall("exp_player_statistics.send_all()"); }); } diff --git a/exp_statistics/messages.ts b/exp_player_statistics/messages.ts similarity index 81% rename from exp_statistics/messages.ts rename to exp_player_statistics/messages.ts index b7be099f..ad6f8da3 100644 --- a/exp_statistics/messages.ts +++ b/exp_player_statistics/messages.ts @@ -42,6 +42,14 @@ export class PlayerStatistics { } } +/** Statistics clusterio already keeps for every user, shown with the others but never added to */ +export function userStatistics(user?: Pick): 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( @@ -66,7 +74,7 @@ export class StatisticsDelta { /** Sent by an instance to add to the totals of its players. */ export class StatisticsUpdateEvent { declare ["constructor"]: typeof StatisticsUpdateEvent; - static plugin = "exp_statistics" as const; + static plugin = "exp_player_statistics" as const; static type = "event" as const; static src = "instance" as const; static dst = "controller" as const; @@ -91,11 +99,11 @@ export class StatisticsUpdateEvent { export class PlayerStatisticsUpdatedEvent { declare ["constructor"]: typeof PlayerStatisticsUpdatedEvent; - static plugin = "exp_statistics" as const; + static plugin = "exp_player_statistics" as const; static type = "event" as const; static src = "controller" as const; static dst = "control" as const; - static permission = "exp_statistics.view" as const; + static permission = "exp_player_statistics.view" as const; constructor( public updates: PlayerStatistics[], @@ -114,14 +122,14 @@ export class PlayerStatisticsUpdatedEvent { } } -/** Totals for one player, empty when they have none. */ +/** Totals for one player, with Playtime and JoinCount from their clusterio user. */ export class StatisticsGetRequest { declare ["constructor"]: typeof StatisticsGetRequest; - static plugin = "exp_statistics" as const; + static plugin = "exp_player_statistics" as const; static type = "request" as const; static src = ["control", "instance"] as const; static dst = "controller" as const; - static permission = "exp_statistics.view" as const; + static permission = "exp_player_statistics.view" as const; static Response = PlayerStatistics; constructor( @@ -143,10 +151,10 @@ export class StatisticsGetRequest { export class StatisticsListRequest { declare ["constructor"]: typeof StatisticsListRequest; - static plugin = "exp_statistics" as const; + static plugin = "exp_player_statistics" as const; static type = "request" as const; static src = ["control", "instance"] as const; static dst = "controller" as const; - static permission = "exp_statistics.view" as const; + static permission = "exp_player_statistics.view" as const; static Response = lib.jsonArray(PlayerStatistics); } diff --git a/exp_statistics/module/config.lua b/exp_player_statistics/module/config.lua similarity index 56% rename from exp_statistics/module/config.lua rename to exp_player_statistics/module/config.lua index 381a73e2..d2725e73 100644 --- a/exp_statistics/module/config.lua +++ b/exp_player_statistics/module/config.lua @@ -1,20 +1,10 @@ ---[[-- ExpStatistics - Config -Which statistics are tracked, and the order they are shown in. +--[[-- ExpPlayerStatistics - Config +The counted statistics and the order statistics are shown in. +Playtime and JoinCount come from the clusterio user, not from here. ]] local e = defines.events return { - MapsPlayed = true, -- Maps a player has joined at least once - Playtime = true, -- Minutes online - AfkTime = true, -- Minutes online after doing nothing for 5 minutes - DistanceTravelled = true, -- Tiles moved while not afk - MachinesRemoved = true, -- Own force entities marked for deconstruction or mined - TreesDestroyed = true, -- Trees marked for deconstruction or mined - OreMined = true, -- Resources mined by hand - DamageDealt = true, -- Damage to entities not on the same force or neutral - Kills = true, -- Entities killed which are not on the same force or neutral - RocketsLaunched = true, -- Counted for every online player on the force - ResearchCompleted = true, -- Counted for every online player on the force --- Statistics that go up by one each time the event is raised for a player --- @type table counters = { @@ -27,12 +17,11 @@ return { ItemsCrafted = e.on_player_crafted_item, DeconstructionPlannerUsed = e.on_player_deconstructed_area, Deaths = e.on_player_died, - JoinCount = e.on_player_joined_game, TilesRemoved = e.on_player_mined_tile, CapsulesUsed = e.on_player_used_capsule, EntityRepaired = e.on_player_repaired_entity, }, - --- The order statistics are shown in, disabled ones are skipped + --- The order statistics are shown in display_order = { "Playtime", "AfkTime", "MapsPlayed", "JoinCount", diff --git a/exp_statistics/module/control.lua b/exp_player_statistics/module/control.lua similarity index 71% rename from exp_statistics/module/control.lua rename to exp_player_statistics/module/control.lua index 6d3b000d..30946870 100644 --- a/exp_statistics/module/control.lua +++ b/exp_player_statistics/module/control.lua @@ -1,4 +1,4 @@ ---[[-- ExpStatistics +--[[-- ExpPlayerStatistics Player statistics kept on the controller. What each player gains is kept here as unsent until it is sent to the @@ -10,32 +10,32 @@ Updates which are lost are not sent again. Anything unsent when the server starts is from a save made before it was sent, so it is dropped rather than risk counting it twice. -local ExpStatistics = require("modules/exp_statistics") +local ExpPlayerStatistics = require("modules/exp_player_statistics") -ExpStatistics.increment(player, "Kills") -ExpStatistics.get(player, "Kills") -for _, name in ipairs(ExpStatistics.display_order) do end +ExpPlayerStatistics.increment(player, "Kills") +ExpPlayerStatistics.get(player, "Kills") +for _, name in ipairs(ExpPlayerStatistics.display_order) do end ]] local clusterio_api = require("modules/clusterio/api") local ExpUtil = require("modules/exp_util") local Storage = require("modules/exp_util/storage") -local config = require("modules/exp_statistics/config") +local config = require("modules/exp_player_statistics/config") local format_number = require("util").format_number ---- @alias ExpStatistics.Stats table +--- @alias ExpPlayerStatistics.Stats table ---- @class ExpStatistics.Totals +--- @class ExpPlayerStatistics.Totals --- @field name string ---- @field stats ExpStatistics.Stats +--- @field stats ExpPlayerStatistics.Stats ---- @class ExpStatistics -local ExpStatistics = { +--- @class ExpPlayerStatistics +local ExpPlayerStatistics = { config = config, - --- Enabled statistics in the order they are shown + --- Statistics in the order they are shown --- @type string[] - display_order = {}, + display_order = config.display_order, --- Shown after the value --- @type table units = { DistanceTravelled = " tiles" }, @@ -46,14 +46,8 @@ local time_statistics = { Playtime = true, AfkTime = true } local long_time_format = ExpUtil.format_time_factory_locale{ format = "long", coefficient = 3600, hours = true, minutes = true } local short_time_format = ExpUtil.format_time_factory_locale{ format = "short", coefficient = 3600, hours = true, minutes = true } -for _, name in ipairs(config.display_order) do - if config[name] == true or config.counters[name] then - ExpStatistics.display_order[#ExpStatistics.display_order + 1] = name - end -end - -local unsent = {} --- @type table -local totals = {} --- @type table +local unsent = {} --- @type table +local totals = {} --- @type table Storage.register({ unsent, totals }, function(tbl) unsent = tbl[1] totals = tbl[2] @@ -63,7 +57,7 @@ end) --- @param player LuaPlayer --- @param statistic string --- @param amount number? Defaults to 1 -function ExpStatistics.increment(player, statistic, amount) +function ExpPlayerStatistics.increment(player, statistic, amount) local name = player.name local stats = unsent[name] if not stats then @@ -77,7 +71,7 @@ end --- @param player LuaPlayer | string --- @param statistic string --- @return number -function ExpStatistics.get(player, statistic) +function ExpPlayerStatistics.get(player, statistic) local name = type(player) == "string" and player or player.name local received = totals[name] local pending = unsent[name] @@ -89,7 +83,7 @@ end --- @param value number --- @param short boolean? Use the short time format --- @return LocalisedString -function ExpStatistics.format(statistic, value, short) +function ExpPlayerStatistics.format(statistic, value, short) if time_statistics[statistic] then return short and short_time_format(value) or long_time_format(value) end @@ -98,7 +92,7 @@ end --- Move the unsent statistics of a player into their totals --- @param name string ---- @return ExpStatistics.Stats? +--- @return ExpPlayerStatistics.Stats? local function take_unsent(name) local stats = unsent[name] if not stats then return nil end @@ -117,45 +111,45 @@ end --- Send what a player gained to the controller --- @param name string -function ExpStatistics.send(name) +function ExpPlayerStatistics.send(name) local stats = take_unsent(name) if stats then - clusterio_api.send_json("exp_statistics:update", { players = { { name = name, stats = stats } } }) + clusterio_api.send_json("exp_player_statistics:update", { players = { { name = name, stats = stats } } }) end end --- Send what every player gained to the controller, in one update -function ExpStatistics.send_all() +function ExpPlayerStatistics.send_all() local players, count = {}, 0 for name in pairs(unsent) do count = count + 1 players[count] = { name = name, stats = take_unsent(name) } end if count > 0 then - clusterio_api.send_json("exp_statistics:update", { players = players }) + clusterio_api.send_json("exp_player_statistics:update", { players = players }) end end --- Send what a player gained and forget them, called when they leave --- @param name string -function ExpStatistics.send_and_forget(name) - ExpStatistics.send(name) +function ExpPlayerStatistics.send_and_forget(name) + ExpPlayerStatistics.send(name) totals[name] = nil end --- Called by the instance plugin over rcon when the server starts -function ExpStatistics.discard_unsent() +function ExpPlayerStatistics.discard_unsent() for name in pairs(unsent) do unsent[name] = nil end end --- Called by the instance plugin over rcon when a player joins ---- @param payload ExpStatistics.Totals -function ExpStatistics.receive_totals(payload) +--- @param payload ExpPlayerStatistics.Totals +function ExpPlayerStatistics.receive_totals(payload) local player = game.get_player(payload.name) if not player or not player.connected then return end totals[payload.name] = payload.stats end -return ExpStatistics +return ExpPlayerStatistics 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_statistics/module/locale/en.cfg b/exp_player_statistics/module/locale/en.cfg similarity index 100% rename from exp_statistics/module/locale/en.cfg rename to exp_player_statistics/module/locale/en.cfg diff --git a/exp_statistics/module/locale/zh-CN.cfg b/exp_player_statistics/module/locale/zh-CN.cfg similarity index 100% rename from exp_statistics/module/locale/zh-CN.cfg rename to exp_player_statistics/module/locale/zh-CN.cfg diff --git a/exp_statistics/module/locale/zh-TW.cfg b/exp_player_statistics/module/locale/zh-TW.cfg similarity index 100% rename from exp_statistics/module/locale/zh-TW.cfg rename to exp_player_statistics/module/locale/zh-TW.cfg diff --git a/exp_statistics/module/module.json b/exp_player_statistics/module/module.json similarity index 79% rename from exp_statistics/module/module.json rename to exp_player_statistics/module/module.json index be0eb2e2..31cbe2c8 100644 --- a/exp_statistics/module/module.json +++ b/exp_player_statistics/module/module.json @@ -1,5 +1,5 @@ { - "name": "exp_statistics", + "name": "exp_player_statistics", "load": [ "events.lua" ], 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_statistics/package.json b/exp_player_statistics/package.json similarity index 97% rename from exp_statistics/package.json rename to exp_player_statistics/package.json index 988ff0fa..37691e50 100644 --- a/exp_statistics/package.json +++ b/exp_player_statistics/package.json @@ -1,5 +1,5 @@ { - "name": "@expcluster/statistics", + "name": "@expcluster/player-statistics", "version": "7.0.1", "description": "Clusterio plugin keeping player statistics on the controller", "author": "Cooldude2606 ", diff --git a/exp_statistics/test/controller.test.js b/exp_player_statistics/test/controller.test.js similarity index 74% rename from exp_statistics/test/controller.test.js rename to exp_player_statistics/test/controller.test.js index e077fa38..a990133a 100644 --- a/exp_statistics/test/controller.test.js +++ b/exp_player_statistics/test/controller.test.js @@ -44,7 +44,7 @@ async function startPlugin(t2, databaseDirectory = t2.testdir()) { subscriptionHandle(Class, handler); }; - await entrypoint({ plugin: { name: "exp_statistics" }, controller, logger }); + await entrypoint({ plugin: { name: "exp_player_statistics" }, controller, logger }); const delta = (name, stats) => new messages.StatisticsDelta(name, stats); return { controller, @@ -54,6 +54,10 @@ async function startPlugin(t2, databaseDirectory = t2.testdir()) { ), get: name => handlers.get(messages.StatisticsGetRequest)(new messages.StatisticsGetRequest(name), fromInstance), list: () => handlers.get(messages.StatisticsListRequest)(new messages.StatisticsListRequest(), fromInstance), + /** The stored totals, without the values get adds from the clusterio user */ + stored: async name => (await handlers.get(messages.StatisticsListRequest)( + new messages.StatisticsListRequest(), fromInstance, + )).find(player => player.id === name)?.stats, subscription: lastRequestTimeMs => handlers.get(messages.PlayerStatisticsUpdatedEvent)({ lastRequestTimeMs }), }; } @@ -61,40 +65,51 @@ async function startPlugin(t2, databaseDirectory = t2.testdir()) { t.test("entrypoint attaches to save and shutdown", async t2 => { const { controller } = await startPlugin(t2); for (const hook of ["save", "shutdown"]) { - t2.ok([...controller.hooks[hook].attached].includes("exp_statistics"), `attached to ${hook}`); + t2.ok([...controller.hooks[hook].attached].includes("exp_player_statistics"), `attached to ${hook}`); } }); t.test("controller plugin", t2 => { t2.test("StatisticsUpdateEvent adds the deltas to the totals", async t3 => { - const { update, get } = await startPlugin(t3); - await update(["alice", { Kills: 2, Playtime: 5 }], ["bob", { Deaths: 1 }]); + const { update, stored } = await startPlugin(t3); + await update(["alice", { Kills: 2, AfkTime: 5 }], ["bob", { Deaths: 1 }]); await update(["alice", { Kills: 3, Deaths: 1 }]); - t3.strictSame((await get("alice")).stats, { Kills: 5, Playtime: 5, Deaths: 1 }, "deltas are summed per stat"); - t3.strictSame((await get("bob")).stats, { Deaths: 1 }); + t3.strictSame(await stored("alice"), { Kills: 5, AfkTime: 5, Deaths: 1 }, "deltas are summed per stat"); + t3.strictSame(await stored("bob"), { Deaths: 1 }); + }); + + t2.test("StatisticsGetRequest adds Playtime and JoinCount from the clusterio user", async t3 => { + const { controller, update, get } = await startPlugin(t3); + const user = controller.users.getOrCreateUser("alice"); + user.instanceStats.set(1, lib.PlayerStats.fromJSON({ join_count: 3, online_time_ms: 125.5 * 60e3 })); + user.recalculatePlayerStats(); + await update(["alice", { Kills: 1 }]); + + t3.strictSame((await get("alice")).stats, { Kills: 1, Playtime: 125, JoinCount: 3 }); + t3.strictSame((await get("bob")).stats, { Playtime: 0, JoinCount: 0 }, "no user and no record"); }); t2.test("StatisticsUpdateEvent merges deltas for the same player in one update", async t3 => { - const { update, get, state } = await startPlugin(t3); + const { update, stored, state } = await startPlugin(t3); await update(["alice", { Kills: 1 }], ["alice", { Kills: 2 }]); - t3.strictSame((await get("alice")).stats, { Kills: 3 }); + t3.strictSame(await stored("alice"), { Kills: 3 }); t3.equal(state.broadcasts.length, 1, "one broadcast"); t3.strictSame(state.broadcasts[0].updates.map(player => player.id), ["alice"], "with the player once"); }); t2.test("StatisticsUpdateEvent ignores values which are not finite or zero", async t3 => { - const { update, get } = await startPlugin(t3); + const { update, stored } = await startPlugin(t3); await update(["alice", { Kills: 1, Deaths: 0, Bad: Infinity, Worse: NaN }]); - t3.strictSame((await get("alice")).stats, { Kills: 1 }); + t3.strictSame(await stored("alice"), { Kills: 1 }); }); t2.test("StatisticsGetRequest gives an empty record for an unknown player", async t3 => { const { get, list } = await startPlugin(t3); const record = await get("carol"); t3.equal(record.id, "carol"); - t3.strictSame(record.stats, {}); + t3.strictSame(record.stats, { Playtime: 0, JoinCount: 0 }); t3.strictSame(await list(), [], "nothing is stored"); }); @@ -119,7 +134,7 @@ t.test("controller plugin", t2 => { await first.controller.hooks.save.invoke(); const second = await startPlugin(t3, directory); - t3.strictSame((await second.get("alice")).stats, { Kills: 4 }); + t3.strictSame(await second.stored("alice"), { Kills: 4 }); }); t2.end(); @@ -127,11 +142,11 @@ t.test("controller plugin", t2 => { t.test("permissions", async t2 => { const names = pluginDeclaration.permissions.map(permission => permission.name); - t2.strictSame(names, ["exp_statistics.view"]); + t2.strictSame(names, ["exp_player_statistics.view"]); for (const Message of [ messages.StatisticsGetRequest, messages.StatisticsListRequest, messages.PlayerStatisticsUpdatedEvent, ]) { - t2.equal(Message.permission, "exp_statistics.view", `${Message.name} needs view`); + t2.equal(Message.permission, "exp_player_statistics.view", `${Message.name} needs view`); } t2.equal(messages.StatisticsUpdateEvent.permission, null, "updates only come from instances"); t2.strictSame(messages.StatisticsUpdateEvent.src, "instance"); diff --git a/exp_statistics/test/instance.test.js b/exp_player_statistics/test/instance.test.js similarity index 89% rename from exp_statistics/test/instance.test.js rename to exp_player_statistics/test/instance.test.js index a9bc86e6..e7742994 100644 --- a/exp_statistics/test/instance.test.js +++ b/exp_player_statistics/test/instance.test.js @@ -54,18 +54,19 @@ async function startPlugin(t2, { status = "running", stats = { Kills: 3 } } = {} state.sent.length = 0; const pluginLogger = { ...logger, warn: message => state.warnings.push(message) }; - await entrypoint({ plugin: { name: "exp_statistics" }, instance, host: {}, logger: pluginLogger }); - return { instance, state, updateIPC: event => ipc.get("exp_statistics:update")(event) }; + await entrypoint({ plugin: { name: "exp_player_statistics" }, instance, host: {}, logger: pluginLogger }); + return { instance, state, updateIPC: event => ipc.get("exp_player_statistics:update")(event) }; } /** The payload of a recorded receive_totals command. */ function decodeTotals(command) { - const match = command.match(/^\/sc exp_statistics\.receive_totals\(helpers\.json_to_table\[(=+)\[(.*)\]\1\]\)$/s); + const pattern = /^\/sc exp_player_statistics\.receive_totals\(helpers\.json_to_table\[(=+)\[(.*)\]\1\]\)$/s; + const match = command.match(pattern); return JSON.parse(match[2]); } t.test("instance plugin", t2 => { - t2.test("exp_statistics:update is forwarded to the controller", async t3 => { + t2.test("exp_player_statistics:update is forwarded to the controller", async t3 => { const { state, updateIPC } = await startPlugin(t3); await updateIPC({ players: [{ name: "alice", stats: { Kills: 1 } }, { name: "bob", stats: { Deaths: 2 } }] }); @@ -123,8 +124,8 @@ t.test("instance plugin", t2 => { await instance.hooks.start.invoke(); await instance.hooks.stop.invoke(); t3.strictSame(state.rcons, [ - "/sc exp_statistics.discard_unsent()", - "/sc exp_statistics.send_all()", + "/sc exp_player_statistics.discard_unsent()", + "/sc exp_player_statistics.send_all()", ]); }); diff --git a/exp_statistics/test/module.test.js b/exp_player_statistics/test/module.test.js similarity index 100% rename from exp_statistics/test/module.test.js rename to exp_player_statistics/test/module.test.js diff --git a/exp_statistics/test/module/control.lua b/exp_player_statistics/test/module/control.lua similarity index 93% rename from exp_statistics/test/module/control.lua rename to exp_player_statistics/test/module/control.lua index a2fec8f2..e874abbe 100644 --- a/exp_statistics/test/module/control.lua +++ b/exp_player_statistics/test/module/control.lua @@ -1,4 +1,4 @@ -local Suite = ... --- @type Suite +local Suite = ... --- @type Suite Suite.test("increment() adds to the unsent statistics", function(env) local Stats = env.Statistics @@ -20,7 +20,7 @@ Suite.test("send_all() sends every player with changes in one update", function( Stats.send_all() Suite.eq(#env.sent, 1, "one update") - Suite.eq(assert(env.sent[1]).channel, "exp_statistics:update", "on the update channel") + Suite.eq(assert(env.sent[1]).channel, "exp_player_statistics:update", "on the update channel") local players = assert(env.sent[1]).data.players table.sort(players, function(a, b) return a.name < b.name end) Suite.eq(players, { @@ -53,7 +53,7 @@ Suite.test("send() sends one player", function(env) Stats.increment(alice, "Kills") Stats.increment(bob, "Kills") Stats.send("alice") - Suite.eq(env.sent, { { channel = "exp_statistics:update", data = { players = { { name = "alice", stats = { Kills = 1 } } } } } }, "only that player") + Suite.eq(env.sent, { { channel = "exp_player_statistics:update", data = { players = { { name = "alice", stats = { Kills = 1 } } } } } }, "only that player") env.reset_log() Stats.send("carol") diff --git a/exp_statistics/test/module/env.lua b/exp_player_statistics/test/module/env.lua similarity index 82% rename from exp_statistics/test/module/env.lua rename to exp_player_statistics/test/module/env.lua index 670ed69b..03d8e104 100644 --- a/exp_statistics/test/module/env.lua +++ b/exp_player_statistics/test/module/env.lua @@ -24,23 +24,23 @@ local event_names = { "on_player_used_capsule", "on_player_repaired_entity", } ---- @class ExpStatistics.TestEnv : Stubs ---- @field Statistics ExpStatistics A fresh copy of the statistics module +--- @class ExpPlayerStatistics.TestEnv : Stubs +--- @field Statistics ExpPlayerStatistics A fresh copy of the statistics module --- @field handlers { events: table, on_nth_tick: table } From events.lua --- @field force fun(name: string): table A force stub with the online players added to it --- @field raise fun(name: string, event: table) Call the handler of an event by its name ---- @field add_player fun(params: ExpStatistics.TestPlayerParams): ExpStatistics.TestPlayer +--- @field add_player fun(params: ExpPlayerStatistics.TestPlayerParams): ExpPlayerStatistics.TestPlayer ---- @class ExpStatistics.TestPlayerParams : Stubs.PlayerParams +--- @class ExpPlayerStatistics.TestPlayerParams : Stubs.PlayerParams --- @field afk_time number? Defaults to 0 --- @field force string? Defaults to "player" ---- @class ExpStatistics.TestPlayer : Stubs.Player +--- @class ExpPlayerStatistics.TestPlayer : Stubs.Player --- @field afk_time number --- @field force table return Framework.suite(function(env) - --- @cast env ExpStatistics.TestEnv + --- @cast env ExpPlayerStatistics.TestEnv local events = {} for index, name in ipairs(event_names) do events[name] = 10 + index @@ -61,10 +61,10 @@ return Framework.suite(function(env) }, } env.extend_requires{ - ["modules/exp_statistics/config"] = assert(loadfile(plugin_root .. "/module/config.lua"))(), + ["modules/exp_player_statistics/config"] = assert(loadfile(plugin_root .. "/module/config.lua"))(), } - env.Statistics = assert(loadfile(plugin_root .. "/module/control.lua"))() --- @type ExpStatistics - env.extend_requires{ ["modules/exp_statistics/control"] = env.Statistics } + env.Statistics = assert(loadfile(plugin_root .. "/module/control.lua"))() --- @type ExpPlayerStatistics + env.extend_requires{ ["modules/exp_player_statistics/control"] = env.Statistics } env.handlers = assert(loadfile(plugin_root .. "/module/events.lua"))() local forces = {} @@ -79,7 +79,7 @@ return Framework.suite(function(env) local add_player = env.add_player function env.add_player(params) - local player = add_player(params) --[[@as ExpStatistics.TestPlayer]] + local player = add_player(params) --[[@as ExpPlayerStatistics.TestPlayer]] local force = env.force(params.force or "player") rawset(player, "afk_time", params.afk_time or 0) rawset(player, "force", force) diff --git a/exp_statistics/test/module/events.lua b/exp_player_statistics/test/module/events.lua similarity index 96% rename from exp_statistics/test/module/events.lua rename to exp_player_statistics/test/module/events.lua index 97b0ddd9..9638e852 100644 --- a/exp_statistics/test/module/events.lua +++ b/exp_player_statistics/test/module/events.lua @@ -1,4 +1,4 @@ -local Suite = ... --- @type Suite +local Suite = ... --- @type Suite Suite.test("counters go up by one for online players", function(env) local Stats = env.Statistics @@ -21,7 +21,7 @@ Suite.test("a player is counted once per map", function(env) Suite.eq(env.Statistics.get(alice, "MapsPlayed"), 1, "maps played") end) -Suite.test("playtime and afk time go up each minute", function(env) +Suite.test("afk time goes up each minute, playtime comes from clusterio", function(env) local Stats = env.Statistics local alice = env.add_player{ name = "alice" } local bob = env.add_player{ name = "bob", afk_time = 6 * 3600 } @@ -30,7 +30,7 @@ Suite.test("playtime and afk time go up each minute", function(env) tick() tick() - Suite.eq(Stats.get(alice, "Playtime"), 2, "playtime") + Suite.eq(Stats.get(alice, "Playtime"), 0, "playtime is not counted") Suite.eq(Stats.get(alice, "AfkTime"), 0, "not afk") Suite.eq(Stats.get(bob, "AfkTime"), 2, "afk") end) diff --git a/exp_statistics/tsconfig.browser.json b/exp_player_statistics/tsconfig.browser.json similarity index 100% rename from exp_statistics/tsconfig.browser.json rename to exp_player_statistics/tsconfig.browser.json diff --git a/exp_statistics/tsconfig.json b/exp_player_statistics/tsconfig.json similarity index 100% rename from exp_statistics/tsconfig.json rename to exp_player_statistics/tsconfig.json diff --git a/exp_statistics/tsconfig.node.json b/exp_player_statistics/tsconfig.node.json similarity index 100% rename from exp_statistics/tsconfig.node.json rename to exp_player_statistics/tsconfig.node.json 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 =
player.id} + pagination={tableState.pagination} + onChange={tableState.onChange} + scroll={{ x: "max-content" }} + /> + ; +} diff --git a/exp_player_statistics/web/components/UserStatistics.tsx b/exp_player_statistics/web/components/UserStatistics.tsx new file mode 100644 index 00000000..02c24326 --- /dev/null +++ b/exp_player_statistics/web/components/UserStatistics.tsx @@ -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(); + + useEffect(() => { + if (canView) { + control.send(new StatisticsGetRequest(name)) + .then(setRecord) + .catch(notifyErrorHandler("Error loading player statistics")); + } + }, [name, canView]); + + if (!canView || !record) { + return null; + } + + return <> + + + {statistics.map(([statistic, title]) => + {formatValue(statistic, record.stats[statistic] ?? 0)} + )} + + ; +} diff --git a/exp_statistics/web/index.tsx b/exp_player_statistics/web/index.tsx similarity index 70% rename from exp_statistics/web/index.tsx rename to exp_player_statistics/web/index.tsx index fcfdb8bc..7f197263 100644 --- a/exp_statistics/web/index.tsx +++ b/exp_player_statistics/web/index.tsx @@ -5,6 +5,7 @@ import * as lib from "@clusterio/lib"; import * as messages from "../messages.js"; import StatisticsPage from "./components/StatisticsPage"; +import UserStatistics from "./components/UserStatistics"; let players: lib.MapSubscriber | undefined; @@ -14,11 +15,15 @@ export function usePlayerStatistics() { } export default function registerPlugin(context: WebPluginContext) { - context.control.hooks.pages.attach("exp_statistics", () => [ + context.control.hooks.extensionComponents.attach("exp_player_statistics", () => ({ + UserViewPage: UserStatistics, + })); + + context.control.hooks.pages.attach("exp_player_statistics", () => [ { - path: "/exp_statistics", + path: "/exp_player_statistics", sidebarName: "Statistics", - permission: "exp_statistics.view", + permission: "exp_player_statistics.view", content: , }, ]); diff --git a/exp_player_statistics/web/statistics.ts b/exp_player_statistics/web/statistics.ts new file mode 100644 index 00000000..f9261634 --- /dev/null +++ b/exp_player_statistics/web/statistics.ts @@ -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(); +} diff --git a/exp_statistics/webpack.config.cjs b/exp_player_statistics/webpack.config.cjs similarity index 88% rename from exp_statistics/webpack.config.cjs rename to exp_player_statistics/webpack.config.cjs index daeda18c..7834f626 100644 --- a/exp_statistics/webpack.config.cjs +++ b/exp_player_statistics/webpack.config.cjs @@ -13,8 +13,8 @@ module.exports = (env = {}) => merge(common(env), { }, plugins: [ new webpack.container.ModuleFederationPlugin({ - name: "exp_statistics", - library: { type: "window", name: "plugin_exp_statistics" }, + name: "exp_player_statistics", + library: { type: "window", name: "plugin_exp_player_statistics" }, exposes: { "./": "./index.ts", "./package.json": "./package.json", diff --git a/exp_scenario/module/gui/player_stats.lua b/exp_scenario/module/gui/player_stats.lua index 2a08cbc0..a14c5acd 100644 --- a/exp_scenario/module/gui/player_stats.lua +++ b/exp_scenario/module/gui/player_stats.lua @@ -6,7 +6,7 @@ local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") local ElementsExtra = require("modules/exp_scenario/gui/elements") local Roles = require("modules/exp_roles") -local ExpStatistics = require("modules/exp_statistics") +local ExpPlayerStatistics = require("modules/exp_player_statistics") --- @class ExpGui_PlayerStats.elements local Elements = {} @@ -26,7 +26,7 @@ local short_time_zero, format_number_zero = short_time_format(0), format_number_ --- @param default number --- @return number local function get_stat(player, statistic, default) - local value = ExpStatistics.get(player, statistic) + local value = ExpPlayerStatistics.get(player, statistic) return value == 0 and default or value end @@ -113,14 +113,14 @@ Elements.player_stats_table = Gui.define("player_stats/data_table") local labels = {} -- Add all standalone stats - for _, stat_name in ipairs(ExpStatistics.display_order) do + for _, stat_name in ipairs(ExpPlayerStatistics.display_order) do Elements.table_label(data_table, { caption = { "exp-statistics." .. stat_name }, tooltip = { "exp-statistics." .. stat_name .. "-tooltip" }, width = 135, }) labels[stat_name] = Elements.table_label(data_table, { - caption = { "exp-gui_readme.data-format", ExpStatistics.format(stat_name, 0, true), ExpStatistics.units[stat_name] or "" }, + caption = { "exp-gui_readme.data-format", ExpPlayerStatistics.format(stat_name, 0, true), ExpPlayerStatistics.units[stat_name] or "" }, tooltip = { "exp-statistics." .. stat_name .. "-tooltip" }, width = 105, }) @@ -151,9 +151,9 @@ function Elements.player_stats_table.refresh(data_table, player) local labels = Elements.player_stats_table.data[data_table] -- Update all standalone stats - for _, stat_name in ipairs(ExpStatistics.display_order) do - local value = ExpStatistics.format(stat_name, ExpStatistics.get(player, stat_name), true) - labels[stat_name].caption = { "exp-gui_readme.data-format", value, ExpStatistics.units[stat_name] or "" } + for _, stat_name in ipairs(ExpPlayerStatistics.display_order) do + local value = ExpPlayerStatistics.format(stat_name, ExpPlayerStatistics.get(player, stat_name), true) + labels[stat_name].caption = { "exp-gui_readme.data-format", value, ExpPlayerStatistics.units[stat_name] or "" } end -- Update all computed stats diff --git a/exp_scenario/module/gui/readme.lua b/exp_scenario/module/gui/readme.lua index 6b9e15fb..c0f55621 100644 --- a/exp_scenario/module/gui/readme.lua +++ b/exp_scenario/module/gui/readme.lua @@ -6,7 +6,7 @@ local ExpUtil = require("modules/exp_util") local Gui = require("modules/exp_gui") local Roles = require("modules/exp_roles") local Commands = require("modules/exp_commands") -local ExpStatistics = require("modules/exp_statistics") +local ExpPlayerStatistics = require("modules/exp_player_statistics") local ExpServerList = require("modules/exp_server_list") local community = require("modules/exp_scenario/config/community_details").config @@ -471,12 +471,14 @@ define_tab( container.add{ type = "flow" } local scroll_pane = Elements.title_table_scroll(container) - local statistics = Elements.title_table(scroll_pane, 250, { "exp-gui_readme.data-tab" }, 4) - for _, name in ipairs(ExpStatistics.display_order) do - local value = ExpStatistics.format(name, ExpStatistics.get(player_name, name)) + local statistics = scroll_pane.add{ type = "table", column_count = 4, style = "bordered_table" } + statistics.style.cell_padding = 0 + statistics.style.horizontally_stretchable = true + for _, name in ipairs(ExpPlayerStatistics.display_order) do + local value = ExpPlayerStatistics.format(name, ExpPlayerStatistics.get(player_name, name), true) local tooltip = { "exp-statistics." .. name .. "-tooltip" } Gui.elements.centered_label(statistics, 150, { "exp-statistics." .. name }, tooltip) - Gui.elements.centered_label(statistics, 130, { "exp-gui_readme.data-format", value, ExpStatistics.units[name] or "" }, tooltip) + Gui.elements.centered_label(statistics, 130, { "exp-gui_readme.data-format", value, ExpPlayerStatistics.units[name] or "" }, tooltip) end return container diff --git a/exp_scenario/module/module.json b/exp_scenario/module/module.json index 1c011673..91878e8b 100644 --- a/exp_scenario/module/module.json +++ b/exp_scenario/module/module.json @@ -12,7 +12,7 @@ "exp_roles": "*", "exp_reports": "*", "exp_server_list": "*", - "exp_statistics": "*", + "exp_player_statistics": "*", "exp_gui": "*", "exp_commands": "*" } diff --git a/exp_scenario/package.json b/exp_scenario/package.json index 0997b985..28694466 100644 --- a/exp_scenario/package.json +++ b/exp_scenario/package.json @@ -52,7 +52,7 @@ "@expcluster/permission-groups": "workspace:^", "@expcluster/roles": "workspace:^", "@expcluster/server-list": "workspace:^", - "@expcluster/statistics": "workspace:^", + "@expcluster/player-statistics": "workspace:^", "@sinclair/typebox": "catalog:" }, "publishConfig": { diff --git a/exp_statistics/module/events.lua b/exp_statistics/module/events.lua deleted file mode 100644 index 6262d5ad..00000000 --- a/exp_statistics/module/events.lua +++ /dev/null @@ -1,188 +0,0 @@ ---[[-- ExpStatistics - Events -Tracks the statistics enabled in config.lua, and sends them to the controller. -]] - -local ExpStatistics = require("modules/exp_statistics/control") -local config = ExpStatistics.config - -local increment = ExpStatistics.increment -local floor = math.floor -local afk_required = 5 * 3600 -- 5 minutes -local send_interval = 5 * 3600 -- 5 minutes - ---- @type table -local events = {} - ---- Add a handler, chained after any other for the same event ---- @param event_id defines.events ---- @param handler fun(event: EventData) -local function add(event_id, handler) - local existing = events[event_id] - if existing then - events[event_id] = function(event) - existing(event) - handler(event) - end - else - events[event_id] = handler - end -end - ---- Get the player of an event if they are online ---- @param event { player_index: uint? } ---- @return LuaPlayer? -local function get_online_player(event) - if not event.player_index then return nil end - local player = game.players[event.player_index] - if not player or not player.valid or not player.connected then return nil end - return player -end - ---- @param event EventData.on_player_left_game -add(defines.events.on_player_left_game, function(event) - local player = game.players[event.player_index] - ExpStatistics.send_and_forget(player.name) -end) - ---- @type table -local on_nth_tick = { - [send_interval] = ExpStatistics.send_all, -} - -if config.MapsPlayed then - --- @param event EventData.on_player_created - add(defines.events.on_player_created, function(event) - increment(game.players[event.player_index], "MapsPlayed") - end) -end - -if config.Playtime or config.AfkTime then - on_nth_tick[3600] = function() - if game.tick == 0 then return end - for _, player in pairs(game.connected_players) do - if config.Playtime then increment(player, "Playtime") end - if config.AfkTime and player.afk_time > afk_required then increment(player, "AfkTime") end - end - end -end - -if config.DistanceTravelled then - --- @param event EventData.on_player_changed_position - add(defines.events.on_player_changed_position, function(event) - local player = get_online_player(event) - if not player or player.afk_time > afk_required then return end - increment(player, "DistanceTravelled") - end) -end - -if config.MachinesRemoved or config.TreesDestroyed or config.OreMined then - --- @param event EventData.on_marked_for_deconstruction | EventData.on_player_mined_entity - local function on_removed(event) - local player = get_online_player(event) - if not player then return end - local entity = event.entity - if not entity.valid then return end - if entity.type == "resource" then - if config.OreMined then increment(player, "OreMined") end - elseif entity.type == "tree" then - if config.TreesDestroyed then increment(player, "TreesDestroyed") end - elseif entity.force == player.force then - if config.MachinesRemoved then increment(player, "MachinesRemoved") end - end - end - add(defines.events.on_marked_for_deconstruction, on_removed) - add(defines.events.on_player_mined_entity, on_removed) -end - ---- Vehicles which credit their driver, or failing that their passenger, with what they hit -local crewed_vehicles = { car = true, ["spider-vehicle"] = true, locomotive = true } - ---- Get the connected player behind the cause of damage or a death, nil for turrets, trains without a crew and the like ---- @param cause LuaEntity? ---- @return LuaPlayer? -local function get_cause_player(cause) - if not cause or not cause.valid then return nil end - - local occupant = cause --- @type LuaEntity | LuaPlayer - if crewed_vehicles[cause.type] then - local crew = cause.get_driver() or cause.get_passenger() - if not crew then return nil end - occupant = crew - end - - local player --- @type LuaPlayer? - if occupant.object_name == "LuaPlayer" then - player = occupant --[[@as LuaPlayer]] - elseif occupant.type == "character" then - player = occupant.player - end - - if not player or not player.valid or not player.connected then return nil end - return player -end - ---- Check that damage or a death counts against an entity, it must be hostile to the player ---- @param entity LuaEntity ---- @param player LuaPlayer ---- @return boolean -local function is_hostile(entity, player) - return entity.valid and entity.force ~= player.force and entity.force.name ~= "neutral" -end - -if config.DamageDealt then - --- @param event EventData.on_entity_damaged - add(defines.events.on_entity_damaged, function(event) - local player = get_cause_player(event.cause) - if not player or not is_hostile(event.entity, player) then return end - increment(player, "DamageDealt", floor(event.final_damage_amount)) - end) -end - -if config.Kills then - --- @param event EventData.on_entity_died - add(defines.events.on_entity_died, function(event) - local player = get_cause_player(event.cause) - if not player or not is_hostile(event.entity, player) then return end - increment(player, "Kills") - end) -end - ---- Increment a statistic for every online player on a force ---- @param force LuaForce? ---- @param statistic string -local function increment_force(force, statistic) - if not force or not force.valid then return end - for _, player in pairs(force.connected_players) do - increment(player, statistic) - end -end - -if config.RocketsLaunched then - --- @param event EventData.on_rocket_launched - add(defines.events.on_rocket_launched, function(event) - local silo = event.rocket_silo - if not silo or not silo.valid then return end - increment_force(silo.force --[[@as LuaForce]], "RocketsLaunched") - end) -end - -if config.ResearchCompleted then - --- @param event EventData.on_research_finished - add(defines.events.on_research_finished, function(event) - local research = event.research - if event.by_script or not research or not research.valid then return end - increment_force(research.force, "ResearchCompleted") - end) -end - -for statistic, event_id in pairs(config.counters) do - add(event_id, function(event) - local player = get_online_player(event) - if player then increment(player, statistic) end - end) -end - -return { - events = events, - on_nth_tick = on_nth_tick, -} diff --git a/exp_statistics/module/globals.lua b/exp_statistics/module/globals.lua deleted file mode 100644 index b470f0bd..00000000 --- a/exp_statistics/module/globals.lua +++ /dev/null @@ -1,4 +0,0 @@ ---- @diagnostic disable: global-in-non-module - --- Access using `/sc exp_statistics.foo()` -exp_statistics = require("modules/exp_statistics/control") diff --git a/exp_statistics/module/module_exports.lua b/exp_statistics/module/module_exports.lua deleted file mode 100644 index f5ab723b..00000000 --- a/exp_statistics/module/module_exports.lua +++ /dev/null @@ -1,2 +0,0 @@ --- Access the exports from other modules using require("modules/exp_statistics") -return require("modules/exp_statistics/control") diff --git a/exp_statistics/web/components/StatisticsPage.tsx b/exp_statistics/web/components/StatisticsPage.tsx deleted file mode 100644 index 77ba9142..00000000 --- a/exp_statistics/web/components/StatisticsPage.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React from "react"; -import { Table } from "antd"; -import type { ColumnsType } from "antd/es/table"; - -import { PageHeader, PageLayout, useColumnSearch, useTableQueryState } from "@clusterio/web_ui"; - -import { PlayerStatistics } from "../../messages.js"; -import { usePlayerStatistics } from ".."; - -const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; - -/** Same order and names as module/config.lua and module/locale/en.cfg */ -const statistics: [name: string, title: string][] = [ - ["Playtime", "Playtime"], - ["AfkTime", "AFK Time"], - ["MapsPlayed", "Maps Played"], - ["JoinCount", "Join Count"], - ["ChatMessages", "Messages"], - ["CommandsUsed", "Commands"], - ["RocketsLaunched", "Rockets Launched"], - ["ResearchCompleted", "Research Completed"], - ["MachinesBuilt", "Machines Built"], - ["MachinesRemoved", "Machines Removed"], - ["TilesBuilt", "Tiles Placed"], - ["TilesRemoved", "Tiles Removed"], - ["TreesDestroyed", "Trees Destroyed"], - ["OreMined", "Ore Mined"], - ["ItemsCrafted", "Items Crafted"], - ["ItemsPickedUp", "Items Picked Up"], - ["Kills", "Kills"], - ["Deaths", "Deaths"], - ["DamageDealt", "Damage Dealt"], - ["DistanceTravelled", "Distance Travelled"], - ["CapsulesUsed", "Capsules Used"], - ["EntityRepaired", "Machines Repaired"], - ["DeconstructionPlannerUsed", "Decon Planner Used"], - ["MapTagsMade", "Map Tags Created"], -]; - -/** Measured in minutes */ -const timeStatistics = new Set(["Playtime", "AfkTime"]); - -function formatValue(name: string, value: number) { - if (timeStatistics.has(name)) { - return `${Math.floor(value / 60)}h ${value % 60}m`; - } - return value.toLocaleString(); -} - -export default function StatisticsPage() { - const [players, synced] = usePlayerStatistics(); - const tableState = useTableQueryState({ - namespace: "statistics", - defaultSortKey: "Playtime", - defaultSortOrder: "descend", - pagination: { defaultPageSize: 50 }, - }); - const playerSearch = useColumnSearch(tableState, "player", player => player.id, "Search players"); - - const columns: ColumnsType = [ - { - title: "Player", - key: "player", - fixed: "left", - render: (_, player) => player.id, - sorter: (a, b) => strcmp(a.id, b.id), - sortOrder: tableState.sortOrder("player"), - filteredValue: tableState.filteredValue("player"), - ...playerSearch, - }, - ...statistics.map(([name, title]): ColumnsType[number] => ({ - title, - key: name, - align: "right", - render: (_, player) => formatValue(name, player.stats[name] ?? 0), - sorter: (a, b) => (a.stats[name] ?? 0) - (b.stats[name] ?? 0), - sortDirections: ["descend", "ascend"], - sortOrder: tableState.sortOrder(name), - })), - ]; - - return - -
player.id} - pagination={tableState.pagination} - onChange={tableState.onChange} - scroll={{ x: "max-content" }} - /> - ; -} diff --git a/tsconfig.json b/tsconfig.json index 9225e5c9..09f58f29 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ { "path": "./exp_scenario/" }, { "path": "./exp_server_list/" }, { "path": "./exp_server_ups/" }, - { "path": "./exp_statistics/" }, + { "path": "./exp_player_statistics/" }, { "path": "./exp_util/" }, ], } From 55bdf72fffe1ac7e96389cb62c3cc7b5d8743a67 Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:07:01 +0200 Subject: [PATCH 5/5] Merge pull request #504 from bbassie/refactor/remove-exp-legacy Remove the exp_legacy plugin --- exp_legacy/.npmignore | 1 - exp_legacy/index.ts | 8 - exp_legacy/instance.ts | 3 - exp_legacy/module/config/_file_loader.lua | 8 - exp_legacy/module/control.lua | 46 - exp_legacy/module/expcore/datastore.lua | 884 ------------------ exp_legacy/module/expcore/player_data.lua | 152 --- exp_legacy/module/locale/en.cfg | 81 -- exp_legacy/module/locale/zh-CN.cfg | 89 -- exp_legacy/module/locale/zh-TW.cfg | 89 -- exp_legacy/module/module.json | 13 - exp_legacy/module/module_exports.lua | 3 - exp_legacy/module/utils/event.lua | 86 -- exp_legacy/package.json | 51 - exp_legacy/tsconfig.browser.json | 4 - exp_legacy/tsconfig.json | 7 - exp_legacy/tsconfig.node.json | 5 - exp_legacy/web/index.tsx | 0 exp_legacy/webpack.config.cjs | 29 - exp_scenario/module/commands/_rcon.lua | 5 +- .../gui/debug/expcore_datastore_view.lua | 132 --- exp_scenario/module/gui/debug/main_view.lua | 1 - exp_scenario/module/locale/en.cfg | 12 + exp_scenario/module/locale/zh-CN.cfg | 12 + exp_scenario/module/locale/zh-TW.cfg | 12 + exp_server_list/README.md | 2 +- tsconfig.json | 1 - 27 files changed, 39 insertions(+), 1697 deletions(-) delete mode 100644 exp_legacy/.npmignore delete mode 100644 exp_legacy/index.ts delete mode 100644 exp_legacy/instance.ts delete mode 100644 exp_legacy/module/config/_file_loader.lua delete mode 100644 exp_legacy/module/control.lua delete mode 100644 exp_legacy/module/expcore/datastore.lua delete mode 100644 exp_legacy/module/expcore/player_data.lua delete mode 100644 exp_legacy/module/locale/en.cfg delete mode 100644 exp_legacy/module/locale/zh-CN.cfg delete mode 100644 exp_legacy/module/locale/zh-TW.cfg delete mode 100644 exp_legacy/module/module.json delete mode 100644 exp_legacy/module/module_exports.lua delete mode 100644 exp_legacy/module/utils/event.lua delete mode 100644 exp_legacy/package.json delete mode 100644 exp_legacy/tsconfig.browser.json delete mode 100644 exp_legacy/tsconfig.json delete mode 100644 exp_legacy/tsconfig.node.json delete mode 100644 exp_legacy/web/index.tsx delete mode 100644 exp_legacy/webpack.config.cjs delete mode 100644 exp_scenario/module/gui/debug/expcore_datastore_view.lua diff --git a/exp_legacy/.npmignore b/exp_legacy/.npmignore deleted file mode 100644 index 36e20175..00000000 --- a/exp_legacy/.npmignore +++ /dev/null @@ -1 +0,0 @@ -# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/ \ No newline at end of file diff --git a/exp_legacy/index.ts b/exp_legacy/index.ts deleted file mode 100644 index 0e72b72f..00000000 --- a/exp_legacy/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as lib from "@clusterio/lib"; - -export const plugin: lib.PluginDeclaration = { - name: "exp_legacy", - title: "ExpGaming - Legacy", - description: "Clusterio plugin implementing the legacy v6 scenario updated for factorio 2.0", - instanceEntrypoint: "./dist/node/instance.js", -}; diff --git a/exp_legacy/instance.ts b/exp_legacy/instance.ts deleted file mode 100644 index 95a41ff8..00000000 --- a/exp_legacy/instance.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Empty because an instance entrypoint must be defined for a module to be injected -// This requirement may change in the future to allow for standalone modules -export default async function () {} diff --git a/exp_legacy/module/config/_file_loader.lua b/exp_legacy/module/config/_file_loader.lua deleted file mode 100644 index 3d029c51..00000000 --- a/exp_legacy/module/config/_file_loader.lua +++ /dev/null @@ -1,8 +0,0 @@ ---- This contains a list of all files that will be loaded and the order they are loaded in; --- to stop a file from loading add "--" in front of it, remove the "--" to have the file be loaded; --- config files should be loaded after all modules are loaded; --- core files should be required by modules and not be present in this list; --- @config File-Loader -return { - "expcore.player_data", -- must be loaded first to register event handlers -} diff --git a/exp_legacy/module/control.lua b/exp_legacy/module/control.lua deleted file mode 100644 index f5033af3..00000000 --- a/exp_legacy/module/control.lua +++ /dev/null @@ -1,46 +0,0 @@ ---- Please go to ./config if you want to change settings, each file is commented with what it does --- if it is not in ./config then you should not attempt to change it unless you know what you are doing --- all files which are loaded (including the config files) are present in ./config/file_loader.lua --- this file is the landing point for all scenarios please DO NOT edit directly, further comments are to aid development - -log("[START] -----| Explosive Gaming Scenario Loader |-----") -log("[INFO] Setting up lua environment") - --- Please go to config/file_loader.lua to edit the files that are loaded -log("[INFO] Reading loader config") -local files = require("modules.exp_legacy.config._file_loader") - --- Error handler for loading files -local errors = {} -local error_count = 0 -local error_format = "[ERROR] %s :: %s" -local currently_loading = nil -local function error_handler(err) - error_count = error_count + 1 - if err:find("module " .. currently_loading .. " not found;", nil, true) then - log("[ERROR] File not found: " .. currently_loading) - errors[error_count] = error_format:format(currently_loading, err) - else - log("[ERROR] Failed to load: " .. currently_loading) - errors[error_count] = debug.traceback(error_format:format(currently_loading, err)) - end - return err -end - --- Loads all files from the config and logs that they are loaded -local total_file_count = string.format("%3d", #files) -for index, path in pairs(files) do - currently_loading = path - log(string.format("[INFO] Loading file %3d/%s (%s)", index, total_file_count, path)) - xpcall(require, error_handler, "modules.exp_legacy." .. path) -end - --- Logs all errors again to make it make it easy to find -log("[INFO] All files loaded with " .. error_count .. " errors:") -for _, error in ipairs(errors) do log(error) end - -log("[END] -----| Explosive Gaming Scenario Loader |-----") - ---- Register all event handlers via event handler -local Event = require("modules/exp_legacy/utils/event") -return Event.real_handlers diff --git a/exp_legacy/module/expcore/datastore.lua b/exp_legacy/module/expcore/datastore.lua deleted file mode 100644 index 08fe4de1..00000000 --- a/exp_legacy/module/expcore/datastore.lua +++ /dev/null @@ -1,884 +0,0 @@ ---[[-- Core Module - Datastore -- A module used to store data in the global table with the option to have it sync to an external source. -@core Datastore -@alias DatastoreManager - -@usage-- Types of Datastore --- This datastore will not save data externally and can be used to watch for updates on values within it --- A common use might be to store data for a gui and only update the gui when a value changes -local LocalDatastore = Datastore.connect('LocalDatastore') - --- This datastore will allow you to use the save and request method, this allows you to have persistent data --- Should be used over auto save as it creates less save requests, but this means you need to tell the data to be saved --- We use this type for player data as we know the data only needs to be saved when the player leaves -local PersistentDatastore = Datastore.connect('PersistentDatastore', true) -- save_to_disk - --- This datastore is the same as above but the save method will be called automatically when ever you change a value --- An auto save datastore should be used if the data does not change often, this can be global settings and things of that sort --- If it is at all possible to setup events to unload and/or save the data then this is preferable -local AutosaveDatastore = Datastore.connect('AutosaveDatastore', true, true) -- save_to_disk, auto_save - --- Finally you can have a datastore that propagates its changes to all other connected servers, this means request does not need to be used --- This should be used when you might have data conflicts while saving, this is done by pushing the saved value to all active servers --- The request method has little use after server start as any external changes to the value will be pushed automatically --- Auto save can also be used with this type and you should follow the same guidelines above for when this should be avoided -local PropagateDatastore = Datastore.connect('PropagateDatastore', true, false, true) -- save_to_disk, propagate_changes - -@usage-- Using Datastores Locally --- Once you have your datastore connection setup, any further requests with connect will return the same datastore --- This is important to know because the settings passed as parameters you have an effect when it is first created - --- One useful thing that you might want to set up before runtime is a serializer, this will convert non string keys into strings --- This serializer will allow use to pass a player object and still have it serialized to the players name -local ExampleData = Datastore.connect('ExampleData') -ExampleData:set_serializer(function(rawKey) - return rawKey.name -end) - --- If we want to get data from the datastore we can use get or get_all -local value = ExampleData:get(player, defaultValue) -local values = ExampleData:get_all() - --- If we want to set data then we can use set, increment, update, or update_all -ExampleData:set(player, 10) -ExampleData:increment(player) -ExampleData:update(player, function(player_name, value) - return value * 2 -end) -ExampleData:update_all(function(player_name, value) - return value * 2 -end) - --- If we want to remove data then we use remove -ExampleData:remove(player) - --- We can also listen for updates to a value done by any of the above methods with on_update -ExampleData:on_update(function(player_name, value) - game.print(player_name..' has had their example data updated to '..tostring(value)) -end) - -@usage-- Using Datastore Externally --- If save_to_disk is used then this opens up the option for persistent data which you can request, save, and remove --- All of the local methods are still usable put now there is the option for extra events --- In order for this to work there must be an external script to read datastore.pipe and inject with Datastore.ingest - --- To request data you would use request and the on_load event, this event can be used to modify data before it is used -ExampleData:request(player) -ExampleData:on_load(function(player_name, value) - game.print('Loaded example data for '..player_name) - -- A value can be returned here to overwrite the received value -end) - --- To save data you would use save and the on_save event, this event can be used to modify data before it is saved -ExampleData:save(player) -ExampleData:on_save(function(player_name, value) - game.print('Saved example data for '..player_name) - -- A value can be returned here to overwrite the value which is saved -end) - --- To remove data locally but not externally, like if a player logs off, you would use unload and on_unload -ExampleData:unload(player) -ExampleData:on_unload(function(player_name, value) - game.print('Unloaded example data for '..player_name) - -- Any return is ignored, this is event is for cleaning up other data -end) - -@usage-- Using Datastore Messaging --- The message action can be used regardless of save_to_disk being set as no data is saved, but an external script is still required --- These messages can be used to send data to other servers which doesnt need to be saved such as shouts or commands --- Using messages is quite simple only using message and on_message -ExampleData:message(key, message) -ExampleData:on_message(function(key, message) - game.print('Received message '..message) -end) - -@usage-- Combined Datastores --- A combined datastore is a datastore which stores its data inside of another datastore --- This means that the data is stored more efficiently in the external database and less requests need to be made --- To understand how combined datastores work think of each key in the parent as a table where the sub datastore is a key in that table --- Player data is the most used version of the combined datastore, below is how the player data module is setup -local PlayerData = Datastore.connect('PlayerData', true) -- saveToDisk -PlayerData:set_serializer(Datastore.name_serializer) -- use player name as key -PlayerData:combine('Statistics') -PlayerData:combine('Settings') -PlayerData:combine('Required') - --- You can then further combine datastores to any depth, below we add some possible settings and statistics that we might use --- Although we dont in this example, each of these functions returns the datastore object which you should use as a local value -PlayerData.Settings:combine('Color') -PlayerData.Settings:combine('Quickbar') -PlayerData.Settings:combine('JoinMessage') -PlayerData.Statistics:combine('Playtime') -PlayerData.Statistics:combine('JoinCount') - --- Because sub datastore work just like a normal datastore you dont need any special code, using get and set will still return as if it wasnt a sub datastore --- Things like the serializer and the datastore settings are always the same as the parent so you dont need to worry about setting up the serializer each time --- And because save, request, and unload methods all point to the root datastore you are able to request and save your data as normal - --- If you used get_all on PlayerData this is what you would get: -{ - Cooldude2606 = { - Settings = { - Color = 'ColorValue', - Quickbar = 'QuickbarValue', - JoinMessage = 'JoinMessageValue' - }, - Statistics = { - Playtime = 'PlaytimeValue', - JoinCount = 'JoinCountValue' - } - } -} - --- If you used get_all on PlayerData.Settings this is what you would get: -{ - Cooldude2606 = { - Color = 'ColorValue', - Quickbar = 'QuickbarValue', - JoinMessage = 'JoinMessageValue' - } -} - --- If you used get_all on PlayerData.Settings.Color this is what you would get: -{ - Cooldude2606 = 'ColorValue' -} - -]] - -local Storage = require("modules/exp_util/storage") - -local DatastoreManager = {} -local Datastores = {} --- @type table -local Data = {} - ---- @class Datastore ---- @field name string ---- @field value_name string ---- @field auto_save boolean ---- @field save_to_disk boolean ---- @field propagate_changes boolean ---- @field serializer function | false ---- @field parent Datastore | false ---- @field children table ---- @field metadata table ---- @field events table ---- @field data table -local Datastore = {} - -local copy = table.deep_copy -local trace = debug.traceback -local table_to_json = helpers.table_to_json -local json_to_table = helpers.json_to_table -local write_file = helpers.write_file - ---- Save datastores in the global table -Storage.register(Data, function(tbl) - Data = tbl - for name, datastore in pairs(Datastores) do - datastore.data = Data[name] - end -end, function(tbl) - for name in pairs(Datastores) do - tbl[name] = tbl[name] or {} - end -end) - ------ Datastore Manager --- @section datastoreManager - ---- Metatable used on datastores -DatastoreManager.metatable = { - __index = function(self, key) return rawget(self.children, key) or rawget(Datastore, key) end, - __newidnex = function(_, _, _) error("Datastore can not be modified", 2) end, - __call = function(self, ...) return self:get(...) end, -} - ---[[-- Make a new datastore connection, if a connection already exists then it is returned -@tparam string datastore_name The name that you want the new datastore to have, this can not have any whitespace -@tparam[opt=false] boolean saveToDisk When set to true, using the save method with write the data to datastore.pipe -@tparam[opt=false] boolean autoSave When set to true, using any method which modifies data will cause the data to be saved -@tparam[opt=false] boolean propagateChanges When set to true, using the save method will send the data to all other connected servers -@treturn table The new datastore connection that can be used to access and modify data in the datastore - -@usage-- Connecting to the test datastore which will allow saving to disk -local ExampleData = Datastore.connect('ExampleData', true) -- saveToDisk - -]] -function DatastoreManager.connect(datastore_name, save_to_disk, autosave, propagate_changes) - if Datastores[datastore_name] then return Datastores[datastore_name] end - if package.lifecycle ~= package.lifecycle_stage.control then - -- Only allow this function to be called during the control stage - error("New datastore connection can not be created during runtime", 2) - end - - local new_datastore = { - name = datastore_name, - value_name = datastore_name, - auto_save = autosave or false, - save_to_disk = save_to_disk or false, - propagate_changes = propagate_changes or false, - serializer = false, - parent = false, - children = {}, - metadata = {}, - events = {}, - data = {}, - } - - Data[datastore_name] = new_datastore.data - Datastores[datastore_name] = new_datastore - return setmetatable(new_datastore, DatastoreManager.metatable) -end - ---[[-- Make a new datastore that stores its data inside of another one -@tparam string datastore_name The name of the datastore that will contain the data for the new datastore -@tparam string subdatastore_name The name of the new datastore, this name will also be used as the key inside the parent datastore -@treturn table The new datastore connection that can be used to access and modify data in the datastore - -@usage-- Setting up a datastore which stores its data inside of another datastore -local BarData = Datastore.combine('ExampleData', 'Bar') - -]] -function DatastoreManager.combine(datastore_name, subdatastore_name) - local datastore = assert(Datastores[datastore_name], "Datastore not found " .. tostring(datastore_name)) - return datastore:combine(subdatastore_name) -end - ---[[-- Ingest the result from a request, this is used through a rcon interface to sync data -@tparam string action The action that should be done, can be: remove, message, propagate, or request -@tparam string datastore_name The name of the datastore that should have the action done to it -@tparam string key The key of that datastore that is having the action done to it -@tparam string valueJson The json string for the value being ingested, remove does not require a value - -@usage-- Replying to a data request -Datastore.ingest('request', 'ExampleData', 'TestKey', 'Foo') - -]] -function DatastoreManager.ingest(action, datastore_name, key, value_json) - local datastore = assert(Datastores[datastore_name], "Datastore ingest error, Datastore not found " .. tostring(datastore_name)) - assert(type(action) == "string", "Datastore ingest error, Action is not a string got: " .. type(action)) - assert(type(key) == "string", "Datastore ingest error, Key is not a string got: " .. type(key)) - - if action == "remove" then - datastore:raw_set(key) - elseif action == "message" then - local success, value = pcall(json_to_table, value_json) - if not success or value == nil then value = tonumber(value_json) or value_json end - datastore:raise_event("on_message", key, value) - elseif action == "propagate" or action == "request" then - local success, value = pcall(json_to_table, value_json) - if not success or value == nil then value = tonumber(value_json) or value_json end - local old_value = datastore:raw_get(key) - value = datastore:raise_event("on_load", key, value, old_value) - datastore:set(key, value) - end -end - ---[[-- Debug, Use to get all datastores, or return debug info on a datastore -@tparam[opt] string datastore_name The name of the datastore to get the debug info of - -@usage-- Get all the datastores -local datastores = Datastore.debug() - -@usage-- Getting the debug info for a datastore -local debug_info = Datastore.debug('ExampleData') - -]] -function DatastoreManager.debug(datastore_name) - if not datastore_name then return Datastores end - local datastore = assert(Datastores[datastore_name], "Datastore not found " .. tostring(datastore_name)) - return datastore:debug() -end - ---[[-- Commonly used serializer, returns the name of the object -@tparam any rawKey The raw key that will be serialized, this can be things like player, force, surface, etc -@treturn string The name of the object that was passed - -@usage-- Using the name serializer for your datastore -local ExampleData = Datastore.connect('ExampleData') -ExampleData:set_serializer(Datastore.name_serializer) - -]] -function DatastoreManager.name_serializer(raw_key) - return raw_key.name -end - ------ Datastore Internal --- @section datastore-internal - ---[[-- Debug, Get the debug info for this datastore -@treturn table The debug info for this datastore, contains stuff like parent, settings, children, etc - -@usage-- Get the debug info for a datastore -local ExampleData = Datastore.connect('ExampleData') -local debug_info = ExampleData:debug() - -]] -function Datastore:debug() - local debug_info = {} - - if self.parent then - debug_info.parent = self.parent.name - else - debug_info.settings = { auto_save = self.auto_save, save_to_disk = self.save_to_disk, propagate_changes = self.propagate_changes, serializer = not not self.serializer } - end - - local children = {} - for name in pairs(self.children) do children[#children + 1] = name end - - if #children > 0 then debug_info.children = children end - - local events = {} - for name, handlers in pairs(self.events) do events[name] = #handlers end - - if next(events) then debug_info.events = events end - - if next(self.metadata) then debug_info.metadata = self.metadata end - debug_info.data = self:get_all() - - return debug_info -end - ---[[-- Internal, Get data following combine logic -@tparam string key The key to get the value of from this datastore -@tparam[opt=false] boolean fromChild If the get request came from a child of this datastore -@treturn any The value that was stored at this key in this datastore - -@usage-- Internal, Get the data from a datastore -local value = self:raw_get('TestKey') - -]] -function Datastore:raw_get(key, from_child) - local data = self.data - if self.parent then - data = self.parent:raw_get(key, true) - key = self.value_name - end - local value = data[key] - if value ~= nil then return value end - if from_child then value = {} end - data[key] = value - return value -end - ---[[-- Internal, Set data following combine logic -@tparam string key The key to set the value of in this datastore -@tparam any value The value that will be set at this key - -@usage-- Internal, Set the value in a datastore -self:raw_set('TestKey', 'Foo') - -]] -function Datastore:raw_set(key, value) - if self.parent then - local data = self.parent:raw_get(key, true) - data[self.value_name] = value - else - self.data[key] = value - end -end - -local function serialize_error(err) error("An error ocurred in a datastore serializer: " .. trace(err)) end ---[[-- Internal, Return the serialized key -@tparam any rawKey The key that needs to be serialized, if it is already a string then it is returned -@treturn string The key after it has been serialized - -@usage-- Internal, Ensure that the key is a string -key = self:serialize(key) - -]] -function Datastore:serialize(raw_key) - if type(raw_key) == "string" then return raw_key end - assert(self.serializer, "Datastore does not have a serializer and received non string key") - local success, key = xpcall(self.serializer, serialize_error, raw_key) - return success and key or nil -end - ---[[-- Internal, Writes an event to the output file to be saved and/or propagated -@tparam string action The action that should be wrote to datastore.pipe, can be request, remove, message, save, propagate -@tparam string key The key that the action is being preformed on -@tparam any value The value that should be used with the action - -@usage-- Write a data request to datastore.pipe -self:write_action('request', 'TestKey') - -@usage-- Write a data save to datastore.pipe -self:write_action('save', 'TestKey', 'Foo') - -]] -function Datastore:write_action(action, key, value) - local data = { action, self.name, key } - if value ~= nil then - data[4] = type(value) == "table" and table_to_json(value) or value - end - write_file("ext/datastore.out", table.concat(data, " ") .. "\n", true, 0) -end - ------ Datastore Local --- @section datastore-local - ---[[-- Create a new datastore which is stores its data inside of this datastore -@tparam string subdatastore_name The name of the datastore that will have its data stored in this datastore -@treturn table The new datastore that was created inside of this datastore - -@usage-- Add a new sub datastore -local ExampleData = Datastore.connect('ExampleData') -local BarData = ExampleData:combine('Bar') - -]] -function Datastore:combine(subdatastore_name) - local new_datastore = DatastoreManager.connect(self.name .. "." .. subdatastore_name) - self.children[subdatastore_name] = new_datastore - new_datastore.value_name = subdatastore_name - new_datastore.serializer = self.serializer - new_datastore.auto_save = self.auto_save - new_datastore.parent = self - Data[new_datastore.name] = nil - new_datastore.data = nil - return new_datastore -end - ---[[-- Set a callback that will be used to serialize keys which aren't strings -@tparam function callback The function that will be used to serialize non string keys passed as an argument - -@usage-- Set a custom serializer, this would be the same as Datastore.name_serializer -local ExampleData = Datastore.connect('ExampleData') -ExampleData:set_serializer(function(rawKey) - return rawKey.name -end) - -]] -function Datastore:set_serializer(callback) - assert(type(callback) == "function", "Callback must be a function") - self.serializer = callback -end - ---[[-- Set a default value to be returned by get if no other default is given, using will mean get will never return nil, set using the default will set to nil to save space -@tparam any value The value that will be deep copied by get if the value is nil and no other default is given -@tparam boolean allowSet When true if the default is passed as the value for set it will be set rather than setting nil - -@usage-- Set a default value to be returned by get -local ExampleData = Datastore.connect('ExampleData') -ExampleData:set_default('Foo') - -]] -function Datastore:set_default(value, allow_set) - self.default = value - self.allow_set_to_default = allow_set -end - ---[[-- Set metadata tags on this datastore which can be accessed by other scripts -@tparam table tags A table of tags that you want to set in the metadata for this datastore - -@usage-- Adding metadata that could be used by a gui to help understand the stored data -local ExampleData = Datastore.connect('ExampleData') -ExampleData:set_metadata{ - caption = 'Test Data', - tooltip = 'Data used for testing datastores', - type = 'table' -} - -]] -function Datastore:set_metadata(tags) - local metadata = self.metadata - for key, value in pairs(tags) do - metadata[key] = value - end -end - ---[[-- Get a value from local storage, option to have a default value, do not edit the data returned as changes may not save, use update if you want to make changes -@tparam any key The key that you want to get the value of, must be a string unless a serializer is set -@tparam[opt] any default The default value that will be returned if no value is found in the datastore - -@usage-- Get a key from the datastore, the default will be deep copied if no value exists in the datastore -local ExampleData = Datastore.connect('ExampleData') -local value = ExampleData:get('TestKey') - -]] -function Datastore:get(key, default) - key = self:serialize(key) - local value = self:raw_get(key) - if value ~= nil then return value end - return copy(default or self.default) -end - ---[[-- Set a value in local storage, will trigger on_update then on_save, save_to_disk and auto_save is required for on_save -@tparam any key The key that you want to set the value of, must be a string unless a serializer is set -@tparam any value The value that you want to set for this key - -@usage-- Set a value in the datastore, this will trigger on_update, if auto_save is true then will trigger save -local ExampleData = Datastore.connect('ExampleData') -ExampleData:set('TestKey', 'Foo') - -]] -function Datastore:set(key, value) - key = self:serialize(key) - local old_value = self:raw_get(key) - if value == self.default and not self.allow_set_to_default then - self:raw_set(key) - else - self:raw_set(key, value) - end - self:raise_event("on_update", key, value, old_value) - if self.auto_save then self:save(key) end - return value -end - ---[[-- Increment the value in local storage, only works for number values, will trigger on_update then on_save, save_to_disk and auto_save is required for on_save -@tparam any key The key that you want to increment the value of, must be a string unless a serializer is set -@tparam[opt=1] number delta The amount that you want to increment the value by, can be negative or a decimal - -@usage-- Increment a value in a datastore, the value must be a number or nil, if nil 0 is used as the start value -local ExampleData = Datastore.connect('ExampleData') -ExampleData:increment('TestNumber') - -]] -function Datastore:increment(key, delta) - key = self:serialize(key) - local value = self:raw_get(key) or 0 - return self:set(key, value + (delta or 1)) -end - -local function update_error(err) log("An error occurred in datastore update:\n\t" .. trace(err)) end ---[[-- Use a function to update the value locally, will trigger on_update then on_save, save_to_disk and auto_save is required for on_save -@tparam any key The key that you want to apply the update to, must be a string unless a serializer is set -@tparam function callback The function that will be used to update the value at this key - -@usage-- Using a function to update a value, if a value is returned then this will be the new value -local ExampleData = Datastore.connect('ExampleData') -ExampleData:increment('TestKey', function(key, value) - return value..value -end) - -]] -function Datastore:update(key, callback) - key = self:serialize(key) - local value = self:get(key) - local raw_value = self:raw_get(key) - local old_value = copy(self:raw_get(key)) - local success, new_value = xpcall(callback, update_error, key, value) - if not success then - self:raw_set(key, old_value) - elseif new_value ~= nil then - self:set(key, new_value) - elseif raw_value == nil then - self:set(key, value) - else - self:raise_event("on_update", key, value, old_value) - if self.auto_save then self:save(key) end - end -end - ---[[-- Remove a value locally and on the external source, works regardless of propagateChanges, requires save_to_disk for external changes -@tparam any key The key that you want to remove locally and externally, must be a string unless a serializer is set - -@usage-- Remove a key locally and externally -local ExampleData = Datastore.connect('ExampleData') -ExampleData:remove('TestKey') - -]] -function Datastore:remove(key) - key = self:serialize(key) - local old_value = self:raw_get(key) - self:raw_set(key) - self:raise_event("on_update", key, nil, old_value) - if self.save_to_disk then self:write_action("remove", key) end - if self.parent and self.parent.auto_save then return self.parent:save(key) end -end - -local function filter_error(err) log("An error ocurred in a datastore filter:\n\t" .. trace(err)) end ---[[-- Internal, Used to filter elements from a table -@tparam table tbl The table that will have the filter applied to it -@tparam[opt] function callback The function that will be used as a filter, if none giving then the provided table is returned -@treturn table The table which has only the key values pairs which passed the filter - -@usage-- Internal, Filter a table by the values it contains, return true to keep the key value pair -local filtered_table = filter({5,3,4,1,2}, function(key, value) - return value > 2 -end) - -]] -local function filter(tbl, callback) - if not callback then return tbl end - local rtn = {} - for key, value in pairs(tbl) do - local success, add = xpcall(callback, filter_error, key, value) - if success and add then rtn[key] = value end - end - - return rtn -end - ---[[-- Get all keys in this datastore, optional filter callback -@tparam[opt] function callback The filter function that can be used to filter the results returned -@treturn table All the data that is in this datastore, filtered if a filter was provided - -@usage-- Get all the data in this datastore -local ExampleData = Datastore.connect('ExampleData') -local data = ExampleData:get_all() - -@usage-- Get all the data in this datastore, with a filter -local ExampleData = Datastore.connect('ExampleData') -local data = ExampleData:get_all(function(key, value) - return type(value) == 'string' -end) - -]] -function Datastore:get_all(callback) - if not self.parent then - return filter(self.data, callback) - else - local data, value_name = {}, self.value_name - for key, value in pairs(self.parent:get_all()) do - data[key] = value[value_name] - end - - return filter(data, callback) - end -end - ---[[-- Update all keys in this datastore using the same update function -@tparam function callback The update function that will be applied to each key - -@usage-- Get all the data in this datastore, with a filter -local ExampleData = Datastore.connect('ExampleData') -ExampleData:update_all(function(key, value) - return value..value -end) - -]] -function Datastore:update_all(callback) - local data = self:get_all() - for key, value in pairs(data) do - local old_value = copy(value) - local success, new_value = xpcall(callback, update_error, key, value) - if success and new_value ~= nil then - self:set(key, new_value) - else - self:raise_event("on_update", key, value, old_value) - if self.auto_save then self:save(key) end - end - end -end - ------ Datastore External --- @section datastore-external - ---[[-- Request a value from an external source, will trigger on_load when data is received -@tparam any key The key that you want to request from an external source, must be a string unless a serializer is set - -@usage-- Request a key from an external source, on_load is triggered when data is received -local ExampleData = Datastore.connect('ExampleData') -ExampleData:request('TestKey') - -]] -function Datastore:request(key) - if self.parent then return self.parent:request(key) end - key = self:serialize(key) - self:write_action("request", key) -end - ---[[-- Save a value to an external source, will trigger on_save before data is saved, save_to_disk must be set to true -@tparam any key The key that you want to save to an external source, must be a string unless a serializer is set - -@usage-- Save a key to an external source, save_to_disk must be set to true for there to be any effect -local ExampleData = Datastore.connect('ExampleData') -ExampleData:save('TestKey') - -]] -function Datastore:save(key) - if self.parent then self.parent:save(key) end - if not self.save_to_disk then return end - key = self:serialize(key) - local value = self:raise_event("on_save", key, copy(self:raw_get(key))) - local action = self.propagate_changes and "propagate" or "save" - self:write_action(action, key, value) -end - ---[[-- Save a value to an external source and remove locally, will trigger on_unload then on_save, save_to_disk is not required for on_unload -@tparam any key The key that you want to unload from the datastore, must be a string unless a serializer is set - -@usage-- Unload a key from the datastore, get will now return nil and value will be saved externally if save_to_disk is set to true -local ExampleData = Datastore.connect('ExampleData') -ExampleData:unload('TestKey') - -]] -function Datastore:unload(key) - if self.parent then return self.parent:unload(key) end - key = self:serialize(key) - self:raise_event("on_unload", key, copy(self:raw_get(key))) - self:save(key) - self:raw_set(key) -end - ---[[-- Use to send a message over the connection, works regardless of saveToDisk and propagateChanges -@tparam any key The key that you want to send a message over, must be a string unless a serializer is set -@tparam any message The message that you want to send to other connected servers, or external source - -@usage-- Send a message to other servers on this key, can listen for messages with on_message -local ExampleData = Datastore.connect('ExampleData') -ExampleData:message('TestKey', 'Foo') - -]] -function Datastore:message(key, message) - key = self:serialize(key) - self:write_action("message", key, message) -end - ---[[-- Save all the keys in the datastore, optional filter callback -@tparam[opt] function callback The filter function that can be used to filter the keys saved - -@usage-- Save all the data in this datastore -local ExampleData = Datastore.connect('ExampleData') -local data = ExampleData:save_all() - -@usage-- Save all the data in this datastore, with a filter -local ExampleData = Datastore.connect('ExampleData') -ExampleData:save_all(function(key, value) - return type(value) == 'string' -end) - -]] -function Datastore:save_all(callback) - local data = self:get_all(callback) - for key in pairs(data) do self:save(key) end -end - ---[[-- Unload all the keys in the datastore, optional filter callback -@tparam[opt] function callback The filter function that can be used to filter the keys unloaded - -@usage-- Unload all the data in this datastore -local ExampleData = Datastore.connect('ExampleData') -ExampleData:unload_all() - -@usage-- Unload all the data in this datastore, with a filter -local ExampleData = Datastore.connect('ExampleData') -ExampleData:unload_all(function(key, value) - return type(value) == 'string' -end) - -]] -function Datastore:unload_all(callback) - local data = self:get_all(callback) - for key in pairs(data) do self:unload(key) end -end - ------ Events --- @section events - -local function event_error(err) log("An error ocurred in a datastore event handler:\n\t" .. trace(err)) end ---[[-- Internal, Raise an event on this datastore -@tparam string event_name The name of the event to raise for this datastore -@tparam string key The key that this event is being raised for -@tparam[opt] any value The current value that this key has, might be a deep copy of the value -@tparam[opt] any old_value The previous value that this key has, might be a deep copy of the value -@tparam[opt] string source Where this call came from, used to do event recursion so can be parent or child -@treturn any The value that is left after being passed through all the event handlers - -@usage-- Internal, Getting the value that should be saved -value = self:raise_event('on_save', key, value) - -]] -function Datastore:raise_event(event_name, key, value, old_value, source) - -- Raise the event for the children of this datastore - if source ~= "child" and next(self.children) then - if type(value) ~= "table" then value = {} end - for value_name, child in pairs(self.children) do - local old_child_value = old_value and old_value[value_name] or nil - value[value_name] = child:raise_event(event_name, key, value[value_name], old_child_value, "parent") - end - end - - -- Raise the event for this datastore - local handlers = self.events[event_name] - if handlers then - for _, handler in ipairs(handlers) do - local success, new_value = xpcall(handler, event_error, key, value, old_value) - if success and new_value ~= nil then value = new_value end - end - end - - -- Raise the event for the parent of this datastore - if source ~= "parent" and self.parent then - local parent_value = self.parent:raw_get(key, true) - self.parent:raise_event(event_name, key, parent_value, parent_value, "child") - end - - -- If this is the save event and the table is empty then return nil - if event_name == "on_save" and next(self.children) and not next(value) then return end - return value -end - ---[[-- Internal, Returns a function which will add a callback to an event -@tparam string event_name The name of the event that this should create a handler adder for -@treturn function The function that can be used to add handlers to this event - -@usage-- Internal, Get the function to add handlers to on_load -Datastore.on_load = event_factory('on_load') - -]] -local function event_factory(event_name) - return function(self, callback) - assert(type(callback) == "function", "Handler must be a function") - local handlers = self.events[event_name] - if not handlers then - self.events[event_name] = { callback } - else - handlers[#handlers + 1] = callback - end - end -end - ---[[-- Register a callback that triggers when data is loaded from an external source, returned value is saved locally -@tparam function callback The handler that will be registered to the on_load event -@usage-- Adding a handler to on_load, returned value will be saved locally, can be used to deserialize the value beyond a normal json -local ExampleData = Datastore.connect('ExampleData') -ExampleData:on_load(function(key, value) - game.print('Test data loaded for: '..key) -end) -]] -Datastore.on_load = event_factory("on_load") - ---[[-- Register a callback that triggers before data is saved, returned value is saved externally -@tparam function callback The handler that will be registered to the on_load event -@usage-- Adding a handler to on_save, returned value will be saved externally, can be used to serialize the value beyond a normal json -local ExampleData = Datastore.connect('ExampleData') -ExampleData:on_save(function(key, value) - game.print('Test data saved for: '..key) -end) -]] -Datastore.on_save = event_factory("on_save") - ---[[-- Register a callback that triggers before data is unloaded, returned value is ignored -@tparam function callback The handler that will be registered to the on_load event -@usage-- Adding a handler to on_unload, returned value is ignored, can be used to clean up guis or local values related to this data -local ExampleData = Datastore.connect('ExampleData') -ExampleData:on_load(function(key, value) - game.print('Test data unloaded for: '..key) -end) -]] -Datastore.on_unload = event_factory("on_unload") - ---[[-- Register a callback that triggers when a message is received, returned value is ignored -@tparam function callback The handler that will be registered to the on_load event -@usage-- Adding a handler to on_message, returned value is ignored, can be used to receive messages from other connected servers without saving data -local ExampleData = Datastore.connect('ExampleData') -ExampleData:on_message(function(key, value) - game.print('Test data message for: '..key) -end) -]] -Datastore.on_message = event_factory("on_message") - ---[[-- Register a callback that triggers any time a value is changed, returned value is ignored -@tparam function callback The handler that will be registered to the on_load event -@usage-- Adding a handler to on_update, returned value is ignored, can be used to update guis or send messages when data is changed -local ExampleData = Datastore.connect('ExampleData') -ExampleData:on_update(function(key, value) - game.print('Test data updated for: '..key) -end) -]] -Datastore.on_update = event_factory("on_update") - ------ Module Return -return DatastoreManager diff --git a/exp_legacy/module/expcore/player_data.lua b/exp_legacy/module/expcore/player_data.lua deleted file mode 100644 index 07318191..00000000 --- a/exp_legacy/module/expcore/player_data.lua +++ /dev/null @@ -1,152 +0,0 @@ ---[[-- Core Module - PlayerData -- A module used to store player data in a central datastore to minimize data requests and saves. -@core PlayerData - -@usage-- Adding a colour setting for players -local PlayerData = require("modules.exp_legacy.expcore.player_data") -local PlayerColors = PlayerData.Settings:combine('Color') - --- Set the players color when their data is loaded -PlayerColors:on_load(function(player_name, color) - local player = game.players[player_name] - player.color = color -end) - --- Overwrite the saved color with the players current color -PlayerColors:on_save(function(player_name, _) - local player = game.players[player_name] - return player.color -- overwrite existing data with the current color -end) - -@usage-- Add a playtime statistic for players -local Event = require("modules/exp_legacy/utils/event") -local PlayerData = require("modules.exp_legacy.expcore.player_data") -local Playtime = PlayerData.Statistics:combine('Playtime') - --- When playtime reaches an hour interval tell the player and say thanks -Playtime:on_update(function(player_name, playtime) - if playtime % 60 == 0 then - local hours = playtime / 60 - local player = game.players[player_name] - player.print('Thanks for playing on our servers, you have played for '..hours..' hours!') - end -end) - --- Update playtime for players, data is only loaded for online players so update_all can be used -Event.add_on_nth_tick(3600, function() - Playtime:update_all(function(player_name, playtime) - return playtime + 1 - end) -end) - -]] - -local Async = require("modules/exp_util/async") -local Event = require("modules/exp_legacy/utils/event") --- @dep utils.event -local Datastore = require("modules.exp_legacy.expcore.datastore") --- @dep expcore.datastore -local Commands = require("modules/exp_commands") - -local table_to_json = helpers.table_to_json -local write_file = helpers.write_file - ---- Common player data that acts as the root store for player data -local PlayerData = Datastore.connect("PlayerData", true) -- saveToDisk -PlayerData:set_serializer(Datastore.name_serializer) -- use player name - ---- Store and enum for the data saving preference -local DataSavingPreference = PlayerData:combine("DataSavingPreference") -local PreferenceEnum = { "All", "Statistics", "Settings", "Required" } --- @type table -for k, v in ipairs(PreferenceEnum) do PreferenceEnum[v] = k end - -DataSavingPreference:set_default("All") -DataSavingPreference:set_metadata{ - name = { "expcore-data.preference" }, - tooltip = { "expcore-data.preference-tooltip" }, - value_tooltip = { "expcore-data.preference-value-tooltip" }, -} - ---- Sets your data saving preference -Commands.new("data-preference", { "expcore-data.description-preference" }) - :optional("option", { "expcore-data.arg-option" }, Commands.types.enum(PreferenceEnum)) - :register(function(player, option) - --- @cast option "All" | "Statistics" | "Settings" | "Required" | nil - if option then - DataSavingPreference:set(player, option) - return Commands.status.success{ "expcore-data.set-preference", option } - else - return Commands.status.success{ "expcore-data.get-preference", DataSavingPreference:get(player) } - end - end) - ---- Gets your data and writes it to a file -Commands.new("save-data", { "expcore-data.description-data" }) - :register(function(player) - player.print{ "expcore-data.get-data" } - write_file("expgaming_player_data.json", table_to_json(PlayerData:get(player, {})), false, player.index) - end) - ---- Async function called after 5 seconds with no player data loaded -local check_data_loaded_async = - Async.register(function(player) - local player_data = PlayerData:get(player) - if not player_data or not player_data.valid then - player.print{ "expcore-data.data-failed" } - Datastore.ingest("request", "PlayerData", player.name, '{"valid":false}') - end - end) - ---- When player data loads tell the player if the load had failed previously -PlayerData:on_load(function(player_name, player_data, existing_data) - if not player_data or player_data.valid == false then return end - if existing_data and existing_data.valid == false then - game.players[player_name].print{ "expcore-data.data-restore" } - end - player_data.valid = true -end) - ---- Remove data that the player doesnt want to have stored -PlayerData:on_save(function(player_name, player_data) - local data_preference = DataSavingPreference:get(player_name) - data_preference = PreferenceEnum[data_preference] - if data_preference == PreferenceEnum.All then - player_data.valid = nil - return player_data - end - - local saved_player_data = { PlayerRequired = player_data.PlayerRequired, DataSavingPreference = PreferenceEnum[data_preference] } - if data_preference <= PreferenceEnum.Settings then saved_player_data["PlayerSettings"] = player_data.PlayerSettings end - if data_preference <= PreferenceEnum.Statistics then saved_player_data["PlayerStatistics"] = player_data.PlayerStatistics end - - return saved_player_data -end) - ---- Display your data preference when your data loads -DataSavingPreference:on_load(function(player_name, data_preference) - game.players[player_name].print{ "expcore-data.get-preference", data_preference or DataSavingPreference.default } -end) - ---- Load player data when they join -Event.add(defines.events.on_player_joined_game, function(event) - local player = game.players[event.player_index] - check_data_loaded_async:start_after(300, player) - PlayerData:request(player) -end) - ---- Unload player data when they leave -Event.add(defines.events.on_player_left_game, function(event) - local player = game.players[event.player_index] - local player_data = PlayerData:get(player) - if player_data and player_data.valid == true then - PlayerData:unload(player) - end -end) - ------ Module Return ----- -return { - All = PlayerData, -- Root for all of a players data - Statistics = PlayerData:combine("Statistics"), -- Common place for stats - Settings = PlayerData:combine("Settings"), -- Common place for settings - Required = PlayerData:combine("Required"), -- Common place for required data - DataSavingPreference = DataSavingPreference, -- Stores what data groups will be saved - PreferenceEnum = PreferenceEnum, -- Enum for the allowed options for data saving preference -} diff --git a/exp_legacy/module/locale/en.cfg b/exp_legacy/module/locale/en.cfg deleted file mode 100644 index 1de56114..00000000 --- a/exp_legacy/module/locale/en.cfg +++ /dev/null @@ -1,81 +0,0 @@ -# addons -[info] -players-online=There are __1__ players online -total-map-time=This map has been on for __1__ -discord=Join us on our discord at: __1__ -website=Please visit our website at: __1__ -status=Want to check if out servers are down? Visit: __1__ -github=Want to help improve our server with some extra features? Help us at: __1__ -patreon=Consider supporting our server at: __1__ -custom-commands=We use custom commands, such as /tag and /me, use /chelp for more info. -read-readme=Make sure you have read the information gui (It can be found through the info mark on the top left) -softmod=We run a softmod on our servers. A softmod is a custom scenario that runs on this server, an example is the player list. -redmew=We don't talk about redmew here; they beat us to 1000 members ;-; -lhd=All trains must be LHD! This is a long standing rule on our servers, please respect this. - -# data -[exp-settings] -Bonus=Player Bonus -Bonus-tooltip=The bonus given to your character -Bonus-value-tooltip=Change by using /bonus - -# expcore -[expcore-data] -description-preference=Allows you to set/get your data saving preference. -description-data=Writes all your player data to a file on your computer. -arg-option=One of "All", "Statistics", "Settings", "Required" -set-preference=You data saving preference has been set to __1__. Existing data will not be effected until you rejoin. -get-preference=You data saving preference is __1__. Use /set-preference to change this. Use /save-data to get a local copy of your data. -get-data=Your player data has been writen to file, location: factorio/script_output/expgaming_player_data.json -data-failed=Your player data has failed to load. Any changes to your data will not be saved. -data-restore=Your player data has been restored and changes will now save when you leave. -preference=Saving Preference -preference-tooltip=Which categories will be saved when you leave the game -preference-value-tooltip=Change by using /set-preference -preference-All=All data will be saved -preference-Statistics=Only statistics, settings, and required data will be saved -preference-Settings=Only settings and required data will be saved -preference-Required=Only required data will be saved - -# gui -[player-list] -main-tooltip=Player List -open-action-bar=Options -close-action-bar=Close Options -reason-confirm=Confirm Reason -reason-entry=Enter Reason -goto-player=Goto player -bring-player=Bring player -report-player=Report player -jail-player=Jail player -kick-player=Kick player -ban-player=Ban player -afk-time=__1__% of total map time\nLast moved __2__ ago -open-map=__1__ __2__\n__3__\nClick to open map - -[bonus] -description=Get / Set the amount of bonus you receive. -arg-amount=Amount to set your bonus to, 0 will disable bonus. -set=Your bonus has been set to __1__. -get=Your bonus is __1__. -perm=You dont have enough permission to set more than __1__. -display-fmms=Mining -display-fmms-tooltip=Force manual mining speed -display-frs=Running -display-frs-tooltip=Force running speed -display-fcs=Crafting -display-fcs-tooltip=Force crafting speed -display-fisb=Inventory -display-fisb-tooltip=Force inventory slots bonus -display-fhb=Health -display-fhb-tooltip=Force health bonus -display-frdb=Reach -display-frdb-tooltip=Force reach distance bonus -display-fwrsm=Robot Speed -display-fwrsm-tooltip=Force worker robots speed modifier -display-fwrbm=Robot Battery -display-fwrbm-tooltip=Force worker robots battery modifier -display-fwrsb=Robot Storage -display-fwrsb-tooltip=Force worker robots storage bonus -display-ffrlm=Robot Lifetime -display-ffrlm-tooltip=Force following robots lifetime modifier diff --git a/exp_legacy/module/locale/zh-CN.cfg b/exp_legacy/module/locale/zh-CN.cfg deleted file mode 100644 index 82f411b8..00000000 --- a/exp_legacy/module/locale/zh-CN.cfg +++ /dev/null @@ -1,89 +0,0 @@ -# addons -[info] -players-online=現在有 __1__ 人在線 -total-map-time=地圖時間為 __1__ -discord=加入社群: __1__ -website=訪問網站: __1__ -status=伺服器狀態: __1__ -github=增加功能或回報錯誤: __1__ -patreon=支持我們: __1__ -custom-commands=這裹使用了自制指令,如 /tag 和 /me。可使用 /chelp 查看更多。 -read-readme=確保你已閱讀相關資訊。按左上 i 圖標可再次查看 -softmod=這裹用了自設情境,是一種軟裝模組。 -redmew=We don't talk about redmew here; they beat us to 1000 members ;-; -lhd=列車必須是左則通行。這是本服務器長久以來的規則。 - -# data -[exp-settings] -Bonus=人物附加屬性 -Bonus-tooltip=你的人物附加屬性 -Bonus-value-tooltip=使用 /bonus 來更改 - -# expcore -[expcore-data] -description-preference=允許寫入資料。 -description-data=把用戶資料寫入電腦。 -arg-option="All", "Statistics", "Settings", "Required" 之中 -set-preference=你的喜好已設定為 __1__ 。在你重新加入前將不會有任何改動。 -get-preference=你的喜好為 __1__ 。使用 /set-preference 來更改,使用 /save-data 來獲得一份資料副本。 -get-data=你的個人資料已寫入: factorio/script_output/expgaming_player_data.json -data-failed=無法載入你的個人資料,任何改動將不會保存。 -data-restore=已載入你的個人資料,所有改動將會被保存。 -preference=儲存喜好 -preference-tooltip=那種資料將在退出時保存 -preference-value-tooltip=使用 /set-preference 來更換儲存喜好 -preference-All=所有資料都會被儲存 -preference-Statistics=只有數據, 設定和資料會被儲存 -preference-Settings=只有設定和資料會被儲存 -preference-Required=只有需要的資料會被儲存 - -# gui -[player-list] -main-tooltip=用戶名單 -open-action-bar=選項 -close-action-bar=關閉選項 -reason-confirm=確認原因 -reason-entry=輸入原因 -goto-player=傳送到用戶 -bring-player=傳送用戶到自己 -report-player=舉報用戶 -jail-player=監禁用戶 -kick-player=踢除用戶 -ban-player=封禁用戶 -afk-time=__1__% 地圖時間\n上次移動: __2__ -open-map=__1__ __2__\n__3__\n按下打開地圖 - -[bonus] -description=取得 / 設定 Bonus 量。 -arg-amount=Bonus 數量, 0 來停用。 -set=你的 Bonus 已設為 __1__。 -get=你的 Bonus 為 __1__。 -perm=你沒有足夠權限設多過 __1__。 -display-fmms=挖掘速度 -display-fmms-tooltip=勢力挖掘速度 -display-frs=跑步速度 -display-frs-tooltip=勢力跑步速度 -display-fcs=合成速度 -display-fcs-tooltip=勢力合成速度 -display-fisb=儲存位 -display-fisb-tooltip=勢力儲存位 -display-fhb=生命 -display-fhb-tooltip=勢力生命 -display-frdb=到達距離 -display-frdb-tooltip=勢力到達距離 -display-fwrsm=機械人速度 -display-fwrsm-tooltip=勢力機械人速度 -display-fwrbm=機械人電力 -display-fwrbm-tooltip=勢力機械人電力 -display-fwrsb=械人儲存 -display-fwrsb-tooltip=勢力機械人儲存 -display-ffrlm=機械人生命 -display-ffrlm-tooltip=勢力戰鬥機械人生命時間 - -[toolbar] -main-caption=工具箱 -main-tooltip=工具箱設定\n選上來設定喜好 -reset=重設 -toggle=啟用喜好 -move-up=向上 -move-down=向下 diff --git a/exp_legacy/module/locale/zh-TW.cfg b/exp_legacy/module/locale/zh-TW.cfg deleted file mode 100644 index 82f411b8..00000000 --- a/exp_legacy/module/locale/zh-TW.cfg +++ /dev/null @@ -1,89 +0,0 @@ -# addons -[info] -players-online=現在有 __1__ 人在線 -total-map-time=地圖時間為 __1__ -discord=加入社群: __1__ -website=訪問網站: __1__ -status=伺服器狀態: __1__ -github=增加功能或回報錯誤: __1__ -patreon=支持我們: __1__ -custom-commands=這裹使用了自制指令,如 /tag 和 /me。可使用 /chelp 查看更多。 -read-readme=確保你已閱讀相關資訊。按左上 i 圖標可再次查看 -softmod=這裹用了自設情境,是一種軟裝模組。 -redmew=We don't talk about redmew here; they beat us to 1000 members ;-; -lhd=列車必須是左則通行。這是本服務器長久以來的規則。 - -# data -[exp-settings] -Bonus=人物附加屬性 -Bonus-tooltip=你的人物附加屬性 -Bonus-value-tooltip=使用 /bonus 來更改 - -# expcore -[expcore-data] -description-preference=允許寫入資料。 -description-data=把用戶資料寫入電腦。 -arg-option="All", "Statistics", "Settings", "Required" 之中 -set-preference=你的喜好已設定為 __1__ 。在你重新加入前將不會有任何改動。 -get-preference=你的喜好為 __1__ 。使用 /set-preference 來更改,使用 /save-data 來獲得一份資料副本。 -get-data=你的個人資料已寫入: factorio/script_output/expgaming_player_data.json -data-failed=無法載入你的個人資料,任何改動將不會保存。 -data-restore=已載入你的個人資料,所有改動將會被保存。 -preference=儲存喜好 -preference-tooltip=那種資料將在退出時保存 -preference-value-tooltip=使用 /set-preference 來更換儲存喜好 -preference-All=所有資料都會被儲存 -preference-Statistics=只有數據, 設定和資料會被儲存 -preference-Settings=只有設定和資料會被儲存 -preference-Required=只有需要的資料會被儲存 - -# gui -[player-list] -main-tooltip=用戶名單 -open-action-bar=選項 -close-action-bar=關閉選項 -reason-confirm=確認原因 -reason-entry=輸入原因 -goto-player=傳送到用戶 -bring-player=傳送用戶到自己 -report-player=舉報用戶 -jail-player=監禁用戶 -kick-player=踢除用戶 -ban-player=封禁用戶 -afk-time=__1__% 地圖時間\n上次移動: __2__ -open-map=__1__ __2__\n__3__\n按下打開地圖 - -[bonus] -description=取得 / 設定 Bonus 量。 -arg-amount=Bonus 數量, 0 來停用。 -set=你的 Bonus 已設為 __1__。 -get=你的 Bonus 為 __1__。 -perm=你沒有足夠權限設多過 __1__。 -display-fmms=挖掘速度 -display-fmms-tooltip=勢力挖掘速度 -display-frs=跑步速度 -display-frs-tooltip=勢力跑步速度 -display-fcs=合成速度 -display-fcs-tooltip=勢力合成速度 -display-fisb=儲存位 -display-fisb-tooltip=勢力儲存位 -display-fhb=生命 -display-fhb-tooltip=勢力生命 -display-frdb=到達距離 -display-frdb-tooltip=勢力到達距離 -display-fwrsm=機械人速度 -display-fwrsm-tooltip=勢力機械人速度 -display-fwrbm=機械人電力 -display-fwrbm-tooltip=勢力機械人電力 -display-fwrsb=械人儲存 -display-fwrsb-tooltip=勢力機械人儲存 -display-ffrlm=機械人生命 -display-ffrlm-tooltip=勢力戰鬥機械人生命時間 - -[toolbar] -main-caption=工具箱 -main-tooltip=工具箱設定\n選上來設定喜好 -reset=重設 -toggle=啟用喜好 -move-up=向上 -move-down=向下 diff --git a/exp_legacy/module/module.json b/exp_legacy/module/module.json deleted file mode 100644 index 2a2ec59e..00000000 --- a/exp_legacy/module/module.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "exp_legacy", - "load": [ - "control.lua" - ], - "require": [ - ], - "dependencies": { - "clusterio": "*", - "exp_util": "*", - "exp_gui": "*" - } -} diff --git a/exp_legacy/module/module_exports.lua b/exp_legacy/module/module_exports.lua deleted file mode 100644 index b970ddaf..00000000 --- a/exp_legacy/module/module_exports.lua +++ /dev/null @@ -1,3 +0,0 @@ ---- Can contain anything you want to allow other plugins to have access to, this example exposes the control file --- Access the exports from other modules using require("modules/exp_legacy") -return require("modules/exp_legacy/control") diff --git a/exp_legacy/module/utils/event.lua b/exp_legacy/module/utils/event.lua deleted file mode 100644 index f2407f06..00000000 --- a/exp_legacy/module/utils/event.lua +++ /dev/null @@ -1,86 +0,0 @@ -local ExpUtil = require("modules/exp_util") - -local Event = { - real_handlers = { - events = {}, - on_nth_tick = {}, - on_init = nil, - on_load = nil, - }, - handlers = { - events = {}, - on_nth_tick = {}, - on_init = nil, - on_load = nil, - }, -} - -local function call_handlers_factory(handlers) - return function(event) - for _, handler in ipairs(handlers) do - handler(event) - end - end -end - -function Event.add(event_name, handler) - ExpUtil.assert_not_runtime() - ExpUtil.assert_argument_type(event_name, "number", 1, "event_name") - ExpUtil.assert_argument_type(handler, "function", 2, "handler") - - local handlers = Event.handlers.events[event_name] - if not handlers then - handlers = {} - Event.handlers.events[event_name] = handlers - Event.real_handlers.events[event_name] = call_handlers_factory(handlers) - end - - handlers[#handlers + 1] = handler -end - -function Event.on_nth_tick(tick, handler) - ExpUtil.assert_not_runtime() - ExpUtil.assert_argument_type(tick, "number", 1, "tick") - ExpUtil.assert_argument_type(handler, "function", 2, "handler") - - local handlers = Event.handlers.on_nth_tick[tick] - if not handlers then - handlers = {} - Event.handlers.on_nth_tick[tick] = handlers - Event.real_handlers.on_nth_tick[tick] = call_handlers_factory(handlers) - end - - handlers[#handlers + 1] = handler -end - -function Event.on_init(handler) - ExpUtil.assert_not_runtime() - ExpUtil.assert_argument_type(handler, "function", 1, "handler") - - local handlers = Event.handlers.on_init - if not handlers then - handlers = {} - Event.handlers.on_init = handlers - Event.real_handlers.on_init = call_handlers_factory(handlers) - end - - handlers[#handlers + 1] = handler - Event.add(defines.events.on_singleplayer_init, handler) - Event.add(defines.events.on_multiplayer_init, handler) -end - -function Event.on_load(handler) - ExpUtil.assert_not_runtime() - ExpUtil.assert_argument_type(handler, "function", 1, "handler") - - local handlers = Event.handlers.on_load - if not handlers then - handlers = {} - Event.handlers.on_load = handlers - Event.real_handlers.on_load = call_handlers_factory(handlers) - end - - handlers[#handlers + 1] = handler -end - -return Event diff --git a/exp_legacy/package.json b/exp_legacy/package.json deleted file mode 100644 index e4b7e3f1..00000000 --- a/exp_legacy/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@expcluster/legacy", - "version": "7.0.1", - "description": "Clusterio plugin implementing the legacy v6 scenario updated for factorio 2.0", - "author": "Cooldude2606 ", - "license": "MIT", - "repository": "explosivegaming/ExpCluster", - "type": "module", - "main": "dist/node/index.js", - "scripts": { - "prepare": "tsc --build && webpack-cli --env production" - }, - "engines": { - "node": ">=22" - }, - "peerDependencies": { - "@clusterio/controller": "workspace:^", - "@clusterio/host": "workspace:^", - "@clusterio/lib": "workspace:^" - }, - "peerDependenciesMeta": { - "@clusterio/controller": { - "optional": true - }, - "@clusterio/host": { - "optional": true - } - }, - "devDependencies": { - "@clusterio/lib": "workspace:^", - "@types/node": "catalog:", - "typescript": "catalog:", - "webpack": "catalog:", - "webpack-cli": "catalog:", - "webpack-merge": "catalog:" - }, - "dependencies": { - "@expcluster/lib_commands": "workspace:^", - "@expcluster/lib_util": "workspace:^", - "@expcluster/lib_gui": "workspace:^", - "@sinclair/typebox": "catalog:" - }, - "publishConfig": { - "access": "public" - }, - "keywords": [ - "clusterio", - "clusterio-plugin", - "factorio" - ] -} diff --git a/exp_legacy/tsconfig.browser.json b/exp_legacy/tsconfig.browser.json deleted file mode 100644 index 1ab2704e..00000000 --- a/exp_legacy/tsconfig.browser.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../tsconfig.browser.json", - "include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ], -} diff --git a/exp_legacy/tsconfig.json b/exp_legacy/tsconfig.json deleted file mode 100644 index 303b8f28..00000000 --- a/exp_legacy/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.node.json" }, - { "path": "./tsconfig.browser.json" } - ] -} diff --git a/exp_legacy/tsconfig.node.json b/exp_legacy/tsconfig.node.json deleted file mode 100644 index 3218f2e7..00000000 --- a/exp_legacy/tsconfig.node.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "../tsconfig.node.json", - "include": ["./**/*.ts"], - "exclude": ["test/*", "./dist/*"], -} diff --git a/exp_legacy/web/index.tsx b/exp_legacy/web/index.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/exp_legacy/webpack.config.cjs b/exp_legacy/webpack.config.cjs deleted file mode 100644 index c5405881..00000000 --- a/exp_legacy/webpack.config.cjs +++ /dev/null @@ -1,29 +0,0 @@ -"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_legacy", - library: { type: "window", name: "plugin_exp_legacy" }, - exposes: { - "./": "./index.ts", - "./package.json": "./package.json", - "./web": "./web/index.tsx", - }, - shared: { - "@clusterio/lib": { import: false }, - "@clusterio/web_ui": { import: false }, - }, - }), - ], -}); diff --git a/exp_scenario/module/commands/_rcon.lua b/exp_scenario/module/commands/_rcon.lua index b40e4700..71802f29 100644 --- a/exp_scenario/module/commands/_rcon.lua +++ b/exp_scenario/module/commands/_rcon.lua @@ -1,5 +1,5 @@ ---[[-- Command Rcon - ExpCore -Adds rcon interfaces for the legacy exp core +--[[-- Command Rcon - ExpGaming +Adds rcon statics for the ExpGaming modules ]] local Commands = require("modules/exp_commands") @@ -8,5 +8,4 @@ local add_static, add_dynamic = Commands.add_rcon_static, Commands.add_rcon_dyna add_static("Gui", require("modules/exp_gui")) add_static("Roles", require("modules/exp_roles")) -add_static("Datastore", require("modules.exp_legacy.expcore.datastore")) add_static("ExpServerList", require("modules/exp_server_list")) diff --git a/exp_scenario/module/gui/debug/expcore_datastore_view.lua b/exp_scenario/module/gui/debug/expcore_datastore_view.lua deleted file mode 100644 index 10bb9263..00000000 --- a/exp_scenario/module/gui/debug/expcore_datastore_view.lua +++ /dev/null @@ -1,132 +0,0 @@ -local Gui = require("modules/exp_scenario/gui/debug/shim") -local Datastore = require("modules.exp_legacy.expcore.datastore") -local Color = require("modules/exp_util/include/color") -local Model = require("modules/exp_scenario/gui/debug/model") - -local dump = Model.dump -local concat = table.concat - -local Public = {} - -local header_name = Gui.uid_name() -local left_panel_name = Gui.uid_name() -local right_panel_name = Gui.uid_name() -local input_text_box_name = Gui.uid_name() -local refresh_name = Gui.uid_name() - -Public.name = "Datastore" - -function Public.show(container) - local main_flow = container.add{ type = "flow", direction = "horizontal" } - - local left_panel = main_flow.add{ type = "scroll-pane", name = left_panel_name } - local left_panel_style = left_panel.style - left_panel_style.width = 300 - - for name in pairs(table.key_sort(Datastore.debug())) do - local header = left_panel.add{ type = "flow" }.add{ type = "label", name = header_name, caption = name } - Gui.set_data(header, name) - end - - local right_flow = main_flow.add{ type = "flow", direction = "vertical" } - - local right_top_flow = right_flow.add{ type = "flow", direction = "horizontal" } - - local input_text_box = right_top_flow.add{ type = "text-box", name = input_text_box_name } - local input_text_box_style = input_text_box.style - input_text_box_style.horizontally_stretchable = true - input_text_box_style.height = 32 - input_text_box_style.maximal_width = 1000 - - local refresh_button = - right_top_flow.add{ type = "sprite-button", name = refresh_name, sprite = "utility/reset", tooltip = "refresh" } - local refresh_button_style = refresh_button.style - refresh_button_style.width = 32 - refresh_button_style.height = 32 - - local right_panel = right_flow.add{ type = "text-box", name = right_panel_name } - right_panel.read_only = true - right_panel.selectable = true - - local right_panel_style = right_panel.style - right_panel_style.vertically_stretchable = true - right_panel_style.horizontally_stretchable = true - right_panel_style.maximal_width = 1000 - right_panel_style.maximal_height = 1000 - - local data = { - right_panel = right_panel, - input_text_box = input_text_box, - selected_header = nil, - } - - Gui.set_data(input_text_box, data) - Gui.set_data(left_panel, data) - Gui.set_data(refresh_button, data) -end - -Gui.on_click( - header_name, - function(event) - local element = event.element - local table_name = Gui.get_data(element) - - local left_panel = element.parent.parent - local data = Gui.get_data(left_panel) - local right_panel = data.right_panel - local selected_header = data.selected_header - local input_text_box = data.input_text_box - - if selected_header then - selected_header.style.font_color = Color.white - end - - element.style.font_color = Color.orange - data.selected_header = element - - input_text_box.text = table_name - input_text_box.style.font_color = Color.black - - local content = Datastore.debug(table_name) - local content_string = {} - for key, value in pairs(content) do - content_string[#content_string + 1] = key:gsub("^%l", string.upper) .. " = " .. dump(value) - end - - right_panel.text = concat(content_string, "\n") - end -) - -local function update_dump(text_input, data) - local content = Datastore.debug(text_input.text) - local content_string = {} - for key, value in pairs(content) do - content_string[#content_string + 1] = key:gsub("^%l", string.upper) .. " = " .. dump(value) - end - - data.right_panel.text = concat(content_string, "\n") -end - -Gui.on_text_changed( - input_text_box_name, - function(event) - local element = event.element - local data = Gui.get_data(element) - - update_dump(element, data) - end -) - -Gui.on_click( - refresh_name, - function(event) - local element = event.element - local data = Gui.get_data(element) - - local input_text_box = data.input_text_box - - update_dump(input_text_box, data) - end -) - -return Public diff --git a/exp_scenario/module/gui/debug/main_view.lua b/exp_scenario/module/gui/debug/main_view.lua index bd2b5eb7..fdbac140 100644 --- a/exp_scenario/module/gui/debug/main_view.lua +++ b/exp_scenario/module/gui/debug/main_view.lua @@ -5,7 +5,6 @@ local Public = {} local pages = { require("modules/exp_scenario/gui/debug/redmew_global_view"), - require("modules/exp_scenario/gui/debug/expcore_datastore_view"), require("modules/exp_scenario/gui/debug/expcore_gui_view"), require("modules/exp_scenario/gui/debug/global_view"), require("modules/exp_scenario/gui/debug/package_view"), diff --git a/exp_scenario/module/locale/en.cfg b/exp_scenario/module/locale/en.cfg index 0f3bcba3..037646a8 100644 --- a/exp_scenario/module/locale/en.cfg +++ b/exp_scenario/module/locale/en.cfg @@ -528,6 +528,18 @@ tooltip-goto-disabled=You are not on a warp point, walk to a warp point [exp_afk-kick] kick-message=All players were kicked because everyone was AFK. +[info] +discord=Join us on our discord at: __1__ +website=Please visit our website at: __1__ +status=Want to check if out servers are down? Visit: __1__ +github=Want to help improve our server with some extra features? Help us at: __1__ +patreon=Consider supporting our server at: __1__ +custom-commands=We use custom commands, such as /tag and /me, use /chelp for more info. +read-readme=Make sure you have read the information gui (It can be found through the info mark on the top left) +softmod=We run a softmod on our servers. A softmod is a custom scenario that runs on this server, an example is the player list. +redmew=We don't talk about redmew here; they beat us to 1000 members ;-; +lhd=All trains must be LHD! This is a long standing rule on our servers, please respect this. + [exp_chat-auto-reply] chat-reply=[Compilatron] __1__ chat-disallowed=You can't use global chat commands diff --git a/exp_scenario/module/locale/zh-CN.cfg b/exp_scenario/module/locale/zh-CN.cfg index e6fee7d8..3fdd5f2e 100644 --- a/exp_scenario/module/locale/zh-CN.cfg +++ b/exp_scenario/module/locale/zh-CN.cfg @@ -518,6 +518,18 @@ message-research=你要研究 [technology=__1__] 在 __2__ 級 才可使用太 [exp_afk-kick] kick-message=因地圖中沒有活躍玩家,所以所有人都已被請離。 +[info] +discord=加入社群: __1__ +website=訪問網站: __1__ +status=伺服器狀態: __1__ +github=增加功能或回報錯誤: __1__ +patreon=支持我們: __1__ +custom-commands=這裹使用了自制指令,如 /tag 和 /me。可使用 /chelp 查看更多。 +read-readme=確保你已閱讀相關資訊。按左上 i 圖標可再次查看 +softmod=這裹用了自設情境,是一種軟裝模組。 +redmew=We don't talk about redmew here; they beat us to 1000 members ;-; +lhd=列車必須是左則通行。這是本服務器長久以來的規則。 + [exp_chat-auto-reply] chat-reply=[Compilatron] __1__ chat-disallowed=You can't use global chat commands diff --git a/exp_scenario/module/locale/zh-TW.cfg b/exp_scenario/module/locale/zh-TW.cfg index e6fee7d8..3fdd5f2e 100644 --- a/exp_scenario/module/locale/zh-TW.cfg +++ b/exp_scenario/module/locale/zh-TW.cfg @@ -518,6 +518,18 @@ message-research=你要研究 [technology=__1__] 在 __2__ 級 才可使用太 [exp_afk-kick] kick-message=因地圖中沒有活躍玩家,所以所有人都已被請離。 +[info] +discord=加入社群: __1__ +website=訪問網站: __1__ +status=伺服器狀態: __1__ +github=增加功能或回報錯誤: __1__ +patreon=支持我們: __1__ +custom-commands=這裹使用了自制指令,如 /tag 和 /me。可使用 /chelp 查看更多。 +read-readme=確保你已閱讀相關資訊。按左上 i 圖標可再次查看 +softmod=這裹用了自設情境,是一種軟裝模組。 +redmew=We don't talk about redmew here; they beat us to 1000 members ;-; +lhd=列車必須是左則通行。這是本服務器長久以來的規則。 + [exp_chat-auto-reply] chat-reply=[Compilatron] __1__ chat-disallowed=You can't use global chat commands diff --git a/exp_server_list/README.md b/exp_server_list/README.md index 93d18450..61e621f8 100644 --- a/exp_server_list/README.md +++ b/exp_server_list/README.md @@ -1,6 +1,6 @@ # ExpGaming - Server List -Shares the details of every instance with every other instance. Replaces `expcore/external` from exp_legacy, which read the same data from a file written by an outside service. +Shares the details of every instance with every other instance. The controller keeps one record per instance with its name, mod pack, Factorio version, players online, status and address. The address is the public address of the host and the game port of the instance, the same one the web UI shows. Whether the server has a game password is sent, the password is not. Nothing is saved, the records are rebuilt from the instances when the controller starts. diff --git a/tsconfig.json b/tsconfig.json index 09f58f29..5bbb0076 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,6 @@ { "path": "./exp_discord_auth/" }, { "path": "./exp_groups/" }, { "path": "./exp_gui/" }, - { "path": "./exp_legacy/" }, { "path": "./exp_reports/" }, { "path": "./exp_roles/" }, { "path": "./exp_scenario/" },