mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 07:24:52 +09:00
Player session end tracking
This commit is contained in:
1 parent
2eedddf17f
commit
73ab8fc4f3
5 files changed
+288
-3
No files matched your search
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user