Player session end tracking

This commit is contained in:
Danielv123 committed 2026-01-29 21:51:19 +01:00
1 parent 2eedddf17f
commit 73ab8fc4f3
5 files changed
+288 -3

No files matched your search

+200 -3
View File
@@ -1,5 +1,5 @@
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin } from "@clusterio/controller";
import { BaseControllerPlugin, type InstanceInfo } from "@clusterio/controller";
import {
TileDataEvent,
GetRawTileRequest,
@@ -10,6 +10,7 @@ import {
RecipeDataEvent,
GetRawRecipeTileRequest,
PlayerPositionEvent,
PlayerSessionEndEvent,
type PlayerData,
GetPlayerPathRequest,
ClearMinimapSurfaceDataRequest,
@@ -53,6 +54,17 @@ interface EnrichedPlayerData extends PlayerData {
_playerId: number;
}
interface QueuedPlayerSessionStart {
playerId: number;
playerName: string;
tMs: number;
}
interface QueuedPlayerSessionEnd {
playerId: number;
tMs: number;
}
export class ControllerPlugin extends BaseControllerPlugin {
private tilesPath: string = "";
private chartTagsPath: string = "";
@@ -62,6 +74,8 @@ export class ControllerPlugin extends BaseControllerPlugin {
private chartTagQueues = new Map<string, ChartTagData[]>();
private recipeSavingQueue = new Map<string, Buffer[]>();
private playerPositionQueues = new Map<string, EnrichedPlayerData[]>();
private playerSessionStartQueues = new Map<string, QueuedPlayerSessionStart[]>();
private playerSessionEndQueues = new Map<string, QueuedPlayerSessionEnd[]>();
// Player session tracking per surface file
// fileKey -> playerName -> playerId
private playerSessions = new Map<string, Map<string, number>>();
@@ -117,6 +131,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
this.controller.handle(RecipeDataEvent, this.handleRecipeDataEvent.bind(this));
this.controller.handle(GetRawRecipeTileRequest, this.handleGetRawRecipeTileRequest.bind(this));
this.controller.handle(PlayerPositionEvent, this.handlePlayerPositionEvent.bind(this));
this.controller.handle(PlayerSessionEndEvent, this.handlePlayerSessionEndEvent.bind(this));
this.controller.handle(GetPlayerPathRequest, this.handleGetPlayerPathRequest.bind(this));
this.controller.handle(
ClearMinimapSurfaceDataRequest,
@@ -270,6 +285,16 @@ export class ControllerPlugin extends BaseControllerPlugin {
this.playerPositionQueues.delete(key);
}
}
for (const key of Array.from(this.playerSessionStartQueues.keys())) {
if (key === `${instanceId}_${surface}`) {
this.playerSessionStartQueues.delete(key);
}
}
for (const key of Array.from(this.playerSessionEndQueues.keys())) {
if (key === `${instanceId}_${surface}`) {
this.playerSessionEndQueues.delete(key);
}
}
}
private removeSurfaceCaches(instanceId: number, surface: string, force?: string) {
@@ -330,6 +355,8 @@ export class ControllerPlugin extends BaseControllerPlugin {
this.chartTagQueues.clear();
this.recipeSavingQueue.clear();
this.playerPositionQueues.clear();
this.playerSessionStartQueues.clear();
this.playerSessionEndQueues.clear();
this.lastSeenTagContent.clear();
this.lastSeenRecipeContent.clear();
@@ -343,6 +370,45 @@ export class ControllerPlugin extends BaseControllerPlugin {
this.logger.info("Cleared all minimap data");
}
async onInstanceStatusChanged(instance: InstanceInfo) {
if (instance.status === "running") {
return;
}
const now = Date.now();
let queued = false;
for (const [fileKey, activeSessions] of this.activePlayerSessions) {
if (!fileKey.startsWith(`${instance.id}_`)) {
continue;
}
if (activeSessions.size === 0) {
continue;
}
const sessions = this.playerSessions.get(fileKey);
if (!sessions) {
activeSessions.clear();
continue;
}
for (const playerName of activeSessions) {
const playerId = sessions.get(playerName);
if (playerId === undefined) {
continue;
}
this.queuePlayerSessionEnd(fileKey, playerId, now);
queued = true;
}
activeSessions.clear();
}
if (queued) {
this.savePlayerPositions().catch(err => this.logger.error(`Error saving player positions: ${err}`));
}
}
async onShutdown() {
if (this.saveInterval) {
@@ -1102,28 +1168,91 @@ export class ControllerPlugin extends BaseControllerPlugin {
}
}
private queuePlayerSessionStart(fileKey: string, playerId: number, playerName: string, tMs: number) {
if (!this.playerSessionStartQueues.has(fileKey)) {
this.playerSessionStartQueues.set(fileKey, []);
}
this.playerSessionStartQueues.get(fileKey)!.push({
playerId,
playerName,
tMs,
});
}
private queuePlayerSessionEnd(fileKey: string, playerId: number, tMs: number) {
if (!this.playerSessionEndQueues.has(fileKey)) {
this.playerSessionEndQueues.set(fileKey, []);
}
this.playerSessionEndQueues.get(fileKey)!.push({
playerId,
tMs,
});
}
async savePlayerPositions() {
if (this.savePlayerPositionsInProgress) {
return;
}
if (this.playerPositionQueues.size === 0) {
if (
this.playerPositionQueues.size === 0
&& this.playerSessionStartQueues.size === 0
&& this.playerSessionEndQueues.size === 0
) {
return;
}
this.savePlayerPositionsInProgress = true;
const queue = this.playerPositionQueues;
this.playerPositionQueues = new Map();
const sessionStartQueue = this.playerSessionStartQueues;
this.playerSessionStartQueues = new Map();
const sessionEndQueue = this.playerSessionEndQueues;
this.playerSessionEndQueues = new Map();
try {
const promises: Promise<void>[] = [];
for (const [fileKey, positions] of queue) {
const fileKeys = new Set([
...queue.keys(),
...sessionStartQueue.keys(),
...sessionEndQueue.keys(),
]);
for (const fileKey of fileKeys) {
const positions = queue.get(fileKey) ?? [];
const sessionStarts = sessionStartQueue.get(fileKey) ?? [];
const sessionEnds = sessionEndQueue.get(fileKey) ?? [];
const promise = (async () => {
const filePath = path.join(this.playerPositionsPath, `${fileKey}.positions`);
// Convert position data to binary format
const buffers: Buffer[] = [];
for (const sessionStart of sessionStarts) {
const nameBytes = Buffer.from(sessionStart.playerName, "utf8");
const buffer = Buffer.alloc(1 + 4 + 2 + 1 + nameBytes.length);
let offset = 0;
// Type (1 byte): 1 = SessionStart
buffer.writeUInt8(1, offset);
offset += 1;
// t_ms (4 bytes): Current timestamp in milliseconds (uint32 wrap)
const tMs = Math.floor(sessionStart.tMs) >>> 0;
buffer.writeUInt32BE(tMs, offset);
offset += 4;
// player_id (2 bytes)
buffer.writeUInt16BE(sessionStart.playerId & 0xFFFF, offset);
offset += 2;
// name_len (1 byte) + name bytes
buffer.writeUInt8(nameBytes.length & 0xFF, offset);
offset += 1;
nameBytes.copy(buffer, offset);
buffers.push(buffer);
}
for (const position of positions) {
// Position record (type 0): 1 + 4 + 4 + 3 + 3 + 2 = 17 bytes
const buffer = Buffer.alloc(17);
@@ -1162,6 +1291,25 @@ export class ControllerPlugin extends BaseControllerPlugin {
buffers.push(buffer);
}
for (const sessionEnd of sessionEnds) {
const buffer = Buffer.alloc(1 + 4 + 2);
let offset = 0;
// Type (1 byte): 2 = SessionEnd
buffer.writeUInt8(2, offset);
offset += 1;
// t_ms (4 bytes): Current timestamp in milliseconds (uint32 wrap)
const tMs = Math.floor(sessionEnd.tMs) >>> 0;
buffer.writeUInt32BE(tMs, offset);
offset += 4;
// player_id (2 bytes)
buffer.writeUInt16BE(sessionEnd.playerId & 0xFFFF, offset);
buffers.push(buffer);
}
if (buffers.length > 0) {
const concatenated = Buffer.concat(buffers);
await fs.appendFile(filePath, concatenated);
@@ -1272,18 +1420,27 @@ export class ControllerPlugin extends BaseControllerPlugin {
// Assign player ID if this player doesn't have one yet, or reopen their session
let playerId: number;
let shouldWriteSessionStart = false;
if (!sessions.has(player_data.player_name)) {
playerId = this.nextPlayerIds.get(fileKey)!;
sessions.set(player_data.player_name, playerId);
this.nextPlayerIds.set(fileKey, playerId + 1);
this.logger.verbose(`Assigned new player ID ${playerId} to ${player_data.player_name} on ${fileKey}`);
shouldWriteSessionStart = true;
} else {
playerId = sessions.get(player_data.player_name)!;
}
// Reopen/track active player session (handles session reopening after controller restart)
if (!activeSessions.has(player_data.player_name)) {
shouldWriteSessionStart = true;
}
activeSessions.add(player_data.player_name);
if (shouldWriteSessionStart) {
this.queuePlayerSessionStart(fileKey, playerId, player_data.player_name, Date.now());
}
// Add the player ID to the data for saving (without modifying the original schema)
const enrichedPlayerData = { ...player_data, _playerId: playerId };
@@ -1305,6 +1462,46 @@ export class ControllerPlugin extends BaseControllerPlugin {
}
}
async handlePlayerSessionEndEvent(event: PlayerSessionEndEvent) {
try {
const { instance_id, session_data } = event;
const fileKey = `${instance_id}_${session_data.surface}`;
if (!this.playerSessions.has(fileKey)) {
this.playerSessions.set(fileKey, new Map());
this.nextPlayerIds.set(fileKey, 1); // Start from 1, reserve 0 for unknown
this.activePlayerSessions.set(fileKey, new Set());
}
const sessions = this.playerSessions.get(fileKey)!;
const activeSessions = this.activePlayerSessions.get(fileKey)!;
let playerId = sessions.get(session_data.player_name);
let shouldWriteSessionStart = false;
if (playerId === undefined) {
playerId = this.nextPlayerIds.get(fileKey)!;
sessions.set(session_data.player_name, playerId);
this.nextPlayerIds.set(fileKey, playerId + 1);
shouldWriteSessionStart = true;
}
if (!activeSessions.has(session_data.player_name)) {
shouldWriteSessionStart = true;
}
const now = Date.now();
if (shouldWriteSessionStart) {
this.queuePlayerSessionStart(fileKey, playerId, session_data.player_name, now);
}
this.queuePlayerSessionEnd(fileKey, playerId, now);
activeSessions.delete(session_data.player_name);
} catch (err) {
this.logger.error(`Error processing player session end data: ${err}`);
}
}
async handleGetPlayerPathRequest(request: GetPlayerPathRequest) {
const { instance_id, surface } = request;
+2
View File
@@ -4,6 +4,7 @@ import {
TileDataEvent,
ChartTagDataEvent,
PlayerPositionEvent,
PlayerSessionEndEvent,
GetRawTileRequest,
GetChartTagsRequest,
GetPlayerPathRequest,
@@ -38,6 +39,7 @@ export const plugin: lib.PluginDeclaration = {
TileDataEvent,
ChartTagDataEvent,
PlayerPositionEvent,
PlayerSessionEndEvent,
RecipeDataEvent,
GetRawTileRequest,
GetChartTagsRequest,
+33
View File
@@ -9,6 +9,7 @@ import {
RecipeData,
PlayerPositionEvent,
PlayerData,
PlayerSessionEndEvent,
} from "./messages";
interface TileDataIpc {
@@ -49,6 +50,11 @@ interface PlayerPositionIpc {
sec: number;
}
interface PlayerSessionEndIpc {
player_name: string;
surface: string;
}
export class InstancePlugin extends BaseInstancePlugin {
pendingTileUpdates = new Map<string, Set<[number, number, [number, number, number, number]]>>();
@@ -84,6 +90,13 @@ export class InstancePlugin extends BaseInstancePlugin {
`Error handling player position data from Lua:\n${err}`
));
});
// Listen for player session end data from Lua module
this.instance.server.on("ipc-minimap:player_session_end", (data: PlayerSessionEndIpc) => {
this.handlePlayerSessionEndFromLua(data).catch(err => this.logger.error(
`Error handling player session end data from Lua:\n${err}`
));
});
}
async handleTileDataFromLua(data: TileDataIpc) {
@@ -188,6 +201,26 @@ export class InstancePlugin extends BaseInstancePlugin {
}
}
async handlePlayerSessionEndFromLua(data: PlayerSessionEndIpc) {
try {
if (!data) {
this.logger.error("Invalid player session end data received");
return;
}
const event = new PlayerSessionEndEvent(
this.instance.config.get("instance.id"),
{
player_name: data.player_name,
surface: data.surface,
}
);
this.instance.sendTo("controller", event);
} catch (err: unknown) {
this.logger.error(`Failed to process player session end data from Lua: ${err}`);
}
}
async onStart() {
// Initialize the Lua module when the instance starts
try {
+38
View File
@@ -56,6 +56,13 @@ export const PlayerDataSchema = Type.Object({
export type PlayerData = Static<typeof PlayerDataSchema>;
export const PlayerSessionEndDataSchema = Type.Object({
player_name: Type.String(),
surface: Type.String(),
});
export type PlayerSessionEndData = Static<typeof PlayerSessionEndDataSchema>;
/**
* PlayerPositionEvent: Used for both Factorio Instance -> Controller and Controller -> Web Clients
*
@@ -93,6 +100,37 @@ export class PlayerPositionEvent {
}
}
/**
* PlayerSessionEndEvent: Instance -> Controller
*
* Notifies controller that a player session has ended (disconnect).
*/
export class PlayerSessionEndEvent {
declare ["constructor"]: typeof PlayerSessionEndEvent;
static type = "event" as const;
static src = ["instance"] as const;
static dst = ["controller"] as const;
static plugin = "minimap" as const;
static permission = null;
constructor(
public instance_id: number,
public session_data: PlayerSessionEndData,
) { }
static jsonSchema = Type.Object({
"instance_id": Type.Number(),
"session_data": PlayerSessionEndDataSchema,
});
static fromJSON(json: Static<typeof PlayerSessionEndEvent.jsonSchema>) {
return new this(
json.instance_id,
json.session_data
);
}
}
/**
* TileDataEvent: Used for both Factorio Instance -> Controller and Controller -> Web Clients
*
+15
View File
@@ -42,6 +42,11 @@ local function send_player_position_data(player_data)
clusterio_api.send_json("minimap:player_position", player_data)
end
-- Send player session end data to plugin
local function send_player_session_end(session_data)
clusterio_api.send_json("minimap:player_session_end", session_data)
end
-- Check if a position has moved significantly (≥1 tile)
local function has_moved_significantly(old_pos, new_pos)
if not old_pos then
@@ -130,6 +135,16 @@ local function on_player_left_game(event)
return
end
local player = game.get_player(event.player_index)
if not player or not player.valid then
return
end
send_player_session_end({
player_name = player.name,
surface = player.surface.name,
})
-- Clean up position tracking data
if storage.minimap.player_positions then
storage.minimap.player_positions[event.player_index] = nil