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.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")

View File

@@ -232,6 +232,9 @@ function boot() {
scheduleNextFarm(true); // auto-farm timer-based (loot_option + 30120s)
jitterLoop(autoBootcampLoop, 720000, 1320000); // bootcamp every 1222 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') {

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
for _col in [
'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):
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

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: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 @@
<div class="card-desc">Αυτόματη συλλογή πόρων από χωριά. Ρυθμίσεις χρόνου λεηλασίας και έλεγχος με ένα κλικ.</div>
</a>
<div class="hub-card tracker">
<span class="soon-badge">Σύντομα</span>
<a href="/player/{{ player_id }}/{{ world_id }}/tracker" class="hub-card tracker">
<span class="card-icon">🛡️</span>
<div class="card-title">Live Tracker</div>
<div class="card-desc">Παρακολούθηση κινήσεων στρατού σε πραγματικό χρόνο. Εισερχόμενες επιθέσεις και ενισχύσεις.</div>
</div>
</a>
</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>