live tracker
This commit is contained in:
163
bot_modules/06_tracker.js
Normal file
163
bot_modules/06_tracker.js
Normal file
@@ -0,0 +1,163 @@
|
||||
// ================================================================
|
||||
// 06_tracker.js — Live Tracker: movement & attack monitoring
|
||||
// Depends on: 00_config.js (BASE_URL, apiFetch, log)
|
||||
//
|
||||
// Strategy (Option B — event-driven + one initial load):
|
||||
// 1. On boot: read current movements from game memory, push to backend
|
||||
// 2. On GameEvents.attack.incoming or GameEvents.command.change:
|
||||
// re-read movements, push to backend (backend notifies SSE clients)
|
||||
//
|
||||
// Data source: CommandsMenuBubble (Grepolis internal Backbone model)
|
||||
// - Already used by the Sound Alarm script — proven safe
|
||||
// - Zero extra server requests to Grepolis
|
||||
// - Contains ALL movement types: incoming attacks, own attacks, support
|
||||
//
|
||||
// All pushes go to POST /api/<world_id>/movements with X-Clan-Key.
|
||||
// Backend is fully isolated per player_id + world_id.
|
||||
// ================================================================
|
||||
|
||||
(function() {
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Internal state — prevent overlapping pushes
|
||||
// ----------------------------------------------------------------
|
||||
let _trackerPushPending = false;
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _extractMovements — reads CommandsMenuBubble from game memory
|
||||
// Returns a clean array of movement objects safe to send to backend.
|
||||
//
|
||||
// Source: Sound Alarm script already validated this model works.
|
||||
// We read .commands which is a list of all active troop movements.
|
||||
// ----------------------------------------------------------------
|
||||
function _extractMovements() {
|
||||
try {
|
||||
const player_id = uw.Game?.player_id;
|
||||
if (!player_id) return [];
|
||||
|
||||
// CommandsMenuBubble holds all movement commands for the player
|
||||
const cmb = uw.MM.checkAndPublishRawModel('CommandsMenuBubble', { id: player_id });
|
||||
if (!cmb) return [];
|
||||
|
||||
const commands = cmb.get('commands') || [];
|
||||
const movements = [];
|
||||
|
||||
for (const cmd of commands) {
|
||||
const attrs = cmd.attributes || cmd;
|
||||
if (!attrs) continue;
|
||||
|
||||
// Normalise command type to a readable key
|
||||
const cmdType = _normaliseType(attrs.type || attrs.command_type || '');
|
||||
|
||||
movements.push({
|
||||
id: String(attrs.id || attrs.command_id || ''),
|
||||
type: cmdType,
|
||||
origin_town: attrs.origin_town_name || attrs.origin?.town_name || null,
|
||||
origin_player: attrs.origin_player_name|| attrs.origin?.player_name|| null,
|
||||
target_town: attrs.target_town_name || attrs.target?.town_name || null,
|
||||
target_player: attrs.target_player_name|| attrs.target?.player_name|| null,
|
||||
// arrival_at is a Unix timestamp (seconds)
|
||||
arrival_at: attrs.arrival_at || attrs.arrival || null,
|
||||
});
|
||||
}
|
||||
|
||||
return movements.filter(m => m.id); // drop any without an ID
|
||||
|
||||
} catch (e) {
|
||||
log(`[tracker] Extract error: ${e}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _normaliseType — maps game's internal type strings to clean keys
|
||||
// ----------------------------------------------------------------
|
||||
function _normaliseType(raw) {
|
||||
const t = String(raw).toLowerCase();
|
||||
if (t.includes('attack') && t.includes('sea')) return 'attack_sea';
|
||||
if (t.includes('attack') && t.includes('land')) return 'attack_land';
|
||||
if (t.includes('attack')) return 'attack_land';
|
||||
if (t.includes('support')) return 'support';
|
||||
if (t.includes('farm') || t.includes('loot')) return 'farming';
|
||||
if (t.includes('spy') || t.includes('espion')) return 'espionage';
|
||||
if (t.includes('settle') || t.includes('colon'))return 'colonization';
|
||||
return t || 'unknown';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _pushMovements — reads memory, sends to backend
|
||||
// Debounced: if a push is already in-flight, skip.
|
||||
// ----------------------------------------------------------------
|
||||
async function _pushMovements() {
|
||||
if (_trackerPushPending) return;
|
||||
_trackerPushPending = true;
|
||||
|
||||
try {
|
||||
const player_id = uw.Game?.player_id;
|
||||
const world_id = uw.Game?.world_id;
|
||||
if (!player_id || !world_id) return;
|
||||
|
||||
const movements = _extractMovements();
|
||||
log(`[tracker] Pushing ${movements.length} movement(s) for ${world_id}`);
|
||||
|
||||
await apiFetch(`${BASE_URL}/api/${world_id}/movements`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ player_id, world_id, movements })
|
||||
});
|
||||
} catch (e) {
|
||||
log(`[tracker] Push failed: ${e}`);
|
||||
} finally {
|
||||
_trackerPushPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// initTracker — called from boot() after game is ready
|
||||
//
|
||||
// 1. Immediate push (Option B initial load)
|
||||
// 2. Bind GameEvents for passive real-time updates
|
||||
// ----------------------------------------------------------------
|
||||
function initTracker() {
|
||||
// Wait a moment for the game models to fully initialise
|
||||
setTimeout(async () => {
|
||||
// --- Initial load push ---
|
||||
await _pushMovements();
|
||||
|
||||
// --- Bind to GameEvents (passive, zero server cost) ---
|
||||
try {
|
||||
// New incoming attack detected
|
||||
uw.$.Observer(uw.GameEvents.attack.incoming).subscribe(
|
||||
'GRC_TRACKER_ATTACK',
|
||||
function(e, data) {
|
||||
// Small delay so game model updates before we read it
|
||||
setTimeout(_pushMovements, 500);
|
||||
}
|
||||
);
|
||||
log('[tracker] ✅ Subscribed to attack.incoming event');
|
||||
} catch (e) {
|
||||
log(`[tracker] Could not subscribe to attack.incoming: ${e}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Any command state changed (sent, landed, recalled, etc.)
|
||||
uw.$.Observer(uw.GameEvents.command.change).subscribe(
|
||||
'GRC_TRACKER_CMD',
|
||||
function(e, data) {
|
||||
setTimeout(_pushMovements, 500);
|
||||
}
|
||||
);
|
||||
log('[tracker] ✅ Subscribed to command.change event');
|
||||
} catch (e) {
|
||||
log(`[tracker] Could not subscribe to command.change: ${e}`);
|
||||
}
|
||||
|
||||
}, 6000); // 6s after boot — ensures CommandsMenuBubble model is loaded
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Expose initTracker so 05_main.js boot() can call it
|
||||
// ----------------------------------------------------------------
|
||||
window._grcInitTracker = initTracker;
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user