from flask import Blueprint, request, jsonify from db import get_db import json from datetime import datetime, timedelta import os from flask import make_response from blueprint_engine import evaluate_blueprints api = Blueprint('api', __name__) # ------------------------------------------------------------------ # Helper — look up clan by the X-Clan-Key header. # Returns the clan row dict, or None if key is missing / invalid. # ------------------------------------------------------------------ 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 # ------------------------------------------------------------------ # Helper — auto-register a player_id under a clan on first push. # ------------------------------------------------------------------ def _auto_register_member(clan_id, player_id, player_name): conn = get_db() conn.execute(''' INSERT OR IGNORE INTO clan_members (clan_id, player_id, player_name) VALUES (?, ?, ?) ''', (clan_id, str(player_id), player_name or '')) # Update name in case it changed conn.execute(''' UPDATE clan_members SET player_name = ? WHERE clan_id = ? AND player_id = ? ''', (player_name or '', clan_id, str(player_id))) conn.commit() conn.close() # ------------------------------------------------------------------ # POST /api/state # Tampermonkey pushes a full town snapshot every poll cycle. # ------------------------------------------------------------------ @api.route('/api/state', methods=['POST']) def receive_state(): data = request.get_json(silent=True) if not data: return jsonify({'error': 'no data'}), 400 towns = data.get('towns', []) player = data.get('player', '') player_id = data.get('player_id', '') alliance_id = str(data.get('alliance_id', '') or '') world_id = data.get('world_id', '') # Auto-register this player to the clan that matches the key (if any) clan = _get_clan_from_request() if clan: _auto_register_member(clan['id'], player_id, player) conn = get_db() c = conn.cursor() for town in towns: x = town.get('x') y = town.get('y') sea = town.get('sea') c.execute(''' INSERT INTO town_state (town_id, town_name, player, player_id, alliance_id, world_id, x, y, sea, data, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(town_id) DO UPDATE SET town_name = excluded.town_name, player = excluded.player, player_id = excluded.player_id, alliance_id = excluded.alliance_id, world_id = excluded.world_id, x = excluded.x, y = excluded.y, sea = excluded.sea, data = excluded.data, updated_at = excluded.updated_at ''', ( str(town['town_id']), town.get('town_name', ''), player, player_id, alliance_id, world_id, x, y, sea, json.dumps(town), 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. # Returns one 'build' AND one 'recruit' command independently, # so both queues are served in parallel without blocking each other. # ------------------------------------------------------------------ def _fetch_pending_of_type(c, cmd_type, player_id): """Fetch a single oldest pending command of a given type (recruit, market, etc.).""" row = c.execute(''' SELECT * FROM commands WHERE status = 'pending' AND type = ? AND player_id = ? ORDER BY updated_at ASC, id ASC LIMIT 1 ''', (cmd_type, player_id)).fetchone() if not row: return None c.execute(''' UPDATE commands SET status = 'executing', updated_at = ? WHERE id = ? ''', (datetime.utcnow().isoformat(), row['id'])) return { 'id': row['id'], 'town_id': row['town_id'], 'type': row['type'], 'payload': json.loads(row['payload']) } def _fetch_pending_builds_all_towns(c, player_id): """ Fetch ONE pending 'build' command per distinct town_id. This allows all towns to build in parallel within a single poll cycle. Within each town the oldest-updated command is picked first, so requeued commands (updated_at = now) naturally sort behind fresh ones. Towns that already have a command in 'executing' state are skipped — this prevents a second build from being dispatched before the first one has reported its result (which was causing commands to pile up in EXECUTING). """ # Towns that currently have a build already in-flight — don't touch those. executing_rows = c.execute(''' SELECT DISTINCT town_id FROM commands WHERE status = 'executing' AND type = 'build' AND player_id = ? ''', (player_id,)).fetchall() busy_towns = {r['town_id'] for r in executing_rows} # Get every town that has at least one pending build, ordered by # which town has been waiting longest (MIN updated_at across its commands). town_rows = c.execute(''' SELECT town_id FROM commands WHERE status = 'pending' AND type = 'build' AND player_id = ? GROUP BY town_id ORDER BY MIN(updated_at) ASC ''', (player_id,)).fetchall() results = [] now = datetime.utcnow().isoformat() for town_row in town_rows: town_id = town_row['town_id'] # Skip this town if a build is already executing for it if town_id in busy_towns: continue row = c.execute(''' SELECT * FROM commands WHERE status = 'pending' AND type = 'build' AND player_id = ? AND town_id = ? ORDER BY position ASC, id ASC LIMIT 1 ''', (player_id, town_id)).fetchone() if not row: continue c.execute(''' UPDATE commands SET status = 'executing', updated_at = ? WHERE id = ? ''', (now, row['id'])) results.append({ 'id': row['id'], 'town_id': row['town_id'], 'type': row['type'], 'payload': json.loads(row['payload']) }) return results @api.route('/api/commands/pending', methods=['GET']) def get_pending_command(): player_id = request.args.get('player_id') if not player_id: return jsonify({'error': 'no player_id provided'}), 400 conn = get_db() c = conn.cursor() # Free up stuck 'executing' commands (e.g. if the game page was refreshed mid-execution) two_minutes_ago = (datetime.utcnow() - timedelta(minutes=2)).isoformat() c.execute(''' UPDATE commands SET status = 'pending', result_msg = 'Requeued (timeout)' WHERE status = 'executing' AND updated_at < ? AND player_id = ? ''', (two_minutes_ago, player_id)) build_cmds = _fetch_pending_builds_all_towns(c, player_id) # one per town recruit_cmd = _fetch_pending_of_type(c, 'recruit', player_id) market_cmd = _fetch_pending_of_type(c, 'market_offer', player_id) farm_cmd = _fetch_pending_of_type(c, 'farm_loot', player_id) farm_upgrade_cmd = _fetch_pending_of_type(c, 'farm_upgrade', player_id) research_cmd = _fetch_pending_of_type(c, 'research', player_id) sync_req = _check_and_reset_sync(c, player_id) # Farm settings farm_row = c.execute( 'SELECT enabled, loot_option FROM farm_settings WHERE player_id = ?', (player_id,) ).fetchone() farm_settings = { 'enabled': bool(farm_row['enabled']) if farm_row else False, 'loot_option': farm_row['loot_option'] if farm_row else 1 } # Bot settings (bootcamp + rural trade) bot_row = c.execute( 'SELECT * FROM bot_settings WHERE player_id = ?', (str(player_id),) ).fetchone() bot_settings = { 'bootcamp_enabled': bool(bot_row['bootcamp_enabled']) if bot_row else False, 'bootcamp_use_def': bool(bot_row['bootcamp_use_def']) if bot_row else False, 'rural_trade_enabled': bool(bot_row['rural_trade_enabled']) if bot_row else False, 'rural_trade_ratio': bot_row['rural_trade_ratio'] if bot_row else 3, } # One-shot manual attack flag attack_now_key = f'bootcamp_attack_now_{player_id}' flag_row = c.execute('SELECT value FROM kv_store WHERE key = ?', (attack_now_key,)).fetchone() if flag_row and flag_row['value'] == '1': bot_settings['attack_now'] = True c.execute("UPDATE kv_store SET value = '0' WHERE key = ?", (attack_now_key,)) else: bot_settings['attack_now'] = False # Feature flags — look up this player's authorized features from their clan member_row = c.execute( 'SELECT features FROM clan_members WHERE player_id = ?', (str(player_id),) ).fetchone() if member_row and member_row['features']: enabled_features = [f.strip() for f in member_row['features'].split(',') if f.strip()] else: enabled_features = ['farm', 'admin'] # default: all on (backward-compatible) conn.commit() conn.close() return jsonify({ 'builds': build_cmds, # list: one build command per town 'recruit': recruit_cmd, 'market': market_cmd, 'research': research_cmd, 'farm': farm_cmd, 'farm_upgrade': farm_upgrade_cmd, 'farm_settings': farm_settings, 'bot_settings': bot_settings, 'enabled_features': enabled_features, 'sync_requested': sync_req }) def _check_and_reset_sync(c, player_id): key = f'sync_request_{player_id}' row = c.execute("SELECT value FROM kv_store WHERE key = ?", (key,)).fetchone() if row and row['value'] == '1': c.execute("UPDATE kv_store SET value = '0', updated_at = ? WHERE key = ?", (datetime.utcnow().isoformat(), key)) return True return False # ------------------------------------------------------------------ # POST /api/sync-request # Dashboard requests an immediate state update from the client. # ------------------------------------------------------------------ @api.route('/api/sync-request', methods=['POST']) def sync_request(): player_id = request.args.get('player_id') if not player_id: return jsonify({'error': 'no player_id provided'}), 400 conn = get_db() conn.execute(''' INSERT INTO kv_store (key, value, updated_at) VALUES (?, '1', ?) ON CONFLICT(key) DO UPDATE SET value = '1', updated_at = excluded.updated_at ''', (f'sync_request_{player_id}', datetime.utcnow().isoformat())) conn.commit() conn.close() return jsonify({'ok': True}) # ------------------------------------------------------------------ # POST /api/commands//result # Tampermonkey reports back whether the command succeeded or failed. # ------------------------------------------------------------------ @api.route('/api/commands//result', methods=['POST']) def command_result(cmd_id): data = request.get_json(silent=True) or {} status = data.get('status', 'done') # 'done' | 'failed' | 'pending' (requeue) msg = data.get('message', '') now = datetime.utcnow().isoformat() conn = get_db() # Look up type + player_id for post-update hooks cmd = conn.execute( 'SELECT type, player_id FROM commands WHERE id = ?', (cmd_id,) ).fetchone() conn.execute(''' UPDATE commands SET status = ?, result_msg = ?, updated_at = ? WHERE id = ? ''', (status, msg, now, cmd_id)) # When an explicit farm_loot command succeeds, record the timestamp if cmd and cmd['type'] == 'farm_loot' and status == 'done' and cmd['player_id']: conn.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'last_farmed_{cmd["player_id"]}', now, now)) conn.commit() conn.close() return jsonify({'ok': True}) # ------------------------------------------------------------------ # POST /api/captcha/alert # Tampermonkey reports when #hcaptcha_window appears/disappears. # Body: { detected: true | false } # ------------------------------------------------------------------ @api.route('/api/captcha/alert', methods=['POST']) def captcha_alert(): player_id = request.args.get('player_id') 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}' conn = get_db() conn.execute(''' INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at ''', (kv_key, '1' if detected else '0', datetime.utcnow().isoformat())) conn.commit() conn.close() return jsonify({'ok': True}) # ------------------------------------------------------------------ # POST /api/market_data # Tampermonkey uploads the market scan data. # ------------------------------------------------------------------ @api.route('/api/market_data', methods=['POST']) def upload_market_data(): player_id = request.args.get('player_id') if not player_id: return jsonify({'error': 'no player_id provided'}), 400 data = request.get_json(silent=True) or {} kv_key = f'market_data_{player_id}' conn = get_db() conn.execute(''' INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at ''', (kv_key, json.dumps(data), datetime.utcnow().isoformat())) conn.commit() conn.close() return jsonify({'ok': True}) # ------------------------------------------------------------------ # POST/GET /api/farm_status — TM reports warehouse_full; dashboard reads it # ------------------------------------------------------------------ @api.route('/api/farm_status', methods=['POST', 'GET']) def farm_status(): player_id = request.args.get('player_id') if not player_id: return jsonify({'error': 'no player_id'}), 400 kv_key = f'farm_status_{player_id}' conn = get_db() if request.method == 'POST': data = request.get_json(silent=True) or {} now = datetime.utcnow().isoformat() conn.execute(''' INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at ''', (kv_key, json.dumps(data), now)) # Auto-farm reports warehouse_full=false when it successfully looted something if not data.get('warehouse_full', True): conn.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'last_farmed_{player_id}', now, now)) conn.commit() conn.close() return jsonify({'ok': True}) else: row = conn.execute('SELECT value FROM kv_store WHERE key=?', (kv_key,)).fetchone() conn.close() return jsonify(json.loads(row['value']) if row else {'warehouse_full': False}) # ------------------------------------------------------------------ # POST /api/bot-logs # TM bot reports log entries for bootcamp / rural_trade loops. # ------------------------------------------------------------------ @api.route('/api/bot-logs', methods=['POST']) def api_bot_logs(): clan = _get_clan_from_request() if not clan: return make_response('Unauthorized', 403) data = request.get_json(silent=True) or {} player_id = str(data.get('player_id', '')) feature = data.get('feature', '') message = data.get('message', '') if not player_id or not feature or not message: return jsonify({'error': 'missing fields'}), 400 conn = get_db() conn.execute( 'INSERT INTO bot_logs (player_id, feature, message) VALUES (?, ?, ?)', (player_id, feature, message) ) # Keep only latest 50 per player/feature conn.execute(''' DELETE FROM bot_logs WHERE player_id = ? AND feature = ? AND id NOT IN ( SELECT id FROM bot_logs WHERE player_id = ? AND feature = ? ORDER BY id DESC LIMIT 50 ) ''', (player_id, feature, player_id, feature)) conn.commit() conn.close() return jsonify({'ok': True}) # ------------------------------------------------------------------ # GET /api/bot # Serves the modular bot code concatenated into a single response # ------------------------------------------------------------------ @api.route('/api/bot', methods=['GET']) def serve_bot(): # Require a valid clan key — reject unknown clients clan = _get_clan_from_request() if not clan: return make_response('Unauthorized: invalid or missing clan key', 403) bot_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'bot_modules') if not os.path.exists(bot_dir): return make_response("Bot modules directory not found", 404) modules = sorted([f for f in os.listdir(bot_dir) if f.endswith('.js')]) combined_code = [] combined_code.append("(function() {") combined_code.append(" 'use strict';\n") for module in modules: with open(os.path.join(bot_dir, module), 'r', encoding='utf-8') as f: combined_code.append(f" // --- BEGIN {module} ---") combined_code.append(f.read()) combined_code.append(f" // --- END {module} ---\n") combined_code.append("})();") response = make_response("\n".join(combined_code)) response.headers['Content-Type'] = 'application/javascript' # Prevent caching so updates are instant response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' return response