473 lines
19 KiB
JavaScript
473 lines
19 KiB
JavaScript
// ==UserScript==
|
||
// @name Grepolis Remote Control
|
||
// @namespace http://tampermonkey.net/
|
||
// @version 2.6
|
||
// @description Polls grepo.haunter-pets.top for remote commands and executes them in-game
|
||
// @author Dimitrios
|
||
// @match https://*.grepolis.com/game/*
|
||
// @grant unsafeWindow
|
||
// @updateURL https://git.haunter-pets.top/haunter/grepo-remote/raw/branch/main/GrepolisRemoteControl.user.js
|
||
// @downloadURL https://git.haunter-pets.top/haunter/grepo-remote/raw/branch/main/GrepolisRemoteControl.user.js
|
||
// ==/UserScript==
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
const uw = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
|
||
const BASE_URL = 'https://grepo.haunter-pets.top';
|
||
|
||
// ---- Jitter helpers -----------------------------------------------
|
||
// Returns a random integer between min and max (inclusive)
|
||
function randInt(min, max) {
|
||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||
}
|
||
// Schedules fn to run after a random ms delay, then reschedules itself
|
||
function jitterLoop(fn, minMs, maxMs) {
|
||
function schedule() {
|
||
setTimeout(async () => {
|
||
await fn();
|
||
schedule(); // reschedule with a NEW random delay every time
|
||
}, randInt(minMs, maxMs));
|
||
}
|
||
schedule();
|
||
}
|
||
|
||
// ----------------------------------------------------------------
|
||
// Toolbar indicator button
|
||
// ----------------------------------------------------------------
|
||
const btnHtml = `
|
||
<div class="divider"></div>
|
||
<div class="activity" id="grc_btn"
|
||
style="filter: brightness(70%) sepia(100%) hue-rotate(200deg) saturate(1000%) contrast(0.8);">
|
||
<p id="grc_label" style="position:relative;top:-8px;font-weight:bold;z-index:6;">Remote</p>
|
||
</div>`;
|
||
|
||
let paused = false;
|
||
|
||
function togglePause() {
|
||
paused = !paused;
|
||
const label = document.getElementById('grc_label');
|
||
const btn = document.getElementById('grc_btn');
|
||
if (paused) {
|
||
label.textContent = 'Paused';
|
||
btn.style.filter = 'brightness(70%) sepia(100%) hue-rotate(-50deg) saturate(1000%) contrast(0.8)';
|
||
} else {
|
||
label.textContent = 'Remote';
|
||
btn.style.filter = 'brightness(294%) sepia(100%) hue-rotate(200deg) saturate(1000%) contrast(0.8)';
|
||
}
|
||
log(`Remote is now ${paused ? 'PAUSED' : 'ACTIVE'}`);
|
||
}
|
||
|
||
setTimeout(() => {
|
||
if (!document.getElementById('grc_btn')) {
|
||
uw.$('.tb_activities, .toolbar_activities').find('.middle').append(btnHtml);
|
||
}
|
||
}, 4000);
|
||
|
||
uw.$(document).on('click', '#grc_btn', togglePause);
|
||
|
||
// ----------------------------------------------------------------
|
||
// Helpers
|
||
// ----------------------------------------------------------------
|
||
function log(msg) {
|
||
console.log(`[GRC] ${msg}`);
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise(r => setTimeout(r, ms));
|
||
}
|
||
|
||
// ----------------------------------------------------------------
|
||
// Push town state to relay
|
||
// ----------------------------------------------------------------
|
||
function gatherState() {
|
||
const towns = uw.ITowns?.towns || {};
|
||
const player = uw.Game?.player_name || '';
|
||
const player_id = uw.Game?.player_id ?? null;
|
||
const alliance_id = uw.Game?.alliance_id ?? null;
|
||
const total_points = uw.Game?.player_points ?? 0;
|
||
const world = uw.Game?.world_id || '';
|
||
|
||
const townList = Object.values(towns).map(town => {
|
||
const res = town.resources();
|
||
const buildings = town.buildings()?.attributes ?? {};
|
||
|
||
const unitsObj = {};
|
||
try {
|
||
const units = town.units();
|
||
if (units) {
|
||
Object.keys(units).forEach(k => {
|
||
unitsObj[k] = typeof units[k] === 'number'
|
||
? units[k]
|
||
: (units[k]?.getAmount?.() ?? 0);
|
||
});
|
||
}
|
||
} catch (e) { }
|
||
|
||
let buildQueue = [];
|
||
try {
|
||
const bo = town.buildingOrders?.();
|
||
if (bo?.models) buildQueue = bo.models.map(m => m.attributes);
|
||
} catch (e) { }
|
||
|
||
let buildDataMap = {};
|
||
try {
|
||
const buildDataRaw = uw.MM?.getModels?.()?.BuildingBuildData?.[town.id]?.attributes?.building_data || {};
|
||
for (const k in buildDataRaw) {
|
||
buildDataMap[k] = {
|
||
buildable: buildDataRaw[k].buildable,
|
||
dependencies: buildDataRaw[k].dependencies_fulfilled !== false,
|
||
wood: buildDataRaw[k].resources_for?.wood || 0,
|
||
stone: buildDataRaw[k].resources_for?.stone || 0,
|
||
iron: buildDataRaw[k].resources_for?.iron || 0,
|
||
pop: buildDataRaw[k].population_for || 0,
|
||
build_time: buildDataRaw[k].building_time || '',
|
||
can_upgrade: !!buildDataRaw[k].can_upgrade,
|
||
enough_resources: !!buildDataRaw[k].enough_resources,
|
||
missing_dependencies: buildDataRaw[k].missing_dependencies || [],
|
||
has_max_level: !!buildDataRaw[k].has_max_level
|
||
};
|
||
}
|
||
} catch (e) {
|
||
log(`Failed to gather build data: ${e}`);
|
||
}
|
||
|
||
// ---- Storage capacity -----------------------------------------------
|
||
// res.storage / res.storage_capacity are always 0 in Grepolis.
|
||
// Real capacity lives in GameData keyed by the storage building level.
|
||
let storageCapacity = 0;
|
||
try {
|
||
// Strategy 1: dedicated getter (exists on some server versions)
|
||
storageCapacity = town.getStorageCapacity?.() || 0;
|
||
|
||
// Strategy 2: GameData.buildingData.storage.max_storage[level]
|
||
if (!storageCapacity) {
|
||
const storageLevel = buildings.storage ?? 0;
|
||
const gd = uw.GameData?.buildingData?.storage;
|
||
storageCapacity = gd?.max_storage?.[storageLevel]
|
||
|| gd?.storage?.[storageLevel]
|
||
|| 0;
|
||
}
|
||
|
||
// Strategy 3: resource object fallback keys
|
||
if (!storageCapacity) {
|
||
storageCapacity = res.capacity || res.storage_capacity || res.storage || 0;
|
||
}
|
||
} catch (e) {
|
||
log(`storage capacity lookup failed: ${e}`);
|
||
}
|
||
|
||
// ---- Coordinates & sea zone -----------------------------------------
|
||
let x = null, y = null, sea = null;
|
||
try {
|
||
x = town.getIslandCoordinateX?.() ?? null;
|
||
y = town.getIslandCoordinateY?.() ?? null;
|
||
if (typeof x === 'number' && typeof y === 'number') {
|
||
sea = Math.floor(x / 100) * 10 + Math.floor(y / 100);
|
||
}
|
||
} catch (e) { }
|
||
|
||
// ---- Researches -----------------------------------------------------
|
||
let researches = {};
|
||
try {
|
||
const r = town.researches?.();
|
||
if (r) researches = r.attributes ?? (typeof r === 'object' ? r : {});
|
||
} catch (e) { log(`[Debug] town.researches() failed: ${e}`); }
|
||
|
||
// ---- Unit Data (Costs & Dependencies) -------------------------------
|
||
let unitDataMap = {};
|
||
try {
|
||
const gdUnits = uw.GameData?.units || {};
|
||
|
||
for (const u in gdUnits) {
|
||
if (u === 'militia') continue;
|
||
|
||
const reqBuildings = gdUnits[u].building_dependencies || {};
|
||
const reqResearch = gdUnits[u].research_dependencies || [];
|
||
|
||
let missing_deps = {};
|
||
for (const reqB in reqBuildings) {
|
||
if ((buildings[reqB] || 0) < reqBuildings[reqB]) {
|
||
missing_deps[reqB] = { name: reqB, needed_level: reqBuildings[reqB] };
|
||
}
|
||
}
|
||
for (const reqR of reqResearch) {
|
||
if (!researches[reqR]) {
|
||
missing_deps[reqR] = { name: reqR, needed_level: 'Έρευνα' };
|
||
}
|
||
}
|
||
|
||
const cost = gdUnits[u].resources || {};
|
||
const w = cost.wood || 0;
|
||
const s = cost.stone || 0;
|
||
const i = cost.iron || 0;
|
||
|
||
let enough = true;
|
||
if (res.wood < w || res.stone < s || res.iron < i) enough = false;
|
||
|
||
unitDataMap[u] = {
|
||
wood: w,
|
||
stone: s,
|
||
iron: i,
|
||
pop: gdUnits[u].population || 0,
|
||
build_time: gdUnits[u].build_time || 0,
|
||
enough_resources: enough,
|
||
missing_dependencies: missing_deps
|
||
};
|
||
}
|
||
} catch (e) { log(`Failed to gather unit data: ${e}`); }
|
||
|
||
// ---- Extra town flags -----------------------------------------------
|
||
let has_premium = false;
|
||
let bonuses = {};
|
||
let wonder_points = 0;
|
||
try {
|
||
// Proper premium check for Admin/Curator
|
||
has_premium = uw.GameDataPremium?.isAdvisorActivated?.('curator') || false;
|
||
} catch (e) { }
|
||
|
||
return {
|
||
town_id: town.id,
|
||
town_name: town.name,
|
||
x, y, sea,
|
||
wood: res.wood,
|
||
stone: res.stone,
|
||
iron: res.iron,
|
||
storage: storageCapacity,
|
||
population: res.population,
|
||
points: town.getPoints?.() ?? 0,
|
||
god: town.god?.() ?? null,
|
||
buildings,
|
||
units: unitsObj,
|
||
buildingOrder: buildQueue,
|
||
buildData: buildDataMap,
|
||
unitData: unitDataMap,
|
||
researches,
|
||
has_premium,
|
||
bonuses,
|
||
wonder_points,
|
||
};
|
||
});
|
||
|
||
return { player, player_id, alliance_id, total_points, world_id: world, towns: townList };
|
||
}
|
||
|
||
function pushState() {
|
||
if (paused) return;
|
||
try {
|
||
const payload = gatherState();
|
||
fetch(`${BASE_URL}/api/state`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
})
|
||
.then(() => log(`State pushed — ${payload.towns.length} towns`))
|
||
.catch(e => log(`State push failed: ${e}`));
|
||
} catch (e) {
|
||
log(`gatherState error: ${e}`);
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------------
|
||
// Report command result back to relay
|
||
// ----------------------------------------------------------------
|
||
function reportResult(cmdId, status, message) {
|
||
fetch(`${BASE_URL}/api/commands/${cmdId}/result`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status, message })
|
||
}).catch(e => log(`reportResult failed: ${e}`));
|
||
}
|
||
|
||
// ----------------------------------------------------------------
|
||
// Captcha detection via MutationObserver
|
||
// Watches document.body for #hcaptcha_window being added/removed.
|
||
// Confirmed selector from live DOM: DIV#hcaptcha_window > DIV.h-captcha
|
||
// ----------------------------------------------------------------
|
||
let captchaActive = false;
|
||
|
||
function reportCaptcha(detected) {
|
||
fetch(`${BASE_URL}/api/captcha/alert`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ detected })
|
||
}).catch(e => log(`captcha report failed: ${e}`));
|
||
}
|
||
|
||
function detectCaptcha() {
|
||
setInterval(() => {
|
||
const win = document.getElementById('hcaptcha_window');
|
||
let isVisible = false;
|
||
|
||
if (win) {
|
||
// Check if it's actually visible on screen (not display: none by the game)
|
||
const style = window.getComputedStyle(win);
|
||
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
||
isVisible = true;
|
||
}
|
||
}
|
||
|
||
if (isVisible && !captchaActive) {
|
||
captchaActive = true;
|
||
paused = true;
|
||
const label = document.getElementById('grc_label');
|
||
const btn = document.getElementById('grc_btn');
|
||
if (label) label.textContent = '⚠ CAPTCHA';
|
||
if (btn) btn.style.filter = 'brightness(70%) sepia(100%) hue-rotate(300deg) saturate(1000%) contrast(0.8)';
|
||
log('⚠ CAPTCHA detected — bot paused, alerting server');
|
||
reportCaptcha(true);
|
||
} else if (!isVisible && captchaActive) {
|
||
captchaActive = false;
|
||
// Don't auto-resume — let the user click the button manually
|
||
log('✅ Captcha resolved — alert cleared (bot remains paused)');
|
||
reportCaptcha(false);
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
// ----------------------------------------------------------------
|
||
// Execute: Build
|
||
// ----------------------------------------------------------------
|
||
async function executeBuild(cmd) {
|
||
const { town_id, payload } = cmd;
|
||
const { building_id } = payload;
|
||
|
||
const town = uw.ITowns?.getTown?.(town_id) || uw.ITowns?.towns?.[town_id];
|
||
if (!town) {
|
||
return { ok: false, msg: `Town ${town_id} not found in ITowns` };
|
||
}
|
||
|
||
// Check build queue
|
||
const queueLen = town.buildingOrders?.()?.length ?? 0;
|
||
const hasCurator = uw.GameDataPremium?.isAdvisorActivated?.('curator');
|
||
const maxQueue = hasCurator ? 7 : 2;
|
||
if (queueLen >= maxQueue) {
|
||
return { ok: false, requeue: true, msg: `Build queue full (${queueLen}/${maxQueue})` };
|
||
}
|
||
|
||
// Check resources
|
||
try {
|
||
const buildData = uw.MM.getModels?.()?.BuildingBuildData?.[town_id]
|
||
?.attributes?.building_data?.[building_id];
|
||
if (buildData) {
|
||
const res = town.resources();
|
||
const { resources_for, population_for } = buildData;
|
||
if (town.getAvailablePopulation?.() < population_for) {
|
||
return { ok: false, requeue: true, msg: `Not enough population for ${building_id}` };
|
||
}
|
||
if (res.wood < resources_for.wood || res.stone < resources_for.stone || res.iron < resources_for.iron) {
|
||
return { ok: false, requeue: true, msg: `Not enough resources for ${building_id}` };
|
||
}
|
||
}
|
||
} catch (e) {
|
||
log(`Resource check skipped: ${e}`);
|
||
}
|
||
|
||
// Fire the build request — with a human-like reaction delay
|
||
const reactionMs = randInt(800, 2500);
|
||
log(`Waiting ${reactionMs}ms before firing buildUp (reaction time)...`);
|
||
await sleep(reactionMs);
|
||
|
||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||
model_url: 'BuildingOrder',
|
||
action_name: 'buildUp',
|
||
arguments: { building_id },
|
||
town_id
|
||
});
|
||
|
||
await sleep(500);
|
||
return { ok: true, msg: `buildUp ${building_id} queued` };
|
||
}
|
||
|
||
// ----------------------------------------------------------------
|
||
// Execute: Recruit
|
||
// ----------------------------------------------------------------
|
||
async function executeRecruit(cmd) {
|
||
const { town_id, payload } = cmd;
|
||
const { unit_id, amount } = payload;
|
||
|
||
const town = uw.ITowns?.getTown?.(town_id) || uw.ITowns?.towns?.[town_id];
|
||
if (!town) {
|
||
return { ok: false, msg: `Town ${town_id} not found` };
|
||
}
|
||
|
||
// Determine endpoint based on unit type
|
||
const navalUnits = [
|
||
'big_transporter', 'small_transporter', 'bireme',
|
||
'attack_ship', 'trireme', 'colonize_ship', 'sea_monster'
|
||
];
|
||
const endpoint = navalUnits.includes(unit_id) ? 'building_docks' : 'building_barracks';
|
||
|
||
// Fire the recruit request — with a human-like reaction delay
|
||
const reactionMs = randInt(800, 2500);
|
||
log(`Waiting ${reactionMs}ms before firing recruit (reaction time)...`);
|
||
await sleep(reactionMs);
|
||
|
||
uw.gpAjax.ajaxPost(endpoint, 'build', {
|
||
unit_id,
|
||
amount: parseInt(amount) || 1,
|
||
town_id
|
||
});
|
||
|
||
await sleep(500);
|
||
return { ok: true, msg: `Recruit ${amount}x ${unit_id} submitted` };
|
||
}
|
||
|
||
// ----------------------------------------------------------------
|
||
// Poll for and execute pending commands (build + recruit in parallel)
|
||
// ----------------------------------------------------------------
|
||
async function pollAndExecute() {
|
||
if (paused) return;
|
||
|
||
let cmdData;
|
||
try {
|
||
const res = await fetch(`${BASE_URL}/api/commands/pending`);
|
||
cmdData = await res.json();
|
||
} catch (e) {
|
||
log(`Poll failed: ${e}`);
|
||
return;
|
||
}
|
||
|
||
// Build queue and Recruit queue are now independent
|
||
const buildCmd = cmdData.build;
|
||
const recruitCmd = cmdData.recruit;
|
||
|
||
const execute = async (cmd) => {
|
||
if (!cmd) return;
|
||
log(`Executing command #${cmd.id} — type:${cmd.type} town:${cmd.town_id}`);
|
||
let result;
|
||
try {
|
||
if (cmd.type === 'build') result = await executeBuild(cmd);
|
||
else if (cmd.type === 'recruit') result = await executeRecruit(cmd);
|
||
else result = { ok: false, msg: `Unknown type: ${cmd.type}` };
|
||
} catch (e) {
|
||
result = { ok: false, msg: `Exception: ${e.message}` };
|
||
}
|
||
const finalStatus = result.requeue ? 'pending' : (result.ok ? 'done' : 'failed');
|
||
log(`Command #${cmd.id}: ${finalStatus === 'done' ? '✅' : finalStatus === 'pending' ? '⏳' : '❌'} ${result.msg}`);
|
||
reportResult(cmd.id, finalStatus, result.msg);
|
||
};
|
||
|
||
// Run both queues concurrently — they do NOT block each other
|
||
await Promise.all([execute(buildCmd), execute(recruitCmd)]);
|
||
}
|
||
|
||
// ----------------------------------------------------------------
|
||
// Boot
|
||
// ----------------------------------------------------------------
|
||
window.addEventListener('load', () => {
|
||
log('Grepolis Remote Control v2.5 loaded');
|
||
|
||
// Start captcha watcher immediately
|
||
detectCaptcha();
|
||
|
||
// Push state once after load, then every 45–90 seconds (randomized)
|
||
setTimeout(pushState, 5000);
|
||
jitterLoop(pushState, 45000, 90000);
|
||
|
||
// Poll for commands every 8–18 seconds (randomized jitter)
|
||
jitterLoop(pollAndExecute, 8000, 18000);
|
||
});
|
||
|
||
})();
|