// ================================================================ // 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//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 (This event works according to logs) const attackEvent = uw.GameEvents?.attack?.incoming || 'attack:incoming'; uw.$.Observer(attackEvent).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.) // Fallback to string if the constant doesn't exist in this version const cmdEvent = (uw.GameEvents?.command && uw.GameEvents.command.change) ? uw.GameEvents.command.change : 'CommandsMenuBubble:change'; uw.$.Observer(cmdEvent).subscribe( 'GRC_TRACKER_CMD', function(e, data) { setTimeout(_pushMovements, 500); } ); log('[tracker] ✅ Subscribed to command changes'); } catch (e) { log(`[tracker] Could not subscribe to command changes: ${e}`); } // --- Failsafe: push every 15 seconds regardless of events --- setInterval(_pushMovements, 15000); }, 6000); // 6s after boot — ensures CommandsMenuBubble model is loaded } // ---------------------------------------------------------------- // Expose initTracker so 05_main.js boot() can call it // ---------------------------------------------------------------- window._grcInitTracker = initTracker; })();