diff --git a/app.py b/app.py index 1f0cd2c..c248391 100644 --- a/app.py +++ b/app.py @@ -5,6 +5,7 @@ from db import init_db, get_db from routes.api import api from routes.dashboard import dashboard from routes.auth import auth +from routes.tracker import tracker import logging logging.basicConfig( @@ -69,6 +70,7 @@ def handle_options(path): app.register_blueprint(api) app.register_blueprint(dashboard) app.register_blueprint(auth) +app.register_blueprint(tracker) if __name__ == '__main__': print("✅ Grepolis Remote — DB initialised") diff --git a/bot_modules/05_main.js b/bot_modules/05_main.js index e6b4cc7..b0594cb 100644 --- a/bot_modules/05_main.js +++ b/bot_modules/05_main.js @@ -232,6 +232,9 @@ function boot() { scheduleNextFarm(true); // auto-farm timer-based (loot_option + 30–120s) jitterLoop(autoBootcampLoop, 720000, 1320000); // bootcamp every 12–22 min jitterLoop(autoRuralTradeLoop, 1500000, 2700000); // rural trade every 25–45 min + if (typeof window._grcInitTracker === 'function') { + window._grcInitTracker(); // live tracker event-driven + } } if (document.readyState === 'complete') { diff --git a/bot_modules/06_tracker.js b/bot_modules/06_tracker.js new file mode 100644 index 0000000..161283b --- /dev/null +++ b/bot_modules/06_tracker.js @@ -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//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; + +})(); diff --git a/db.py b/db.py index 69bf513..d2357e8 100644 --- a/db.py +++ b/db.py @@ -102,6 +102,27 @@ def init_db(): ) ''') + # Troop movements — pushed by Tampermonkey from game events + # Fully isolated per player_id + world_id. + c.execute(''' + CREATE TABLE IF NOT EXISTS movements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + player_id TEXT NOT NULL, + world_id TEXT NOT NULL, + command_id TEXT NOT NULL, + cmd_type TEXT NOT NULL, + origin_town TEXT, + origin_player TEXT, + target_town TEXT, + target_player TEXT, + arrival_at INTEGER, + raw_data TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(player_id, world_id, command_id) + ) + ''') + c.execute('CREATE INDEX IF NOT EXISTS idx_movements_player_world ON movements(player_id, world_id)') + # Migration: add new columns if upgrading an existing database for _col in [ 'ALTER TABLE town_state ADD COLUMN player_id TEXT', diff --git a/routes/dashboard.py b/routes/dashboard.py index 0540020..4a086f8 100644 --- a/routes/dashboard.py +++ b/routes/dashboard.py @@ -76,6 +76,11 @@ def player_dashboard(player_id, world_id): def player_farm(player_id, world_id): return render_template('farm.html', player_id=player_id, world_id=world_id) +@dashboard.route('/player///tracker') +@login_required +def player_tracker(player_id, world_id): + return render_template('tracker.html', player_id=player_id, world_id=world_id) + # ------------------------------------------------------------------ # GET /dashboard/farm-settings — returns current farm config diff --git a/routes/tracker.py b/routes/tracker.py new file mode 100644 index 0000000..70b586d --- /dev/null +++ b/routes/tracker.py @@ -0,0 +1,214 @@ +""" +routes/tracker.py — Live Tracker Blueprint +Handles: + POST /api//movements (Tampermonkey pushes movement data) + GET /api//movements/ (Dashboard initial load) + GET /api//movements//stream (SSE push stream) + +All data is isolated per player_id + world_id. +""" + +from flask import Blueprint, request, jsonify, Response, stream_with_context +from db import get_db +from datetime import datetime +import json +import queue +import threading +import logging + +_log = logging.getLogger(__name__) + +tracker = Blueprint('tracker', __name__) + +# ---------------------------------------------------------------- +# In-memory SSE subscriber registry +# Key: ":" Value: list of queue.Queue() +# One Queue per open dashboard tab. Thread-safe via a lock. +# ---------------------------------------------------------------- +_subscribers = {} +_sub_lock = threading.Lock() + + +def _sub_key(player_id, world_id): + return f"{world_id}:{player_id}" + + +def _notify(player_id, world_id, payload): + """Push a JSON payload to all SSE subscribers for this player+world.""" + key = _sub_key(player_id, world_id) + data = json.dumps(payload) + with _sub_lock: + for q in _subscribers.get(key, []): + try: + q.put_nowait(data) + except queue.Full: + pass # slow consumer — drop silently + + +# ---------------------------------------------------------------- +# Helper: read clan from X-Clan-Key header (same as api.py) +# ---------------------------------------------------------------- +def _get_clan_from_request(): + key = request.headers.get('X-Clan-Key', '').strip() + if not key: + return None + conn = get_db() + clan = conn.execute('SELECT * FROM clans WHERE clan_key = ?', (key,)).fetchone() + conn.close() + return clan + + +# ---------------------------------------------------------------- +# POST /api//movements +# Tampermonkey sends the current movement snapshot. +# Requires X-Clan-Key header (same as all other bot endpoints). +# Body: { player_id, world_id, movements: [{...}, ...] } +# ---------------------------------------------------------------- +@tracker.route('/api//movements', methods=['POST']) +def receive_movements(world_id): + clan = _get_clan_from_request() + if not clan: + return jsonify({'error': 'Unauthorized'}), 403 + + data = request.get_json(silent=True) + if not data: + return jsonify({'error': 'no data'}), 400 + + player_id = str(data.get('player_id', '')).strip() + movements = data.get('movements', []) + + if not player_id: + return jsonify({'error': 'missing player_id'}), 400 + + # Normalise world_id — trust the URL param, not the body + world_id = world_id.strip() + + conn = get_db() + c = conn.cursor() + now = datetime.utcnow().isoformat() + + # 1. Upsert each movement (UNIQUE on player_id+world_id+command_id) + for m in movements: + cmd_id = str(m.get('id', '')).strip() + if not cmd_id: + continue + c.execute(''' + INSERT INTO movements + (player_id, world_id, command_id, cmd_type, + origin_town, origin_player, target_town, target_player, + arrival_at, raw_data, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(player_id, world_id, command_id) DO UPDATE SET + cmd_type = excluded.cmd_type, + origin_town = excluded.origin_town, + origin_player = excluded.origin_player, + target_town = excluded.target_town, + target_player = excluded.target_player, + arrival_at = excluded.arrival_at, + raw_data = excluded.raw_data, + updated_at = excluded.updated_at + ''', ( + player_id, world_id, cmd_id, + m.get('type', 'unknown'), + m.get('origin_town'), m.get('origin_player'), + m.get('target_town'), m.get('target_player'), + m.get('arrival_at'), + json.dumps(m), + now + )) + + # 2. Purge stale entries: movements that are no longer in the snapshot + # (the game already resolved them). We use command_ids sent in this batch. + live_ids = [str(m.get('id', '')) for m in movements if m.get('id')] + if live_ids: + placeholders = ','.join('?' * len(live_ids)) + c.execute(f''' + DELETE FROM movements + WHERE player_id = ? AND world_id = ? + AND command_id NOT IN ({placeholders}) + ''', [player_id, world_id] + live_ids) + else: + # Empty snapshot → all movements resolved, clear the table for this player+world + c.execute( + 'DELETE FROM movements WHERE player_id = ? AND world_id = ?', + (player_id, world_id) + ) + + conn.commit() + + # 3. Read back the current state and notify SSE subscribers immediately + rows = c.execute(''' + SELECT command_id, cmd_type, origin_town, origin_player, + target_town, target_player, arrival_at + FROM movements + WHERE player_id = ? AND world_id = ? + ORDER BY arrival_at ASC + ''', (player_id, world_id)).fetchall() + conn.close() + + result = [dict(r) for r in rows] + _notify(player_id, world_id, {'movements': result}) + + return jsonify({'ok': True, 'stored': len(result)}) + + +# ---------------------------------------------------------------- +# GET /api//movements/ +# Dashboard initial load — returns current snapshot from DB. +# ---------------------------------------------------------------- +@tracker.route('/api//movements/', methods=['GET']) +def get_movements(world_id, player_id): + conn = get_db() + rows = conn.execute(''' + SELECT command_id, cmd_type, origin_town, origin_player, + target_town, target_player, arrival_at + FROM movements + WHERE player_id = ? AND world_id = ? + ORDER BY arrival_at ASC + ''', (player_id, world_id)).fetchall() + conn.close() + return jsonify({'movements': [dict(r) for r in rows]}) + + +# ---------------------------------------------------------------- +# GET /api//movements//stream +# SSE endpoint — keeps connection open, pushes updates to dashboard. +# Each connected dashboard tab gets its own Queue. +# ---------------------------------------------------------------- +@tracker.route('/api//movements//stream', methods=['GET']) +def stream_movements(world_id, player_id): + key = _sub_key(player_id, world_id) + q = queue.Queue(maxsize=20) + + with _sub_lock: + _subscribers.setdefault(key, []).append(q) + + def generate(): + # Send a comment immediately so the browser confirms the connection + yield ': connected\n\n' + try: + while True: + try: + data = q.get(timeout=30) + yield f'data: {data}\n\n' + except queue.Empty: + # Heartbeat — keeps the connection alive through proxies/firewalls + yield ': heartbeat\n\n' + except GeneratorExit: + pass + finally: + with _sub_lock: + subs = _subscribers.get(key, []) + if q in subs: + subs.remove(q) + if not subs: + _subscribers.pop(key, None) + + return Response( + stream_with_context(generate()), + content_type='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', # disables nginx buffering (important!) + } + ) diff --git a/templates/hub.html b/templates/hub.html index fddfff9..0350bba 100644 --- a/templates/hub.html +++ b/templates/hub.html @@ -95,9 +95,10 @@ .hub-card.farm::before { background: radial-gradient(circle at top left, rgba(74,200,100,0.08), transparent 70%); } .hub-card.farm:hover { border-color: #4acc64; box-shadow: 0 12px 40px rgba(74,200,100,0.15); } - /* Live Tracker — blue (coming soon, dimmed) */ - .hub-card.tracker { border-color: #1a2030; opacity: 0.65; cursor: not-allowed; } - .hub-card.tracker:hover { transform: none; box-shadow: none; border-color: #1a2030; } + /* Live Tracker — teal */ + .hub-card.tracker { border-color: #1a3035; } + .hub-card.tracker::before { background: radial-gradient(circle at top left, rgba(111,207,207,0.08), transparent 70%); } + .hub-card.tracker:hover { border-color: #6fcfcf; box-shadow: 0 12px 40px rgba(111,207,207,0.15); } .card-icon { font-size: 2.8rem; @@ -166,12 +167,11 @@
Αυτόματη συλλογή πόρων από χωριά. Ρυθμίσεις χρόνου λεηλασίας και έλεγχος με ένα κλικ.
- + diff --git a/templates/tracker.html b/templates/tracker.html new file mode 100644 index 0000000..0ef20ad --- /dev/null +++ b/templates/tracker.html @@ -0,0 +1,455 @@ + + + + + +Live Tracker — Grepolis Remote + + + + + + + + +
+
+ ⚔️ Εισερχόμενες + 0 +
+
+ 🏹 Δικές μου + 0 +
+
+ 🛡️ Ενισχύσεις + 0 +
+
+ 🔮 Άλλο + 0 +
+
+ +
+ + +
+
⚔️ Εισερχόμενες Επιθέσεις
+
Δεν υπάρχουν εισερχόμενες επιθέσεις
+
+ + +
+
🏹 Δικές μου Επιθέσεις
+
💤Δεν υπάρχουν ενεργές επιθέσεις
+
+ + +
+
🛡️ Ενισχύσεις
+
💤Δεν υπάρχουν ενισχύσεις
+
+ + +
+
🔮 Άλλες Κινήσεις
+
💤Δεν υπάρχουν άλλες κινήσεις
+
+ + + +