live tracker

This commit is contained in:
2026-05-03 03:04:47 +03:00
parent 76b991a62b
commit 47381a9304
8 changed files with 869 additions and 6 deletions

2
app.py
View File

@@ -5,6 +5,7 @@ from db import init_db, get_db
from routes.api import api from routes.api import api
from routes.dashboard import dashboard from routes.dashboard import dashboard
from routes.auth import auth from routes.auth import auth
from routes.tracker import tracker
import logging import logging
logging.basicConfig( logging.basicConfig(
@@ -69,6 +70,7 @@ def handle_options(path):
app.register_blueprint(api) app.register_blueprint(api)
app.register_blueprint(dashboard) app.register_blueprint(dashboard)
app.register_blueprint(auth) app.register_blueprint(auth)
app.register_blueprint(tracker)
if __name__ == '__main__': if __name__ == '__main__':
print("✅ Grepolis Remote — DB initialised") print("✅ Grepolis Remote — DB initialised")

View File

@@ -232,6 +232,9 @@ function boot() {
scheduleNextFarm(true); // auto-farm timer-based (loot_option + 30120s) scheduleNextFarm(true); // auto-farm timer-based (loot_option + 30120s)
jitterLoop(autoBootcampLoop, 720000, 1320000); // bootcamp every 1222 min jitterLoop(autoBootcampLoop, 720000, 1320000); // bootcamp every 1222 min
jitterLoop(autoRuralTradeLoop, 1500000, 2700000); // rural trade every 2545 min jitterLoop(autoRuralTradeLoop, 1500000, 2700000); // rural trade every 2545 min
if (typeof window._grcInitTracker === 'function') {
window._grcInitTracker(); // live tracker event-driven
}
} }
if (document.readyState === 'complete') { if (document.readyState === 'complete') {

163
bot_modules/06_tracker.js Normal file
View 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;
})();

21
db.py
View File

@@ -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 # Migration: add new columns if upgrading an existing database
for _col in [ for _col in [
'ALTER TABLE town_state ADD COLUMN player_id TEXT', 'ALTER TABLE town_state ADD COLUMN player_id TEXT',

View File

@@ -76,6 +76,11 @@ def player_dashboard(player_id, world_id):
def player_farm(player_id, world_id): def player_farm(player_id, world_id):
return render_template('farm.html', player_id=player_id, world_id=world_id) return render_template('farm.html', player_id=player_id, world_id=world_id)
@dashboard.route('/player/<player_id>/<world_id>/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 # GET /dashboard/farm-settings — returns current farm config

214
routes/tracker.py Normal file
View File

@@ -0,0 +1,214 @@
"""
routes/tracker.py — Live Tracker Blueprint
Handles:
POST /api/<world_id>/movements (Tampermonkey pushes movement data)
GET /api/<world_id>/movements/<player_id> (Dashboard initial load)
GET /api/<world_id>/movements/<player_id>/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: "<world_id>:<player_id>" 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/<world_id>/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/<world_id>/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/<world_id>/movements/<player_id>
# Dashboard initial load — returns current snapshot from DB.
# ----------------------------------------------------------------
@tracker.route('/api/<world_id>/movements/<player_id>', 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/<world_id>/movements/<player_id>/stream
# SSE endpoint — keeps connection open, pushes updates to dashboard.
# Each connected dashboard tab gets its own Queue.
# ----------------------------------------------------------------
@tracker.route('/api/<world_id>/movements/<player_id>/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!)
}
)

View File

@@ -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::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); } .hub-card.farm:hover { border-color: #4acc64; box-shadow: 0 12px 40px rgba(74,200,100,0.15); }
/* Live Tracker — blue (coming soon, dimmed) */ /* Live Tracker — teal */
.hub-card.tracker { border-color: #1a2030; opacity: 0.65; cursor: not-allowed; } .hub-card.tracker { border-color: #1a3035; }
.hub-card.tracker:hover { transform: none; box-shadow: none; border-color: #1a2030; } .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 { .card-icon {
font-size: 2.8rem; font-size: 2.8rem;
@@ -166,12 +167,11 @@
<div class="card-desc">Αυτόματη συλλογή πόρων από χωριά. Ρυθμίσεις χρόνου λεηλασίας και έλεγχος με ένα κλικ.</div> <div class="card-desc">Αυτόματη συλλογή πόρων από χωριά. Ρυθμίσεις χρόνου λεηλασίας και έλεγχος με ένα κλικ.</div>
</a> </a>
<div class="hub-card tracker"> <a href="/player/{{ player_id }}/{{ world_id }}/tracker" class="hub-card tracker">
<span class="soon-badge">Σύντομα</span>
<span class="card-icon">🛡️</span> <span class="card-icon">🛡️</span>
<div class="card-title">Live Tracker</div> <div class="card-title">Live Tracker</div>
<div class="card-desc">Παρακολούθηση κινήσεων στρατού σε πραγματικό χρόνο. Εισερχόμενες επιθέσεις και ενισχύσεις.</div> <div class="card-desc">Παρακολούθηση κινήσεων στρατού σε πραγματικό χρόνο. Εισερχόμενες επιθέσεις και ενισχύσεις.</div>
</div> </a>
</div> </div>

455
templates/tracker.html Normal file
View File

@@ -0,0 +1,455 @@
<!DOCTYPE html>
<html lang="el">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Live Tracker — Grepolis Remote</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f0f1a;
--surface: #181824;
--border: #2a2a40;
--text: #e0e0e0;
--muted: #666;
--gold: #c8a44a;
--red: #e05555;
--green: #4acc64;
--blue: #6fcfcf;
--yellow: #f0c040;
--purple: #a07adf;
}
body {
min-height: 100vh;
background: var(--bg);
font-family: 'Inter', 'Segoe UI', sans-serif;
color: var(--text);
padding: 1.5rem 2rem;
}
/* ---- Header ---- */
.page-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.page-header h1 {
font-size: 1.7rem;
font-weight: 800;
background: linear-gradient(135deg, #c8a44a, #f0c96e, #c8a44a);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
flex: 1;
}
.back-link {
color: var(--muted);
text-decoration: none;
font-size: 0.85rem;
transition: color 0.2s;
}
.back-link:hover { color: var(--text); }
/* ---- Connection status pill ---- */
#conn-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
border: 1px solid;
transition: all 0.3s;
}
#conn-status.live { background: rgba(74,204,100,0.12); border-color: rgba(74,204,100,0.4); color: var(--green); }
#conn-status.wait { background: rgba(240,192,64,0.12); border-color: rgba(240,192,64,0.4); color: var(--yellow); }
#conn-status.error { background: rgba(224,85,85,0.12); border-color: rgba(224,85,85,0.4); color: var(--red); }
.status-dot {
width: 7px; height: 7px; border-radius: 50%;
background: currentColor;
animation: pulse 1.5s infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
/* ---- Summary badges ---- */
.summary-bar {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.summary-badge {
display: flex;
align-items: center;
gap: 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 8px 16px;
font-size: 0.85rem;
font-weight: 600;
min-width: 130px;
}
.summary-badge .badge-icon { font-size: 1.2rem; }
.summary-badge .badge-count { font-size: 1.5rem; font-weight: 800; margin-left: auto; }
.badge-incoming { border-color: rgba(224,85,85,0.4); }
.badge-incoming .badge-count { color: var(--red); }
.badge-outgoing { border-color: rgba(74,204,100,0.4); }
.badge-outgoing .badge-count { color: var(--green); }
.badge-support { border-color: rgba(111,207,207,0.4);}
.badge-support .badge-count { color: var(--blue); }
.badge-other { border-color: rgba(160,122,223,0.4);}
.badge-other .badge-count { color: var(--purple); }
/* ---- Section card ---- */
.section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
margin-bottom: 1.5rem;
overflow: hidden;
transition: border-color 0.3s;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
background: rgba(255,255,255,0.03);
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
font-weight: 700;
}
.section.incoming { border-color: rgba(224,85,85,0.35); }
.section.incoming .section-header { color: var(--red); }
.section.outgoing { border-color: rgba(74,204,100,0.35); }
.section.outgoing .section-header { color: var(--green); }
.section.support { border-color: rgba(111,207,207,0.35); }
.section.support .section-header { color: var(--blue); }
.section.other { border-color: rgba(160,122,223,0.35); }
.section.other .section-header { color: var(--purple); }
/* ---- Table ---- */
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
th {
text-align: left;
padding: 10px 20px;
color: var(--muted);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--border);
}
td {
padding: 11px 20px;
border-bottom: 1px solid rgba(255,255,255,0.04);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,0.025); }
.type-chip {
display: inline-block;
padding: 2px 9px;
border-radius: 8px;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.chip-attack_land { background: rgba(224,85,85,0.2); color: var(--red); border: 1px solid rgba(224,85,85,0.4); }
.chip-attack_sea { background: rgba(224,85,85,0.2); color: #f08080; border: 1px solid rgba(224,85,85,0.4); }
.chip-own_attack_land,
.chip-own_attack_sea { background: rgba(74,204,100,0.15); color: var(--green); border: 1px solid rgba(74,204,100,0.4); }
.chip-support { background: rgba(111,207,207,0.15);color: var(--blue); border: 1px solid rgba(111,207,207,0.4); }
.chip-own_support { background: rgba(111,207,207,0.15);color: #a0e8e8; border: 1px solid rgba(111,207,207,0.4); }
.chip-farming { background: rgba(160,122,223,0.15);color: var(--purple); border: 1px solid rgba(160,122,223,0.4); }
.chip-espionage { background: rgba(240,192,64,0.15); color: var(--yellow); border: 1px solid rgba(240,192,64,0.4); }
.chip-unknown { background: rgba(255,255,255,0.08);color: var(--muted); border: 1px solid var(--border); }
.countdown {
font-family: 'Courier New', monospace;
font-weight: 700;
font-size: 0.9rem;
}
.countdown.urgent { color: var(--red); animation: pulse 1s infinite; }
.countdown.soon { color: var(--yellow); }
.countdown.ok { color: var(--green); }
.empty-state {
text-align: center;
padding: 2rem;
color: var(--muted);
font-size: 0.9rem;
}
.empty-state span { font-size: 2rem; display: block; margin-bottom: 0.5rem; }
/* ---- Attack flash on new incoming ---- */
@keyframes flash-row {
0% { background: rgba(224,85,85,0.25); }
100% { background: transparent; }
}
.flash { animation: flash-row 1.5s ease-out; }
/* ---- Last updated ---- */
#last-updated {
text-align: right;
font-size: 0.75rem;
color: var(--muted);
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="page-header">
<a href="/player/{{ player_id }}/{{ world_id }}/hub" class="back-link">← Πίσω στο Hub</a>
<h1>🛡️ Live Tracker — {{ world_id }}</h1>
<div id="conn-status" class="wait">
<span class="status-dot"></span>
<span id="conn-label">Σύνδεση...</span>
</div>
</div>
<div class="summary-bar">
<div class="summary-badge badge-incoming">
<span class="badge-icon">⚔️</span> Εισερχόμενες
<span class="badge-count" id="count-incoming">0</span>
</div>
<div class="summary-badge badge-outgoing">
<span class="badge-icon">🏹</span> Δικές μου
<span class="badge-count" id="count-outgoing">0</span>
</div>
<div class="summary-badge badge-support">
<span class="badge-icon">🛡️</span> Ενισχύσεις
<span class="badge-count" id="count-support">0</span>
</div>
<div class="summary-badge badge-other">
<span class="badge-icon">🔮</span> Άλλο
<span class="badge-count" id="count-other">0</span>
</div>
</div>
<div id="last-updated"></div>
<!-- Incoming attacks -->
<div class="section incoming" id="sec-incoming">
<div class="section-header">⚔️ Εισερχόμενες Επιθέσεις</div>
<div id="tbl-incoming"><div class="empty-state"><span></span>Δεν υπάρχουν εισερχόμενες επιθέσεις</div></div>
</div>
<!-- Own attacks -->
<div class="section outgoing" id="sec-outgoing">
<div class="section-header">🏹 Δικές μου Επιθέσεις</div>
<div id="tbl-outgoing"><div class="empty-state"><span>💤</span>Δεν υπάρχουν ενεργές επιθέσεις</div></div>
</div>
<!-- Support -->
<div class="section support" id="sec-support">
<div class="section-header">🛡️ Ενισχύσεις</div>
<div id="tbl-support"><div class="empty-state"><span>💤</span>Δεν υπάρχουν ενισχύσεις</div></div>
</div>
<!-- Other (farming, espionage, colonization) -->
<div class="section other" id="sec-other">
<div class="section-header">🔮 Άλλες Κινήσεις</div>
<div id="tbl-other"><div class="empty-state"><span>💤</span>Δεν υπάρχουν άλλες κινήσεις</div></div>
</div>
<script>
(function() {
const PLAYER_ID = '{{ player_id }}';
const WORLD_ID = '{{ world_id }}';
const BASE = '';
// ---- Classify movement type into a display group ----
function classify(type) {
const t = (type || '').toLowerCase();
if (t === 'attack_land' || t === 'attack_sea') return 'incoming';
if (t === 'own_attack_land' || t === 'own_attack_sea') return 'outgoing';
if (t === 'support' || t === 'own_support') return 'support';
return 'other';
}
// ---- Format arrival_at (unix seconds) as HH:MM:SS countdown ----
function formatCountdown(arrivalAt) {
if (!arrivalAt) return { text: '', cls: 'ok' };
const secsLeft = Math.floor(arrivalAt - Date.now() / 1000);
if (secsLeft <= 0) return { text: 'Έφτασε', cls: 'ok' };
const h = Math.floor(secsLeft / 3600);
const m = Math.floor((secsLeft % 3600) / 60);
const s = secsLeft % 60;
const text = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
const cls = secsLeft < 120 ? 'urgent' : secsLeft < 600 ? 'soon' : 'ok';
return { text, cls };
}
// ---- Friendly label for type chip ----
function typeLabel(type) {
const map = {
'attack_land': '⚔️ Χερσαία',
'attack_sea': '⚓ Ναυτική',
'own_attack_land': '🏹 Επίθεση',
'own_attack_sea': '⛵ Ναυτ. Επίθ.',
'support': '🛡️ Ενίσχυση',
'own_support': '🛡️ Ενίσχυσα',
'farming': '🌾 Λεηλασία',
'espionage': '🔍 Κατασκοπεία',
'colonization': '🏛️ Αποίκιση',
'unknown': '❓ Άγνωστο',
};
return map[type] || type;
}
// ---- Build a table from a list of movements ----
function buildTable(movements) {
if (!movements.length) return null;
let html = `<table>
<thead><tr>
<th>Τύπος</th>
<th>Από</th>
<th>Πόλη Αφετηρίας</th>
<th>Προς</th>
<th>Πόλη Στόχου</th>
<th>Άφιξη</th>
<th>Αντίστροφη Μέτρηση</th>
</tr></thead><tbody>`;
for (const m of movements) {
const cd = formatCountdown(m.arrival_at);
const dt = m.arrival_at
? new Date(m.arrival_at * 1000).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
: '';
html += `<tr data-cmd="${m.command_id}">
<td><span class="type-chip chip-${m.cmd_type}">${typeLabel(m.cmd_type)}</span></td>
<td>${m.origin_player || ''}</td>
<td>${m.origin_town || ''}</td>
<td>${m.target_player || ''}</td>
<td>${m.target_town || ''}</td>
<td>${dt}</td>
<td><span class="countdown ${cd.cls}" data-arrival="${m.arrival_at || 0}">${cd.text}</span></td>
</tr>`;
}
html += '</tbody></table>';
return html;
}
// ---- Render all sections from the full movements array ----
let _prevIncomingIds = new Set();
function render(movements) {
const groups = { incoming: [], outgoing: [], support: [], other: [] };
for (const m of movements) {
const g = classify(m.cmd_type);
groups[g].push(m);
}
// Sort each group by arrival_at
for (const g of Object.values(groups)) {
g.sort((a, b) => (a.arrival_at || 0) - (b.arrival_at || 0));
}
// Update summary badges
document.getElementById('count-incoming').textContent = groups.incoming.length;
document.getElementById('count-outgoing').textContent = groups.outgoing.length;
document.getElementById('count-support').textContent = groups.support.length;
document.getElementById('count-other').textContent = groups.other.length;
// Render each table
const sections = ['incoming', 'outgoing', 'support', 'other'];
const emptyMsgs = {
incoming: { icon: '✅', msg: 'Δεν υπάρχουν εισερχόμενες επιθέσεις' },
outgoing: { icon: '💤', msg: 'Δεν υπάρχουν ενεργές επιθέσεις' },
support: { icon: '💤', msg: 'Δεν υπάρχουν ενισχύσεις' },
other: { icon: '💤', msg: 'Δεν υπάρχουν άλλες κινήσεις' },
};
for (const g of sections) {
const container = document.getElementById(`tbl-${g}`);
const tbl = buildTable(groups[g]);
container.innerHTML = tbl || `<div class="empty-state"><span>${emptyMsgs[g].icon}</span>${emptyMsgs[g].msg}</div>`;
}
// Flash rows that are new incoming attacks
const newIncomingIds = new Set(groups.incoming.map(m => m.command_id));
for (const id of newIncomingIds) {
if (!_prevIncomingIds.has(id)) {
const row = document.querySelector(`tr[data-cmd="${id}"]`);
if (row) { row.classList.remove('flash'); void row.offsetWidth; row.classList.add('flash'); }
}
}
_prevIncomingIds = newIncomingIds;
// Update last-updated timestamp
document.getElementById('last-updated').textContent =
`Τελευταία ενημέρωση: ${new Date().toLocaleTimeString('el-GR')}`;
}
// ---- Countdown ticker — updates every second client-side ----
function tickCountdowns() {
document.querySelectorAll('.countdown[data-arrival]').forEach(el => {
const arrival = parseInt(el.dataset.arrival, 10);
if (!arrival) return;
const cd = formatCountdown(arrival);
el.textContent = cd.text;
el.className = `countdown ${cd.cls}`;
});
}
setInterval(tickCountdowns, 1000);
// ---- Connection status helpers ----
function setStatus(state, label) {
const el = document.getElementById('conn-status');
el.className = `${state}`;
document.getElementById('conn-label').textContent = label;
// Re-add the inner dot
if (!el.querySelector('.status-dot')) {
el.insertAdjacentHTML('afterbegin', '<span class="status-dot"></span>');
}
}
// ---- 1. Initial load ----
fetch(`${BASE}/api/${WORLD_ID}/movements/${PLAYER_ID}`)
.then(r => r.json())
.then(d => { if (d.movements) render(d.movements); })
.catch(() => {});
// ---- 2. SSE stream for real-time updates ----
function connectSSE() {
setStatus('wait', 'Σύνδεση...');
const es = new EventSource(`${BASE}/api/${WORLD_ID}/movements/${PLAYER_ID}/stream`);
es.onopen = () => setStatus('live', 'Live ●');
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.movements) render(data.movements);
} catch(e) {}
};
es.onerror = () => {
setStatus('error', 'Αποσυνδέθηκε — επανασύνδεση...');
es.close();
// Auto-reconnect after 5 seconds
setTimeout(connectSSE, 5000);
};
}
connectSSE();
})();
</script>
</body>
</html>