// ================================================================ // 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; })();