MJ: attack coordinator update / various fixes . captcha and back button and jitterloop 2 secs
This commit is contained in:
245
bot_modules/07_attack_planner.js
Normal file
245
bot_modules/07_attack_planner.js
Normal file
@@ -0,0 +1,245 @@
|
||||
// ================================================================
|
||||
// 07_attack_planner.js — Coordinated Timed Attack Executor
|
||||
// Depends on: 00_config.js (BASE_URL, apiFetch, log, sleep)
|
||||
//
|
||||
// Flow:
|
||||
// 1. Every ~10s polls /api/<world>/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;
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user