246 lines
10 KiB
JavaScript
246 lines
10 KiB
JavaScript
// ================================================================
|
|
// 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;
|
|
|
|
})();
|