MJ: attack coordinator update / various fixes . captcha and back button and jitterloop 2 secs
This commit is contained in:
@@ -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.
|
||||
|
||||
646
routes/attack_planner.py
Normal file
646
routes/attack_planner.py
Normal file
@@ -0,0 +1,646 @@
|
||||
"""
|
||||
routes/attack_planner.py — Coordinated Attack Planner Blueprint
|
||||
|
||||
Endpoints:
|
||||
GET /api/server_time Clock sync
|
||||
POST /api/<world>/attack_plans Create plan
|
||||
GET /api/<world>/attack_plans List plans
|
||||
GET /api/<world>/attack_plans/<plan_id> Get plan details
|
||||
POST /api/<world>/attack_plans/<plan_id>/arm Arm plan (lock & activate)
|
||||
POST /api/<world>/attack_plans/<plan_id>/cancel Cancel plan
|
||||
POST /api/<world>/attack_plans/<plan_id>/participants Add participant
|
||||
DELETE /api/<world>/attack_plans/<plan_id>/participants/<town_id> Remove participant
|
||||
POST /api/<world>/attack_plans/<plan_id>/participants/<town_id>/cancel Self-cancel
|
||||
POST /api/<world>/attack_plans/<plan_id>/participants/<town_id>/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/<world_id>/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/<world_id>/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/<world_id>/attack_plans
|
||||
# List all non-cancelled plans for this world.
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/<world_id>/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/<world_id>/attack_plans/<plan_id>
|
||||
# Full plan details including participants.
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/<world_id>/attack_plans/<int:plan_id>', 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/<world_id>/attack_plans/<plan_id>/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/<world_id>/attack_plans/<int:plan_id>/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/<world_id>/attack_plans/<plan_id>/participants/<town_id>
|
||||
# Remove a participant (coordinator/admin only, plan must be draft).
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route(
|
||||
'/api/<world_id>/attack_plans/<int:plan_id>/participants/<town_id>',
|
||||
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/<world_id>/attack_plans/<plan_id>/participants/<town_id>/cancel
|
||||
# Player self-cancels their own town's participation (if > 2min before send).
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route(
|
||||
'/api/<world_id>/attack_plans/<int:plan_id>/participants/<town_id>/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/<world_id>/attack_plans/<plan_id>/participants/<town_id>/status
|
||||
# Tampermonkey bot reports its status (armed, sent, missed).
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route(
|
||||
'/api/<world_id>/attack_plans/<int:plan_id>/participants/<town_id>/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/<world_id>/attack_plans/<plan_id>/arm
|
||||
# Lock the plan and activate it. Validates all participants feasible.
|
||||
# Requires attack_planner_admin.
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/<world_id>/attack_plans/<int:plan_id>/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/<world_id>/attack_plans/<plan_id>/cancel
|
||||
# Cancel the entire plan.
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/<world_id>/attack_plans/<int:plan_id>/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/<world_id>/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/<world_id>/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)
|
||||
@@ -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/<player_id>', 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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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/<player_id>/<world_id>/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,
|
||||
|
||||
Reference in New Issue
Block a user