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