Files
factorio-expcluster/exp_statistics/test/module/control.lua
T
bbassieandClaude Opus 5.5 d1b22dd4e7 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 <noreply@anthropic.com>
2026-10-14 22:44:59 +00:00

121 lines
5.0 KiB
Lua

local Suite = ... --- @type Suite<ExpStatistics.TestEnv>
Suite.test("increment() adds to the unsent statistics", function(env)
local Stats = env.Statistics
local alice = env.add_player{ name = "alice" }
Stats.increment(alice, "Kills")
Stats.increment(alice, "Kills", 4)
Suite.eq(Stats.get(alice, "Kills"), 5, "by one, then by the amount")
Suite.eq(Stats.get("alice", "Kills"), 5, "by player name")
Suite.eq(Stats.get(alice, "Deaths"), 0, "missing statistics are zero")
end)
Suite.test("send_all() sends every player with changes in one update", function(env)
local Stats = env.Statistics
local alice = env.add_player{ name = "alice" }
local bob = env.add_player{ name = "bob" }
env.add_player{ name = "carol" }
Stats.increment(alice, "Kills", 2)
Stats.increment(bob, "Deaths")
Stats.send_all()
Suite.eq(#env.sent, 1, "one update")
Suite.eq(assert(env.sent[1]).channel, "exp_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()