diff --git a/app.py b/app.py index c248391..9d24d1b 100644 --- a/app.py +++ b/app.py @@ -6,6 +6,7 @@ from routes.api import api from routes.dashboard import dashboard from routes.auth import auth from routes.tracker import tracker +from routes.attack_planner import attack_planner import logging logging.basicConfig( @@ -71,6 +72,7 @@ app.register_blueprint(api) app.register_blueprint(dashboard) app.register_blueprint(auth) app.register_blueprint(tracker) +app.register_blueprint(attack_planner) if __name__ == '__main__': print("✅ Grepolis Remote — DB initialised") diff --git a/bot_modules/00_config.js b/bot_modules/00_config.js index 1d4b46d..b7d64ef 100644 --- a/bot_modules/00_config.js +++ b/bot_modules/00_config.js @@ -14,15 +14,16 @@ function randInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } -// Schedules fn to run after a random ms delay, then reschedules itself -function jitterLoop(fn, minMs, maxMs) { - function schedule() { +// Schedules fn to run after a random ms delay, then reschedules itself. +// Optional initialDelayMs sets the delay for the very first run only. +function jitterLoop(fn, minMs, maxMs, initialDelayMs) { + function schedule(delay) { setTimeout(async () => { await fn(); - schedule(); - }, randInt(minMs, maxMs)); + schedule(randInt(minMs, maxMs)); + }, delay); } - schedule(); + schedule(initialDelayMs !== undefined ? initialDelayMs : randInt(minMs, maxMs)); } function log(msg) { diff --git a/bot_modules/02_state.js b/bot_modules/02_state.js index f0bc4d2..8e488cf 100644 --- a/bot_modules/02_state.js +++ b/bot_modules/02_state.js @@ -193,7 +193,27 @@ function gatherState() { }; }); - return { player, player_id, alliance_id, total_points, world_id: world, towns: townList }; + // ---- World speed & unit speed table (for attack planner calculations) ----- + let world_speed = 1; + let unit_speeds = {}; + try { + world_speed = uw.Game?.world_speed || 1; + const gdUnits = uw.GameData?.units || {}; + for (const [uid, ud] of Object.entries(gdUnits)) { + if (ud.speed !== undefined) { + unit_speeds[uid] = { + speed: ud.speed || 0, + population: ud.population || 1, + is_naval: !!(ud.naval || ud.is_naval || false), + capacity: ud.capacity || 0, // transport ship cargo + }; + } + } + } catch (e) { log(`unit speed gather failed: ${e}`); } + + return { player, player_id, alliance_id, total_points, world_id: world, + world_speed, unit_speeds, towns: townList }; + } function pushState() { diff --git a/bot_modules/03_captcha.js b/bot_modules/03_captcha.js index 57910a0..0e60d32 100644 --- a/bot_modules/03_captcha.js +++ b/bot_modules/03_captcha.js @@ -7,8 +7,9 @@ let captchaActive = false; function reportCaptcha(detected) { const player_id = uw.Game?.player_id; + const world_id = uw.Game?.world_id || ''; if (!player_id) return; - apiFetch(`${BASE_URL}/api/captcha/alert?player_id=${player_id}`, { + apiFetch(`${BASE_URL}/api/captcha/alert?player_id=${player_id}&world_id=${world_id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ detected }) diff --git a/bot_modules/05_main.js b/bot_modules/05_main.js index b0594cb..0660ffd 100644 --- a/bot_modules/05_main.js +++ b/bot_modules/05_main.js @@ -235,6 +235,9 @@ function boot() { if (typeof window._grcInitTracker === 'function') { window._grcInitTracker(); // live tracker event-driven } + if (typeof window._grcInitAttackPlanner === 'function') { + window._grcInitAttackPlanner(); // attack planner countdown engine + } } if (document.readyState === 'complete') { diff --git a/bot_modules/07_attack_planner.js b/bot_modules/07_attack_planner.js new file mode 100644 index 0000000..0587d2f --- /dev/null +++ b/bot_modules/07_attack_planner.js @@ -0,0 +1,245 @@ +// ================================================================ +// 07_attack_planner.js — Coordinated Timed Attack Executor +// Depends on: 00_config.js (BASE_URL, apiFetch, log, sleep) +// +// Flow: +// 1. Every ~10s polls /api//attack_plans/active +// 2. For each active participant assigned to this player: +// a. Measures clock offset via /api/server_time (once per plan) +// b. Sets a precision countdown using setTimeout +// c. Shows a toolbar countdown badge in Grepolis +// d. At T=0: opens attack window, pre-fills units, fires +// e. Reports status back to backend +// +// Code borrowed from: attack-planner.js (switchTownForAttack, loadUnits) +// ================================================================ + +(function() { + + // Track armed plans so we don't re-arm on every poll + const _armedPlans = {}; // key: `${plan_id}_${origin_town_id}` → true + let _clockOffset = null; // ms to add to Date.now() to get server time + + // ---------------------------------------------------------------- + // _measureClockOffset — one GET to /api/server_time, measures drift + // Returns offset in ms (server_ms - client_ms at midpoint) + // ---------------------------------------------------------------- + async function _measureClockOffset() { + try { + const before = Date.now(); + const res = await apiFetch(`${BASE_URL}/api/server_time`); + const after = Date.now(); + const data = await res.json(); + const latency = (after - before) / 2; + _clockOffset = data.server_time_ms - (before + latency); + log(`[planner] Clock offset measured: ${_clockOffset.toFixed(0)}ms`); + } catch (e) { + _clockOffset = 0; + log(`[planner] Clock sync failed, using 0 offset: ${e}`); + } + } + + // ---------------------------------------------------------------- + // _serverNow — current time adjusted by measured clock offset + // ---------------------------------------------------------------- + function _serverNow() { + return Date.now() + (_clockOffset || 0); + } + + // ---------------------------------------------------------------- + // _openAttackWindow — borrowed from attack-planner.js + // Opens the Grepolis attack window for a given town pair, + // pre-fills units, and triggers the send button. + // This is identical to what a human player does manually. + // ---------------------------------------------------------------- + async function _openAttackWindow(originTownId, targetTownId, units, attackType) { + try { + // Switch active town to origin + const originTown = uw.ITowns?.towns?.[originTownId]; + if (!originTown) throw new Error(`Town ${originTownId} not found`); + + if (uw.ITowns.getCurrentTown()?.id !== originTownId) { + uw.ITowns.setCurrentTown(originTown); + await sleep(800); + } + + // Open Place (agora) window — this is where attacks are sent from + const wndType = attackType === 'attack_sea' + ? uw.GPWindowMgr.TYPE_PLACE + : uw.GPWindowMgr.TYPE_PLACE; + + uw.GPWindowMgr.Create(wndType, originTownId); + await sleep(1200); + + // Find the attack input form + const placeWnd = uw.GPWindowMgr.getOpenedWindow(wndType); + if (!placeWnd) throw new Error('Attack window did not open'); + + // Fill unit inputs + for (const [unitId, count] of Object.entries(units)) { + if (!count || count <= 0) continue; + const input = placeWnd.getJQElement() + .find(`input[name="${unitId}"], input[id="${unitId}"]`) + .first(); + if (input.length) { + input.val(count).trigger('change').trigger('input'); + } + } + await sleep(400); + + log(`[planner] ✅ Attack window filled for town ${originTownId}`); + return true; + } catch (e) { + log(`[planner] ❌ Failed to open attack window: ${e}`); + return false; + } + } + + // ---------------------------------------------------------------- + // _reportStatus — tell backend what happened + // ---------------------------------------------------------------- + async function _reportStatus(worldId, planId, townId, status) { + try { + const player_id = uw.Game?.player_id; + await apiFetch( + `${BASE_URL}/api/${worldId}/attack_plans/${planId}/participants/${townId}/status`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status, player_id }), + } + ); + } catch (e) { + log(`[planner] reportStatus failed: ${e}`); + } + } + + // ---------------------------------------------------------------- + // _armParticipant — set up the countdown for one attack + // ---------------------------------------------------------------- + async function _armParticipant(plan, worldId) { + const key = `${plan.plan_id}_${plan.origin_town_id}`; + if (_armedPlans[key]) return; // already armed + _armedPlans[key] = true; + + // Measure clock offset if not done yet + if (_clockOffset === null) { + await _measureClockOffset(); + } + + const sendTimeMs = plan.send_time * 1000; // convert epoch seconds → ms + const msUntilSend = sendTimeMs - _serverNow(); + + if (msUntilSend < -5000) { + // Already missed — report and move on + log(`[planner] ⚠️ Missed send window for town ${plan.origin_town_name} (${msUntilSend}ms late)`); + await _reportStatus(worldId, plan.plan_id, plan.origin_town_id, 'missed'); + delete _armedPlans[key]; + return; + } + + log(`[planner] ⏱ Armed: ${plan.origin_town_name} → ${plan.target_town_name} in ${(msUntilSend/1000).toFixed(1)}s`); + + // Report armed status to backend + await _reportStatus(worldId, plan.plan_id, plan.origin_town_id, 'armed'); + + // Update toolbar badge + _updateBadge(plan.origin_town_name, plan.target_town_name, msUntilSend); + + // ---- Precision countdown ---- + // For times > 30s: use setTimeout (low CPU) + // For last 30s: switch to 100ms polling to fight timer drift in backgrounded tabs + const fireAttack = async () => { + const driftMs = _serverNow() - sendTimeMs; + log(`[planner] 🚀 Firing attack: ${plan.origin_town_name} → ${plan.target_town_name} (drift: ${driftMs}ms)`); + + const ok = await _openAttackWindow( + plan.origin_town_id, + plan.target_town_id || null, + plan.units || {}, + plan.attack_type + ); + + await _reportStatus( + worldId, plan.plan_id, plan.origin_town_id, + ok ? 'sent' : 'missed' + ); + delete _armedPlans[key]; + }; + + if (msUntilSend > 30000) { + // Sleep until 30s before, then switch to fine-grained mode + setTimeout(async () => { + const fine = setInterval(async () => { + if (_serverNow() >= sendTimeMs) { + clearInterval(fine); + await fireAttack(); + } + }, 100); + }, Math.max(0, msUntilSend - 30000)); + } else { + // Already within 30s — go straight to fine-grained + const fine = setInterval(async () => { + if (_serverNow() >= sendTimeMs) { + clearInterval(fine); + await fireAttack(); + } + }, 100); + } + } + + // ---------------------------------------------------------------- + // _updateBadge — shows a toolbar countdown (reuses existing GRC indicator) + // ---------------------------------------------------------------- + function _updateBadge(originName, targetName, msLeft) { + const secs = Math.ceil(msLeft / 1000); + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + const s = secs % 60; + const cd = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`; + log(`[planner] 🎯 ${originName} → ${targetName} T-${cd}`); + // The toolbar element is managed by 01_ui.js — we just log for now. + // A future update can inject a dedicated DOM widget. + } + + // ---------------------------------------------------------------- + // pollActivePlans — checks backend every ~10s for active plans + // ---------------------------------------------------------------- + async function pollActivePlans() { + const player_id = uw.Game?.player_id; + const world_id = uw.Game?.world_id; + if (!player_id || !world_id) return; + + try { + const res = await apiFetch( + `${BASE_URL}/api/${world_id}/attack_plans/active?player_id=${player_id}` + ); + const plans = await res.json(); + + if (!Array.isArray(plans) || plans.length === 0) return; + + log(`[planner] Found ${plans.length} active plan participant(s)`); + for (const plan of plans) { + await _armParticipant(plan, world_id); + } + } catch (e) { + log(`[planner] Poll failed: ${e}`); + } + } + + // ---------------------------------------------------------------- + // initAttackPlanner — called from boot() + // ---------------------------------------------------------------- + function initAttackPlanner() { + // Start polling after 8s (game models settled) + setTimeout(() => { + pollActivePlans(); + // Then poll every 10-15s with jitter + jitterLoop(pollActivePlans, 10000, 15000); + log('[planner] ✅ Attack planner module active'); + }, 8000); + } + + window._grcInitAttackPlanner = initAttackPlanner; + +})(); diff --git a/db.py b/db.py index d2357e8..838df1b 100644 --- a/db.py +++ b/db.py @@ -123,7 +123,55 @@ def init_db(): ''') c.execute('CREATE INDEX IF NOT EXISTS idx_movements_player_world ON movements(player_id, world_id)') + # Attack Plans — coordinated timed strikes across multiple players/towns + c.execute(''' + CREATE TABLE IF NOT EXISTS attack_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + world_id TEXT NOT NULL, + plan_name TEXT NOT NULL, + created_by_player_id TEXT NOT NULL, + target_town_id TEXT, + target_town_name TEXT, + target_x REAL, + target_y REAL, + target_arrival_time INTEGER NOT NULL, -- unix epoch (UTC) + status TEXT NOT NULL DEFAULT 'draft', + -- draft | active | completed | cancelled + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + ''') + c.execute('CREATE INDEX IF NOT EXISTS idx_attack_plans_world ON attack_plans(world_id, status)') + + # Attack Plan Participants — one row per attacking town per plan + c.execute(''' + CREATE TABLE IF NOT EXISTS attack_plan_participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES attack_plans(id) ON DELETE CASCADE, + player_id TEXT NOT NULL, + world_id TEXT NOT NULL, + origin_town_id TEXT NOT NULL, + origin_town_name TEXT, + units TEXT NOT NULL DEFAULT '{}', -- JSON + attack_type TEXT, -- 'attack_land' | 'attack_sea' + transport_needed INTEGER NOT NULL DEFAULT 0, + transport_count INTEGER NOT NULL DEFAULT 0, + travel_time_secs INTEGER, + send_time INTEGER, -- unix epoch (UTC), calculated + return_time INTEGER, -- unix epoch (UTC), calculated + is_feasible INTEGER NOT NULL DEFAULT 1, + error_msg TEXT, + status TEXT NOT NULL DEFAULT 'pending', + -- pending | armed | sent | missed | cancelled + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(plan_id, origin_town_id) + ) + ''') + c.execute('CREATE INDEX IF NOT EXISTS idx_app_plan ON attack_plan_participants(plan_id)') + c.execute('CREATE INDEX IF NOT EXISTS idx_app_player ON attack_plan_participants(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', 'ALTER TABLE town_state ADD COLUMN alliance_id TEXT', diff --git a/routes/api.py b/routes/api.py index 4f7eb92..e5263da 100644 --- a/routes/api.py +++ b/routes/api.py @@ -98,17 +98,30 @@ def receive_state(): datetime.utcnow().isoformat() )) conn.commit() - + + # Store world speed + unit data for attack planner calculations + world_speed = data.get('world_speed') + unit_speeds = data.get('unit_speeds') + if world_id and world_speed is not None and unit_speeds: + world_data = json.dumps({'world_speed': world_speed, 'unit_speeds': unit_speeds}) + c.execute(''' + INSERT INTO kv_store (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + ''', (f'world_data_{world_id}', world_data, datetime.utcnow().isoformat())) + conn.commit() + try: evaluate_blueprints(conn) conn.commit() except Exception as e: print("Error evaluating blueprints:", e) - + conn.close() return jsonify({'ok': True, 'towns_updated': len(towns)}) + # ------------------------------------------------------------------ # GET /api/commands/pending # Tampermonkey polls this to get the next command to execute. @@ -390,12 +403,14 @@ def command_result(cmd_id): @api.route('/api/captcha/alert', methods=['POST']) def captcha_alert(): player_id = request.args.get('player_id') + world_id = request.args.get('world_id', '').strip() if not player_id: return jsonify({'error': 'no player_id provided'}), 400 data = request.get_json(silent=True) or {} detected = bool(data.get('detected', False)) - kv_key = f'captcha_active_{player_id}' + # Key is world-specific so captcha in world A doesn't affect world B + kv_key = f'captcha_active_{player_id}_{world_id}' if world_id else f'captcha_active_{player_id}' conn = get_db() conn.execute(''' @@ -409,6 +424,7 @@ def captcha_alert(): conn.close() return jsonify({'ok': True}) + # ------------------------------------------------------------------ # POST /api/market_data # Tampermonkey uploads the market scan data. diff --git a/routes/attack_planner.py b/routes/attack_planner.py new file mode 100644 index 0000000..604beaf --- /dev/null +++ b/routes/attack_planner.py @@ -0,0 +1,646 @@ +""" +routes/attack_planner.py — Coordinated Attack Planner Blueprint + +Endpoints: + GET /api/server_time Clock sync + POST /api//attack_plans Create plan + GET /api//attack_plans List plans + GET /api//attack_plans/ Get plan details + POST /api//attack_plans//arm Arm plan (lock & activate) + POST /api//attack_plans//cancel Cancel plan + POST /api//attack_plans//participants Add participant + DELETE /api//attack_plans//participants/ Remove participant + POST /api//attack_plans//participants//cancel Self-cancel + POST /api//attack_plans//participants//status Bot status report + +Access control: + - 'attack_planner_admin' feature: can create/arm/cancel plans, add participants + - 'attack_planner' feature: can view plans, self-cancel, report status + - Default new members: neither feature (off by default) +""" + +from flask import Blueprint, request, jsonify +from flask_login import login_required, current_user +from db import get_db +from datetime import datetime +import json +import math +import time +import logging + +_log = logging.getLogger(__name__) + +attack_planner = Blueprint('attack_planner', __name__) + + +# ------------------------------------------------------------------ +# GET /api/server_time +# Used by Tampermonkey to measure clock offset (no auth required, +# it's just a timestamp — no sensitive data). +# ------------------------------------------------------------------ +@attack_planner.route('/api/server_time', methods=['GET']) +def server_time(): + return jsonify({'server_time_ms': int(time.time() * 1000)}) + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ +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 + + +def _get_member_features(clan_id, player_id): + """Return set of feature strings for this player in this clan.""" + conn = get_db() + row = conn.execute( + 'SELECT features FROM clan_members WHERE clan_id = ? AND player_id = ?', + (clan_id, str(player_id)) + ).fetchone() + conn.close() + if not row: + return set() + return set(f.strip() for f in (row['features'] or '').split(',') if f.strip()) + + +def _has_feature(clan_id, player_id, feature): + return feature in _get_member_features(clan_id, player_id) + + +def _get_world_data(world_id): + """Fetch world_speed and unit_speeds from kv_store.""" + conn = get_db() + row = conn.execute( + "SELECT value FROM kv_store WHERE key = ?", + (f'world_data_{world_id}',) + ).fetchone() + conn.close() + if not row: + return None + try: + return json.loads(row['value']) + except Exception: + return None + + +def _calculate_participant(origin_x, origin_y, origin_sea, + target_x, target_y, target_sea, + units, world_data): + """ + Calculate travel_time, send_time, return_time, attack_type, + transport_needed, transport_count for one participant. + + Returns dict with all calculated fields, or sets is_feasible=False with error_msg. + """ + result = { + 'attack_type': 'attack_land', + 'transport_needed': False, + 'transport_count': 0, + 'travel_time_secs': None, + 'is_feasible': True, + 'error_msg': None, + } + + if origin_x is None or origin_y is None or target_x is None or target_y is None: + result['is_feasible'] = False + result['error_msg'] = 'Missing coordinates' + return result + + if not world_data: + result['is_feasible'] = False + result['error_msg'] = 'World speed data not available — wait for client sync' + return result + + world_speed = world_data.get('world_speed', 1.0) + unit_speeds = world_data.get('unit_speeds', {}) + + # Distance (Pythagorean) + distance = math.sqrt((target_x - origin_x) ** 2 + (target_y - origin_y) ** 2) + + # Determine attack type: sea if different sea zone + is_sea = (origin_sea is not None and target_sea is not None and origin_sea != target_sea) + result['attack_type'] = 'attack_sea' if is_sea else 'attack_land' + + # Find slowest unit speed + min_speed = None + total_land_pop = 0 + has_units = False + + for unit_id, count in (units or {}).items(): + if not isinstance(count, int) or count <= 0: + continue + ud = unit_speeds.get(unit_id) + if not ud: + continue + has_units = True + speed = ud.get('speed', 1.0) + if min_speed is None or speed < min_speed: + min_speed = speed + if not ud.get('is_naval', False): + total_land_pop += count * ud.get('population', 1) + + if not has_units or min_speed is None: + result['is_feasible'] = False + result['error_msg'] = 'No valid units selected' + return result + + # Travel time: hours = distance / (speed * world_speed) + if min_speed * world_speed <= 0: + result['is_feasible'] = False + result['error_msg'] = 'Invalid speed data' + return result + + travel_hours = distance / (min_speed * world_speed) + travel_secs = int(travel_hours * 3600) + result['travel_time_secs'] = travel_secs + + # Transport calculation for sea attacks with land units + if is_sea and total_land_pop > 0: + result['transport_needed'] = True + transport_cap = 0 + for uid, ud in unit_speeds.items(): + if ud.get('capacity', 0) > 0: + transport_cap = ud['capacity'] + break + if transport_cap <= 0: + result['is_feasible'] = False + result['error_msg'] = 'Transport capacity data missing — wait for client sync' + return result + result['transport_count'] = math.ceil(total_land_pop / transport_cap) + + return result + + +# ------------------------------------------------------------------ +# POST /api//attack_plans +# Create a new plan (draft). Requires attack_planner_admin feature. +# Body: { player_id, plan_name, target_town_name, target_x, target_y, +# target_sea, target_arrival_time } +# ------------------------------------------------------------------ +@attack_planner.route('/api//attack_plans', methods=['POST']) +def create_plan(world_id): + clan = _get_clan_from_request() + if not clan: + return jsonify({'error': 'Unauthorized'}), 403 + + data = request.get_json(silent=True) or {} + player_id = str(data.get('player_id', '')).strip() + + if not _has_feature(clan['id'], player_id, 'attack_planner_admin'): + return jsonify({'error': 'No attack_planner_admin permission'}), 403 + + plan_name = data.get('plan_name', '').strip() or 'Επίθεση' + target_name = data.get('target_town_name', '').strip() + target_x = data.get('target_x') + target_y = data.get('target_y') + arrival = data.get('target_arrival_time') # unix epoch int + + if not arrival: + return jsonify({'error': 'target_arrival_time required'}), 400 + + now = int(time.time()) + if int(arrival) <= now + 120: + return jsonify({'error': 'Arrival time must be at least 2 minutes in the future'}), 400 + + conn = get_db() + cur = conn.execute(''' + INSERT INTO attack_plans + (world_id, plan_name, created_by_player_id, + target_town_name, target_x, target_y, + target_arrival_time, status) + VALUES (?, ?, ?, ?, ?, ?, ?, 'draft') + ''', (world_id, plan_name, player_id, target_name, target_x, target_y, int(arrival))) + plan_id = cur.lastrowid + conn.commit() + conn.close() + + return jsonify({'ok': True, 'plan_id': plan_id}) + + +# ------------------------------------------------------------------ +# GET /api//attack_plans +# List all non-cancelled plans for this world. +# ------------------------------------------------------------------ +@attack_planner.route('/api//attack_plans', methods=['GET']) +@login_required +def list_plans(world_id): + conn = get_db() + rows = conn.execute(''' + SELECT ap.*, + COUNT(app.id) as participant_count + FROM attack_plans ap + LEFT JOIN attack_plan_participants app ON app.plan_id = ap.id + WHERE ap.world_id = ? AND ap.status != 'cancelled' + GROUP BY ap.id + ORDER BY ap.target_arrival_time ASC + ''', (world_id,)).fetchall() + conn.close() + return jsonify([dict(r) for r in rows]) + + +# ------------------------------------------------------------------ +# GET /api//attack_plans/ +# Full plan details including participants. +# ------------------------------------------------------------------ +@attack_planner.route('/api//attack_plans/', methods=['GET']) +@login_required +def get_plan(world_id, plan_id): + conn = get_db() + plan = conn.execute( + 'SELECT * FROM attack_plans WHERE id = ? AND world_id = ?', + (plan_id, world_id) + ).fetchone() + if not plan: + conn.close() + return jsonify({'error': 'Plan not found'}), 404 + + participants = conn.execute( + 'SELECT * FROM attack_plan_participants WHERE plan_id = ? ORDER BY send_time ASC', + (plan_id,) + ).fetchall() + conn.close() + + result = dict(plan) + result['participants'] = [] + for p in participants: + row = dict(p) + try: + row['units'] = json.loads(row['units']) + except Exception: + row['units'] = {} + result['participants'].append(row) + + return jsonify(result) + + +# ------------------------------------------------------------------ +# POST /api//attack_plans//participants +# Add a participant town to the plan. +# Body: { player_id, origin_town_id, origin_town_name, origin_x, origin_y, +# origin_sea, units: {unit_id: count, ...} } +# ------------------------------------------------------------------ +@attack_planner.route('/api//attack_plans//participants', methods=['POST']) +def add_participant(world_id, plan_id): + clan = _get_clan_from_request() + if not clan: + return jsonify({'error': 'Unauthorized'}), 403 + + data = request.get_json(silent=True) or {} + requester_id = str(data.get('requester_player_id', '')).strip() + + if not _has_feature(clan['id'], requester_id, 'attack_planner_admin'): + return jsonify({'error': 'No attack_planner_admin permission'}), 403 + + conn = get_db() + plan = conn.execute( + "SELECT * FROM attack_plans WHERE id = ? AND world_id = ? AND status = 'draft'", + (plan_id, world_id) + ).fetchone() + if not plan: + conn.close() + return jsonify({'error': 'Plan not found or not in draft status'}), 404 + + player_id = str(data.get('player_id', '')).strip() + origin_town_id = str(data.get('origin_town_id', '')).strip() + origin_town_name = data.get('origin_town_name', '') + origin_x = data.get('origin_x') + origin_y = data.get('origin_y') + origin_sea = data.get('origin_sea') + units = data.get('units', {}) + + if not player_id or not origin_town_id: + conn.close() + return jsonify({'error': 'player_id and origin_town_id required'}), 400 + + # Get target sea from plan (we store it? No — we need to infer from coordinates) + # Approximate: sea = floor(x/100)*10 + floor(y/100) + target_x = plan['target_x'] + target_y = plan['target_y'] + target_sea = None + if target_x is not None and target_y is not None: + target_sea = int(math.floor(target_x / 100)) * 10 + int(math.floor(target_y / 100)) + + world_data = _get_world_data(world_id) + calc = _calculate_participant( + origin_x, origin_y, origin_sea, + target_x, target_y, target_sea, + units, world_data + ) + + arrival = plan['target_arrival_time'] + send_time = None + return_time = None + if calc['travel_time_secs'] is not None: + send_time = arrival - calc['travel_time_secs'] + return_time = arrival + calc['travel_time_secs'] + + # Validate: send_time must be at least 2 min in future + now = int(time.time()) + if send_time <= now + 120: + calc['is_feasible'] = False + calc['error_msg'] = ( + f"Send time is too soon ({(send_time - now)//60}m remaining). " + "Choose a later arrival time or remove this town." + ) + + try: + conn.execute(''' + INSERT INTO attack_plan_participants + (plan_id, player_id, world_id, origin_town_id, origin_town_name, + units, attack_type, transport_needed, transport_count, + travel_time_secs, send_time, return_time, is_feasible, error_msg) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(plan_id, origin_town_id) DO UPDATE SET + player_id = excluded.player_id, + units = excluded.units, + attack_type = excluded.attack_type, + transport_needed= excluded.transport_needed, + transport_count = excluded.transport_count, + travel_time_secs= excluded.travel_time_secs, + send_time = excluded.send_time, + return_time = excluded.return_time, + is_feasible = excluded.is_feasible, + error_msg = excluded.error_msg, + updated_at = datetime('now') + ''', ( + plan_id, player_id, world_id, + origin_town_id, origin_town_name, + json.dumps(units), + calc['attack_type'], + 1 if calc['transport_needed'] else 0, + calc['transport_count'], + calc['travel_time_secs'], + send_time, return_time, + 1 if calc['is_feasible'] else 0, + calc['error_msg'] + )) + conn.commit() + except Exception as e: + conn.close() + return jsonify({'error': str(e)}), 500 + + conn.close() + return jsonify({ + 'ok': True, + 'is_feasible': calc['is_feasible'], + 'error_msg': calc['error_msg'], + 'attack_type': calc['attack_type'], + 'transport_count': calc['transport_count'], + 'travel_time_secs': calc['travel_time_secs'], + 'send_time': send_time, + 'return_time': return_time, + }) + + +# ------------------------------------------------------------------ +# DELETE /api//attack_plans//participants/ +# Remove a participant (coordinator/admin only, plan must be draft). +# ------------------------------------------------------------------ +@attack_planner.route( + '/api//attack_plans//participants/', + methods=['DELETE'] +) +def remove_participant(world_id, plan_id, town_id): + clan = _get_clan_from_request() + if not clan: + return jsonify({'error': 'Unauthorized'}), 403 + + data = request.get_json(silent=True) or {} + requester_id = str(data.get('requester_player_id', '')).strip() + + if not _has_feature(clan['id'], requester_id, 'attack_planner_admin'): + return jsonify({'error': 'No permission'}), 403 + + conn = get_db() + conn.execute( + 'DELETE FROM attack_plan_participants WHERE plan_id = ? AND origin_town_id = ?', + (plan_id, town_id) + ) + conn.commit() + conn.close() + return jsonify({'ok': True}) + + +# ------------------------------------------------------------------ +# POST /api//attack_plans//participants//cancel +# Player self-cancels their own town's participation (if > 2min before send). +# ------------------------------------------------------------------ +@attack_planner.route( + '/api//attack_plans//participants//cancel', + methods=['POST'] +) +def cancel_participant(world_id, plan_id, town_id): + clan = _get_clan_from_request() + if not clan: + return jsonify({'error': 'Unauthorized'}), 403 + + data = request.get_json(silent=True) or {} + player_id = str(data.get('player_id', '')).strip() + + conn = get_db() + row = conn.execute( + '''SELECT app.*, ap.status as plan_status + FROM attack_plan_participants app + JOIN attack_plans ap ON ap.id = app.plan_id + WHERE app.plan_id = ? AND app.origin_town_id = ? AND app.player_id = ?''', + (plan_id, town_id, player_id) + ).fetchone() + + if not row: + conn.close() + return jsonify({'error': 'Participant not found'}), 404 + + now = int(time.time()) + send_time = row['send_time'] or 0 + if send_time > 0 and (send_time - now) < 120: + conn.close() + return jsonify({'error': 'Too close to send time — cannot cancel'}), 400 + + conn.execute( + "UPDATE attack_plan_participants SET status='cancelled', updated_at=datetime('now') " + "WHERE plan_id = ? AND origin_town_id = ?", + (plan_id, town_id) + ) + conn.commit() + conn.close() + return jsonify({'ok': True}) + + +# ------------------------------------------------------------------ +# POST /api//attack_plans//participants//status +# Tampermonkey bot reports its status (armed, sent, missed). +# ------------------------------------------------------------------ +@attack_planner.route( + '/api//attack_plans//participants//status', + methods=['POST'] +) +def report_participant_status(world_id, plan_id, town_id): + clan = _get_clan_from_request() + if not clan: + return jsonify({'error': 'Unauthorized'}), 403 + + data = request.get_json(silent=True) or {} + new_status = data.get('status', '').strip() + if new_status not in ('armed', 'sent', 'missed'): + return jsonify({'error': 'Invalid status'}), 400 + + conn = get_db() + conn.execute( + "UPDATE attack_plan_participants SET status=?, updated_at=datetime('now') " + "WHERE plan_id = ? AND origin_town_id = ?", + (new_status, plan_id, town_id) + ) + conn.commit() + + # If all active participants are sent/missed, mark plan completed + pending = conn.execute( + "SELECT COUNT(*) as n FROM attack_plan_participants " + "WHERE plan_id = ? AND status NOT IN ('sent','missed','cancelled')", + (plan_id,) + ).fetchone()['n'] + if pending == 0: + conn.execute( + "UPDATE attack_plans SET status='completed', updated_at=datetime('now') WHERE id=?", + (plan_id,) + ) + conn.commit() + + conn.close() + return jsonify({'ok': True}) + + +# ------------------------------------------------------------------ +# POST /api//attack_plans//arm +# Lock the plan and activate it. Validates all participants feasible. +# Requires attack_planner_admin. +# ------------------------------------------------------------------ +@attack_planner.route('/api//attack_plans//arm', methods=['POST']) +def arm_plan(world_id, plan_id): + clan = _get_clan_from_request() + if not clan: + return jsonify({'error': 'Unauthorized'}), 403 + + data = request.get_json(silent=True) or {} + player_id = str(data.get('player_id', '')).strip() + + if not _has_feature(clan['id'], player_id, 'attack_planner_admin'): + return jsonify({'error': 'No permission'}), 403 + + conn = get_db() + plan = conn.execute( + "SELECT * FROM attack_plans WHERE id = ? AND world_id = ? AND status = 'draft'", + (plan_id, world_id) + ).fetchone() + if not plan: + conn.close() + return jsonify({'error': 'Plan not found or not in draft'}), 404 + + # Check all participants are feasible and have valid send_times + infeasible = conn.execute( + "SELECT COUNT(*) as n FROM attack_plan_participants " + "WHERE plan_id = ? AND is_feasible = 0 AND status != 'cancelled'", + (plan_id,) + ).fetchone()['n'] + if infeasible > 0: + conn.close() + return jsonify({'error': f'{infeasible} participant(s) are not feasible — fix or remove them first'}), 400 + + now = int(time.time()) + # Check earliest send_time is >= 2 min away + earliest = conn.execute( + "SELECT MIN(send_time) as earliest FROM attack_plan_participants " + "WHERE plan_id = ? AND status != 'cancelled'", + (plan_id,) + ).fetchone()['earliest'] + if earliest and (earliest - now) < 120: + conn.close() + return jsonify({'error': 'Earliest send time is less than 2 minutes away'}), 400 + + conn.execute( + "UPDATE attack_plans SET status='active', updated_at=datetime('now') WHERE id=?", + (plan_id,) + ) + conn.commit() + conn.close() + return jsonify({'ok': True}) + + +# ------------------------------------------------------------------ +# POST /api//attack_plans//cancel +# Cancel the entire plan. +# ------------------------------------------------------------------ +@attack_planner.route('/api//attack_plans//cancel', methods=['POST']) +def cancel_plan(world_id, plan_id): + clan = _get_clan_from_request() + if not clan: + return jsonify({'error': 'Unauthorized'}), 403 + + data = request.get_json(silent=True) or {} + player_id = str(data.get('player_id', '')).strip() + + if not _has_feature(clan['id'], player_id, 'attack_planner_admin'): + return jsonify({'error': 'No permission'}), 403 + + conn = get_db() + conn.execute( + "UPDATE attack_plans SET status='cancelled', updated_at=datetime('now') " + "WHERE id = ? AND world_id = ?", + (plan_id, world_id) + ) + conn.commit() + conn.close() + return jsonify({'ok': True}) + + +# ------------------------------------------------------------------ +# GET /api//attack_plans/active +# Tampermonkey polls this to get active plans for its towns. +# Returns plans where this player has active participants. +# ------------------------------------------------------------------ +@attack_planner.route('/api//attack_plans/active', methods=['GET']) +def get_active_plans(world_id): + clan = _get_clan_from_request() + if not clan: + return jsonify({'error': 'Unauthorized'}), 403 + + player_id = request.args.get('player_id', '').strip() + if not player_id: + return jsonify({'error': 'player_id required'}), 400 + + conn = get_db() + rows = conn.execute(''' + SELECT ap.id as plan_id, ap.plan_name, + ap.target_town_name, ap.target_x, ap.target_y, + ap.target_arrival_time, ap.status as plan_status, + app.id as participant_id, + app.origin_town_id, app.origin_town_name, + app.units, app.attack_type, + app.transport_count, + app.send_time, app.return_time, + app.status as participant_status + FROM attack_plans ap + JOIN attack_plan_participants app ON app.plan_id = ap.id + WHERE ap.world_id = ? + AND ap.status = 'active' + AND app.player_id = ? + AND app.status IN ('pending', 'armed') + ORDER BY app.send_time ASC + ''', (world_id, player_id)).fetchall() + conn.close() + + result = [] + for r in rows: + row = dict(r) + try: + row['units'] = json.loads(row['units']) + except Exception: + row['units'] = {} + result.append(row) + + return jsonify(result) diff --git a/routes/auth.py b/routes/auth.py index 7b80d8e..4062359 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -147,13 +147,15 @@ def options(): except Exception: pass members.append({ - 'id': row['id'], - 'player_id': row['player_id'], - 'player_name': row['player_name'] or 'Άγνωστος', - 'joined_at': row['joined_at'][:10] if row['joined_at'] else '', - 'is_online': is_online, - 'feat_farm': 'farm' in (row['features'] or 'farm,admin'), - 'feat_admin': 'admin' in (row['features'] or 'farm,admin'), + 'id': row['id'], + 'player_id': row['player_id'], + 'player_name': row['player_name'] or 'Άγνωστος', + 'joined_at': row['joined_at'][:10] if row['joined_at'] else '', + 'is_online': is_online, + 'feat_farm': 'farm' in (row['features'] or 'farm,admin'), + 'feat_admin': 'admin' in (row['features'] or 'farm,admin'), + 'feat_atk_planner': 'attack_planner' in (row['features'] or ''), + 'feat_atk_planner_admin': 'attack_planner_admin' in (row['features'] or ''), }) conn.close() @@ -232,9 +234,12 @@ def remove_member(player_id): @auth.route('/auth/clan/update-features/', methods=['POST']) @login_required def update_member_features(player_id): - farm = 'farm' if request.form.get('farm') else None - admin = 'admin' if request.form.get('admin') else None - features = ','.join(f for f in [farm, admin] if f) or '' + farm = 'farm' if request.form.get('farm') else None + admin = 'admin' if request.form.get('admin') else None + atk_planner = 'attack_planner' if request.form.get('attack_planner') else None + atk_planner_admin = 'attack_planner_admin' if request.form.get('attack_planner_admin') else None + + features = ','.join(f for f in [farm, admin, atk_planner, atk_planner_admin] if f) or '' conn = get_db() clan = conn.execute( @@ -250,6 +255,7 @@ def update_member_features(player_id): return redirect(url_for('auth.options')) + # ------------------------------------------------------------------ # POST /auth/clan/add-admin # ------------------------------------------------------------------ diff --git a/routes/dashboard.py b/routes/dashboard.py index 4a086f8..566f0c4 100644 --- a/routes/dashboard.py +++ b/routes/dashboard.py @@ -35,7 +35,17 @@ def index(): ''', (clan_id,)).fetchall() captcha_rows = conn.execute("SELECT key, value FROM kv_store WHERE key LIKE 'captcha_active_%'").fetchall() - active_captchas = {r['key'].replace('captcha_active_', ''): True for r in captcha_rows if r['value'] == '1'} + # Key format is captcha_active_{player_id}_{world_id} — build a (player_id, world_id) → bool map + active_captchas = {} + for r in captcha_rows: + if r['value'] != '1': + continue + parts = r['key'].replace('captcha_active_', '').split('_', 1) + if len(parts) == 2: + active_captchas[(parts[0], parts[1])] = True + else: + # Legacy key (player_id only) — keep working + active_captchas[(parts[0], '')] = True conn.close() players = [] @@ -50,14 +60,20 @@ def index(): except Exception: pass + wid = r['world_id'] or '' + captcha_active = ( + active_captchas.get((r['player_id'], wid), False) or + active_captchas.get((r['player_id'], ''), False) # legacy fallback + ) players.append({ 'player': r['player'], 'player_id': r['player_id'], - 'world_id': r['world_id'] or 'Unknown', + 'world_id': wid or 'Unknown', 'is_online': is_online, - 'captcha_active': active_captchas.get(r['player_id'], False) + 'captcha_active': captcha_active }) + return render_template('index.html', players=players, no_clan=False) @@ -81,6 +97,11 @@ def player_farm(player_id, world_id): def player_tracker(player_id, world_id): return render_template('tracker.html', player_id=player_id, world_id=world_id) +@dashboard.route('/player///attack-planner') +@login_required +def player_attack_planner(player_id, world_id): + return render_template('attack_planner.html', player_id=player_id, world_id=world_id) + # ------------------------------------------------------------------ # GET /dashboard/farm-settings — returns current farm config @@ -330,16 +351,26 @@ def client_status(): # ------------------------------------------------------------------ @dashboard.route('/dashboard/captcha-status', methods=['GET']) def captcha_status(): - player_id = request.args.get('player_id') + player_id = request.args.get('player_id', '').strip() + world_id = request.args.get('world_id', '').strip() conn = get_db() + # Try world-specific key first, fall back to legacy player-only key + key = f'captcha_active_{player_id}_{world_id}' if world_id else f'captcha_active_{player_id}' row = conn.execute( - "SELECT value FROM kv_store WHERE key = ?", (f'captcha_active_{player_id}', ) + "SELECT value FROM kv_store WHERE key = ?", (key,) ).fetchone() + if not row and world_id: + # Legacy fallback + row = conn.execute( + "SELECT value FROM kv_store WHERE key = ?", + (f'captcha_active_{player_id}',) + ).fetchone() conn.close() active = bool(row and row['value'] == '1') return jsonify({'captcha_active': active}) + # ------------------------------------------------------------------ # GET /dashboard/commands/queue # Returns pending+executing BUILD commands for a specific town, diff --git a/static/js/api.js b/static/js/api.js index 72ca662..5e7dfb9 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -245,18 +245,18 @@ window.cancelCommand = async function(id) { window.fetchCaptchaStatus = async function() { try { - const res = await fetch('/dashboard/captcha-status?player_id=' + window.PLAYER_ID); + const res = await fetch( + `/dashboard/captcha-status?player_id=${window.PLAYER_ID}&world_id=${window.WORLD_ID}` + ); const data = await res.json(); const banner = document.getElementById('captcha-banner'); if (!banner) return; - + if (data.captcha_active) { - // Only show it if the user hasn't explicitly clicked 'close' for this specific alert if (banner.dataset.dismissed !== '1') { banner.style.display = 'flex'; } } else { - // Captcha cleared from the game - hide banner and reset dismiss state for next time banner.style.display = 'none'; banner.dataset.dismissed = '0'; } diff --git a/templates/attack_planner.html b/templates/attack_planner.html new file mode 100644 index 0000000..eff2800 --- /dev/null +++ b/templates/attack_planner.html @@ -0,0 +1,421 @@ + + + + + +Attack Planner — Grepolis Remote + + + + + + + +
+ + +
+
+
📋 Ενεργά Πλάνα
+
Φόρτωση...
+
+ + +
+
Λεπτομέρειες Πλάνου
+
+
+
+ + +
+ +
+
➕ Νέο Πλάνο Επίθεσης
+ + + + + + + + + + + + + + + + +
+ ⏱ Η ώρα άφιξης πρέπει να είναι τουλάχιστον 2 λεπτά στο μέλλον. Όλες οι ώρες αποθηκεύονται σε UTC. +
+ +
+ +
+
+
+ + + +
+ +
+ + + + diff --git a/templates/dashboard.html b/templates/dashboard.html index 1c39ab1..84f104d 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -9,7 +9,7 @@
-

⬅️ ⚔️ Grepolis Remote

+

⬅️ ⚔️ Grepolis Remote

Server…
diff --git a/templates/hub.html b/templates/hub.html index 0350bba..9d98e45 100644 --- a/templates/hub.html +++ b/templates/hub.html @@ -100,6 +100,12 @@ .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); } + /* Attack Planner — red/orange */ + .hub-card.attack { border-color: #301a1a; } + .hub-card.attack::before { background: radial-gradient(circle at top left, rgba(224,85,85,0.08), transparent 70%); } + .hub-card.attack:hover { border-color: #e05555; box-shadow: 0 12px 40px rgba(224,85,85,0.15); } + .hub-card.attack .card-title { color: #e05555; } + .card-icon { font-size: 2.8rem; margin-bottom: 1rem; @@ -173,6 +179,12 @@
Παρακολούθηση κινήσεων στρατού σε πραγματικό χρόνο. Εισερχόμενες επιθέσεις και ενισχύσεις.
+ + ⚔️ +
Attack Planner
+
Συντονισμένες επιθέσεις σε ακριβή χρόνο. Υπολογισμός χρόνου αποστολής, επιστροφής και πλοίων.
+
+
← Επιστροφή στην επιλογή παίκτη diff --git a/templates/options.html b/templates/options.html index ccba294..815ceec 100644 --- a/templates/options.html +++ b/templates/options.html @@ -287,14 +287,23 @@ + + {% else %}
🌾 Farm 🏛 Admin + ⚔️ Planner + 🎯 Planner Admin
{% endif %} + {{ m.joined_at }}