diff --git a/bot_modules/04c_execute_bootcamp_trade.js b/bot_modules/04c_execute_bootcamp_trade.js new file mode 100644 index 0000000..17e06e6 --- /dev/null +++ b/bot_modules/04c_execute_bootcamp_trade.js @@ -0,0 +1,294 @@ +// ================================================================ +// 04c_execute_bootcamp_trade.js +// Auto-Bootcamp & Auto-Rural-Trade loops +// +// Both are driven by jitterLoop (registered in 05_main.js boot()). +// Settings arrive via cmdData.bot_settings in the poll response — +// no extra network call is needed. +// +// Timers (human-like, randomised): +// Bootcamp : 12–22 min (camp cooldown is ~12–15 min) +// RuralTrade: 25–45 min (trading is low-urgency) +// ================================================================ + +// Shared cache — set by pollAndExecute every 8-18 s +let lastKnownBotSettings = {}; + +// ---------------------------------------------------------------- +// botLog — sends a log entry to the server +// ---------------------------------------------------------------- +async function botLog(player_id, feature, message) { + log(`[${feature}] ${message}`); + try { + await apiFetch(`${BASE_URL}/api/bot-logs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ player_id, feature, message }) + }); + } catch (e) { /* non-critical */ } +} + +// ================================================================ +// AUTO BOOTCAMP +// ================================================================ +async function autoBootcampLoop() { + if (paused) return; + + const settings = lastKnownBotSettings; + if (!settings.bootcamp_enabled) return; + + const player_id = uw.Game?.player_id; + if (!player_id) return; + + let model; + try { + model = uw.MM.getModelByNameAndPlayerId('PlayerAttackSpot'); + } catch (e) { return; } + + if (!model) return; + + // ── 1. Claim reward if available ────────────────────────────── + try { + const hasReward = model.hasReward?.(); + if (hasReward) { + const reward = model.getReward?.(); + if (reward) { + const isInstant = reward.power_id?.includes('instant'); + const isFavor = reward.power_id?.includes('favor'); + const stashable = reward.stashable; + + if (isInstant && !isFavor) { + // Use instant rewards immediately + uw.gpAjax.ajaxPost('frontend_bridge', 'execute', { + model_url: `PlayerAttackSpot/${player_id}`, + action_name: 'useReward', + arguments: {} + }); + await botLog(player_id, 'bootcamp', `Reward used: ${reward.power_id}`); + } else if (stashable) { + uw.gpAjax.ajaxPost('frontend_bridge', 'execute', { + model_url: `PlayerAttackSpot/${player_id}`, + action_name: 'stashReward', + arguments: {} + }); + await botLog(player_id, 'bootcamp', `Reward stashed: ${reward.power_id}`); + } else { + uw.gpAjax.ajaxPost('frontend_bridge', 'execute', { + model_url: `PlayerAttackSpot/${player_id}`, + action_name: 'useReward', + arguments: {} + }); + await botLog(player_id, 'bootcamp', `Reward used (fallback): ${reward.power_id}`); + } + await sleep(randInt(3000, 7000)); + return; // Wait for next cycle to attack + } + } + } catch (e) { /* reward check failed — continue to attack check */ } + + // ── 2. Attack if no cooldown ─────────────────────────────────── + try { + const cooldown = model.getCooldownDuration?.() ?? 1; + if (cooldown > 0) { + const minRemaining = Math.round(cooldown / 60); + await botLog(player_id, 'bootcamp', `Camp on cooldown — ${minRemaining} min remaining`); + return; + } + + // Check no existing attack movement to/from camp + const movements = uw.MM.getModels()?.MovementsUnits; + if (movements) { + for (const mv of Object.values(movements)) { + if (mv.attributes.destination_is_attack_spot || mv.attributes.origin_is_attack_spot) { + await botLog(player_id, 'bootcamp', 'Attack already in flight — skipping'); + return; + } + } + } + + // Collect units from current town + const currentTownId = uw.Game?.townId; + if (!currentTownId) return; + + const town = uw.ITowns?.towns?.[currentTownId]; + if (!town) return; + + const units = { ...town.units?.() }; + delete units.militia; + + // Remove naval + for (const unit in units) { + if (uw.GameData?.units?.[unit]?.is_naval) delete units[unit]; + } + + // Remove defensive if use_def is off + if (!settings.bootcamp_use_def) { + delete units.sword; + delete units.archer; + } + + // Remove zero-count + for (const unit in units) { + if (!units[unit] || units[unit] <= 0) delete units[unit]; + } + + if (Object.keys(units).length === 0) { + await botLog(player_id, 'bootcamp', 'No available units — skipping attack'); + return; + } + + uw.gpAjax.ajaxPost('frontend_bridge', 'execute', { + model_url: `PlayerAttackSpot/${player_id}`, + action_name: 'attack', + arguments: units + }); + + const unitSummary = Object.entries(units).map(([u, n]) => `${n}x${u}`).join(', '); + await botLog(player_id, 'bootcamp', `Attack sent — ${unitSummary}`); + + } catch (e) { + await botLog(player_id, 'bootcamp', `Error during attack: ${e}`); + } +} + + +// ================================================================ +// AUTO RURAL TRADE +// Triggers only when a town's pending build command is stuck due to +// insufficient resources. Trades for the specific missing resource. +// +// Ratio map: 1→0.25, 2→0.5, 3→0.75, 4→1.0, 5→1.25 +// ================================================================ +const RATIO_MAP = { 1: 0.25, 2: 0.50, 3: 0.75, 4: 1.00, 5: 1.25 }; + +async function autoRuralTradeLoop() { + if (paused) return; + + const settings = lastKnownBotSettings; + if (!settings.rural_trade_enabled) return; + + const player_id = uw.Game?.player_id; + if (!player_id) return; + + const minRatio = RATIO_MAP[settings.rural_trade_ratio] ?? 0.75; + + let farmModels, relModels; + try { + farmModels = uw.MM.getOnlyCollectionByName('FarmTown')?.models; + relModels = uw.MM.getOnlyCollectionByName('FarmTownPlayerRelation')?.models; + } catch (e) { return; } + + if (!farmModels || !relModels) return; + + // ── Find towns with stuck build commands ─────────────────────── + // We look at each town's in-game build queue. If the queue slot + // is empty but we have a pending remote command, the build is + // waiting — check which resource it needs. + const towns = uw.ITowns?.towns; + if (!towns) return; + + let tradesTotal = 0; + + for (const [town_id_str, town] of Object.entries(towns)) { + if (paused) return; + + // Get next pending build for this town from BuildingBuildData + let missingResource = null; + try { + const buildData = uw.MM.getModels()?.BuildingBuildData?.[town_id_str]; + if (!buildData) continue; + + const orders = town.buildingOrders?.()?.models ?? []; + // Only check if the in-game queue has room (build could be submitted) + const queueCap = uw.GameDataPremium?.isAdvisorActivated('curator') ? 7 : 2; + if (orders.length >= queueCap) continue; // Queue full — not stuck on resources + + // Check all building types to find one we might be trying to build + // Heuristic: look for any building where we have less resources than needed + const allBuildingData = buildData.attributes?.building_data ?? {}; + const res = town.resources?.() ?? {}; + + for (const [building_id, bdata] of Object.entries(allBuildingData)) { + if (!bdata.resources_for) continue; + const cost = bdata.resources_for; + const w = cost.wood || 0; + const s = cost.stone || 0; + const ir = cost.iron || 0; + + // Skip if we can already afford it + if (res.wood >= w && res.stone >= s && res.iron >= ir) continue; + + // Find which resource is most lacking (relative to cost) + const shortfalls = []; + if (w > 0) shortfalls.push({ res: 'wood', ratio: res.wood / w }); + if (s > 0) shortfalls.push({ res: 'stone', ratio: res.stone / s }); + if (ir > 0) shortfalls.push({ res: 'iron', ratio: res.iron / ir }); + + if (shortfalls.length === 0) continue; + shortfalls.sort((a, b) => a.ratio - b.ratio); + missingResource = shortfalls[0].res; + break; + } + } catch (e) { continue; } + + if (!missingResource) continue; + + // ── Find farm villages on this island offering missingResource ── + const town_obj = town; + const ix = town_obj.getIslandCoordinateX?.(); + const iy = town_obj.getIslandCoordinateY?.(); + if (ix == null || iy == null) continue; + + let tradeMade = false; + for (const farm of farmModels) { + if (farm.attributes.island_x !== ix || farm.attributes.island_y !== iy) continue; + if (farm.attributes.resource_offer !== missingResource) continue; + + for (const rel of relModels) { + if (rel.attributes.farm_town_id !== farm.attributes.id) continue; + if (rel.attributes.relation_status !== 1) continue; // must be allied + + // Check ratio meets minimum + const tradeRatio = rel.attributes.current_trade_ratio ?? 0; + if (tradeRatio < minRatio) continue; + + const tradeCapacity = town_obj.getAvailableTradeCapacity?.() ?? 0; + if (tradeCapacity < 100) continue; + + const amount = Math.min(tradeCapacity, 3000); + + if (paused) return; + + try { + uw.gpAjax.ajaxPost('frontend_bridge', 'execute', { + model_url: `FarmTownPlayerRelation/${rel.id}`, + action_name: 'trade', + arguments: { farm_town_id: farm.attributes.id, amount }, + town_id: parseInt(town_id_str) + }); + await botLog(player_id, 'rural_trade', + `Traded ${amount} ${missingResource} ← ${farm.attributes.name} via ${town_obj.getName?.() ?? town_id_str}`); + tradesTotal++; + tradeMade = true; + } catch (e) { + await botLog(player_id, 'rural_trade', `Trade error: ${e}`); + } + + await sleep(randInt(800, 1800)); + } + + if (tradeMade) break; // One trade per town per cycle is enough + } + + if (!tradeMade && missingResource) { + await botLog(player_id, 'rural_trade', + `${town_obj.getName?.() ?? town_id_str} needs ${missingResource} but no suitable village found`); + } + + await sleep(randInt(500, 1200)); + } + + if (tradesTotal === 0) { + log('[rural_trade] No stuck builds or no tradeable villages — nothing to do'); + } +} diff --git a/bot_modules/05_main.js b/bot_modules/05_main.js index 2c656d3..9fbbcf3 100644 --- a/bot_modules/05_main.js +++ b/bot_modules/05_main.js @@ -28,8 +28,9 @@ async function pollAndExecute() { return; } - // Cache farm settings so autoFarmLoop can read them without an extra call + // Cache farm + bot settings so autonomous loops can read them without extra calls lastKnownFarmSettings = cmdData.farm_settings || {}; + lastKnownBotSettings = cmdData.bot_settings || {}; // Feature flags — default to all on if server doesn't send them (backward compatible) const features = cmdData.enabled_features || ['farm', 'admin']; @@ -190,12 +191,14 @@ async function autoFarmLoop() { // so we check readyState and boot immediately in that case. // ---------------------------------------------------------------- function boot() { - log('Grepolis Remote Control v4.1.0 (remote) loaded'); + log('Grepolis Remote Control v4.2.0 (remote) loaded'); detectCaptcha(); setTimeout(pushState, 5000); - jitterLoop(pushState, 60000, 120000); // state sync every 1-2 min - jitterLoop(pollAndExecute, 8000, 18000); // command poll every 8-18 s - jitterLoop(autoFarmLoop, 60000, 120000); // auto-farm every 1-2 min (independent) + jitterLoop(pushState, 60000, 120000); // state sync every 1–2 min + jitterLoop(pollAndExecute, 8000, 18000); // command poll every 8–18 s + jitterLoop(autoFarmLoop, 60000, 120000); // auto-farm every 1–2 min + jitterLoop(autoBootcampLoop, 720000, 1320000); // bootcamp every 12–22 min + jitterLoop(autoRuralTradeLoop, 1500000, 2700000); // rural trade every 25–45 min } if (document.readyState === 'complete') { diff --git a/db.py b/db.py index 4577d7f..07588d2 100644 --- a/db.py +++ b/db.py @@ -68,6 +68,30 @@ def init_db(): ) ''') + # Bot settings — per-player config for bootcamp & rural-trade auto-loops + c.execute(''' + CREATE TABLE IF NOT EXISTS bot_settings ( + player_id TEXT PRIMARY KEY, + bootcamp_enabled INTEGER NOT NULL DEFAULT 0, + bootcamp_use_def INTEGER NOT NULL DEFAULT 0, + rural_trade_enabled INTEGER NOT NULL DEFAULT 0, + rural_trade_ratio INTEGER NOT NULL DEFAULT 3, -- 1=0.25 2=0.5 3=0.75 4=1.0 5=1.25 + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + ''') + + # Bot logs — ring buffer of last 50 entries per player per feature + c.execute(''' + CREATE TABLE IF NOT EXISTS bot_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + player_id TEXT NOT NULL, + feature TEXT NOT NULL, -- 'bootcamp' | 'rural_trade' + message TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + ''') + c.execute('CREATE INDEX IF NOT EXISTS idx_bot_logs_player_feature ON bot_logs(player_id, feature)') + # Migration: add new columns if upgrading an existing database for _col in [ 'ALTER TABLE town_state ADD COLUMN player_id TEXT', diff --git a/routes/api.py b/routes/api.py index 2e1b59c..746a8b9 100644 --- a/routes/api.py +++ b/routes/api.py @@ -195,6 +195,17 @@ def get_pending_command(): '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, + } + # 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),) @@ -215,6 +226,7 @@ def get_pending_command(): 'farm': farm_cmd, 'farm_upgrade': farm_upgrade_cmd, 'farm_settings': farm_settings, + 'bot_settings': bot_settings, 'enabled_features': enabled_features, 'sync_requested': sync_req }) @@ -367,6 +379,46 @@ def farm_status(): 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 diff --git a/routes/dashboard.py b/routes/dashboard.py index ee1ef21..8b69ffa 100644 --- a/routes/dashboard.py +++ b/routes/dashboard.py @@ -441,3 +441,105 @@ def fail_stale_commands(): conn.commit() conn.close() return jsonify({'ok': True, 'failed': affected}) + + +# ------------------------------------------------------------------ +# GET /dashboard/bot-settings — fetch bootcamp + rural trade config +# POST /dashboard/bot-settings — save config +# ------------------------------------------------------------------ +@dashboard.route('/dashboard/bot-settings', methods=['GET', 'POST']) +def bot_settings(): + player_id = request.args.get('player_id') or (request.json or {}).get('player_id') + if not player_id: + return jsonify({'error': 'missing player_id'}), 400 + + conn = get_db() + c = conn.cursor() + + if request.method == 'GET': + row = c.execute( + 'SELECT * FROM bot_settings WHERE player_id = ?', (player_id,) + ).fetchone() + conn.close() + if row: + return jsonify(dict(row)) + return jsonify({ + 'player_id': player_id, + 'bootcamp_enabled': 0, + 'bootcamp_use_def': 0, + 'rural_trade_enabled': 0, + 'rural_trade_ratio': 3, + }) + + # POST — upsert + data = request.json or {} + c.execute(''' + INSERT INTO bot_settings (player_id, bootcamp_enabled, bootcamp_use_def, + rural_trade_enabled, rural_trade_ratio, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(player_id) DO UPDATE SET + bootcamp_enabled = excluded.bootcamp_enabled, + bootcamp_use_def = excluded.bootcamp_use_def, + rural_trade_enabled = excluded.rural_trade_enabled, + rural_trade_ratio = excluded.rural_trade_ratio, + updated_at = excluded.updated_at + ''', ( + player_id, + int(bool(data.get('bootcamp_enabled', 0))), + int(bool(data.get('bootcamp_use_def', 0))), + int(bool(data.get('rural_trade_enabled', 0))), + int(data.get('rural_trade_ratio', 3)), + datetime.utcnow().isoformat() + )) + conn.commit() + conn.close() + return jsonify({'ok': True}) + + +# ------------------------------------------------------------------ +# GET /dashboard/bot-logs?player_id=&feature= — last 50 log lines +# POST /dashboard/bot-logs — append + prune +# ------------------------------------------------------------------ +@dashboard.route('/dashboard/bot-logs', methods=['GET', 'POST']) +def bot_logs(): + player_id = request.args.get('player_id') or (request.json or {}).get('player_id') + if not player_id: + return jsonify({'error': 'missing player_id'}), 400 + + conn = get_db() + c = conn.cursor() + + if request.method == 'GET': + feature = request.args.get('feature', '') + query = 'SELECT * FROM bot_logs WHERE player_id = ?' + params = [player_id] + if feature: + query += ' AND feature = ?' + params.append(feature) + query += ' ORDER BY id DESC LIMIT 50' + rows = c.execute(query, params).fetchall() + conn.close() + return jsonify([dict(r) for r in rows]) + + # POST — append entry and prune to last 50 + data = request.json or {} + feature = data.get('feature', 'bootcamp') + message = data.get('message', '') + c.execute( + 'INSERT INTO bot_logs (player_id, feature, message) VALUES (?, ?, ?)', + (player_id, feature, message) + ) + # Prune: keep only the latest 50 per player/feature + c.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}) + diff --git a/templates/farm.html b/templates/farm.html index 1106920..b7622d3 100644 --- a/templates/farm.html +++ b/templates/farm.html @@ -313,6 +313,75 @@ + +
+ Το bot επιτίθεται αυτόματα στο στρατόπεδο ληστών και διεκδικεί αμοιβές.
+ Ελέγχει κάθε 12–22 λεπτά (τυχαίο) — ανθρώπινος ρυθμός.
+
+ Ενεργοποιείται μόνο όταν μια κατασκευή κολλάει λόγω πόρων.
+ Ψάχνει χωριά στο νησί που προσφέρουν τον ελλείποντα πόρο και κάνει trade.
+ Ελέγχει κάθε 25–45 λεπτά (τυχαίο).
+