Testi
// ==UserScript==
// @name Attack_Keeper
// @namespace tm-grepolis-attack-keeper
// @version 1.0.0
// @author Chepa
// @copyright 2026
// @description Testi
// @match https://*.grepolis.com/game/*
// @exclude forum.*.grepolis.*/*
// @exclude wiki.*.grepolis.*/*
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
"use strict";
const SCRIPT_ID = "tm-grepolis-attack-keeper";
const STORAGE_PREFIX = "tm_grepolis_attack_keeper_v1";
const STORAGE_STATE_KEY = `${STORAGE_PREFIX}:state`;
const STORAGE_LOCK_KEY = `${STORAGE_PREFIX}:lock`;
const CHANNEL_NAME = `${STORAGE_PREFIX}:channel`;
const HEARTBEAT_MS = 1000;
const LOCK_TTL_MS = 15000;
const AJAX_TIMEOUT_MS = 10000;
const COMMAND_TIMEOUT_MS = 5000;
const COMMAND_RESOLVE_TIMEOUT_MS = 15000;
const BINDINGS_WAIT_TIMEOUT_MS = 30000;
const BINDINGS_POLL_MS = 250;
const DEBUG_LOGGING = true;
const LOG_LIMIT = 100;
const ATTACK_INTERVAL_MS = 4 * 60 * 1000 + 30 * 1000;
const SHORT_RANGE_ATTACK_INTERVAL_MS = 3 * 60 * 1000;
const PRESSURE_WINDOW_MS = 5 * 60 * 1000;
const CANCELLATION_LOCK_MS = 10 * 60 * 1000;
const CANCELLATION_SAFETY_MS = 5000;
const PRE_CANCEL_SWITCH_MS = 5000;
const SHORT_RANGE_CANCEL_EARLIEST_MS = 60 * 1000;
const SHORT_RANGE_CANCEL_LATEST_MS = 30 * 1000;
const RETRY_AFTER_ERROR_MS = 15000;
const EXECUTION_JITTER_MS = 4000;
const HUMAN_DELAY_MIN_MS = 1000;
const HUMAN_DELAY_MAX_MS = 4000;
const REQUEST_NL_INIT = true;
const UNIT_INPUT_MIN = 0;
const UNIT_INPUT_MAX = 999999;
const OWNER_ID_SESSION_KEY = `${STORAGE_PREFIX}:owner_id`;
const SUPPORTED_ATTACK_TYPES = new Set(["attack"]);
const SERVER_TIME_SELECTOR = "#server_time_area, .server_time_area";
const STATIC_UNIT_ORDER = [
"sword",
"slinger",
"archer",
"hoplite",
"rider",
"chariot",
"catapult",
"minotaur",
"manticore",
"zyklop",
"cerberus",
"harpy",
"medusa",
"griffin",
"calydonian_boar",
"fury",
"pegasus",
"satyr",
"godsent",
"big_transporter",
"small_transporter",
"bireme",
"attack_ship",
"demolition_ship",
"trireme",
"colonize_ship",
"sea_monster",
"hydra",
"siren",
"spartoi",
"ladon",
];
const rootWindow = window;
if (!rootWindow || window.__tmGrepolisAttackKeeperLoaded) {
if (DEBUG_LOGGING) {
console.info(`[${SCRIPT_ID}] skipped bootstrap`, {
hasRootWindow: Boolean(rootWindow),
alreadyLoaded: Boolean(window.__tmGrepolisAttackKeeperLoaded),
});
}
return;
}
window.__tmGrepolisAttackKeeperLoaded = true;
const state = {
bindings: null,
ownerId: getOwnerId(),
serverClockOffsetMs: 0,
unitCatalog: [],
data: null,
ui: {
launcher: null,
wnd: null,
frame: null,
sourceTown: null,
targetTownId: null,
endAt: null,
serverTime: null,
status: null,
nextSend: null,
activeLabel: null,
unitsGrid: null,
unitsInputs: new Map(),
unitsAvailable: new Map(),
start: null,
stop: null,
banner: null,
log: null,
},
timers: {
heartbeat: null,
clock: null,
},
heartbeatBusy: false,
channel: null,
};
bootstrap().catch((error) => {
console.error(`[${SCRIPT_ID}] bootstrap failed`, error);
});
function debugLog(message, extra) {
if (!DEBUG_LOGGING) {
return;
}
if (extra === undefined) {
console.info(`[${SCRIPT_ID}] ${message}`);
return;
}
console.info(`[${SCRIPT_ID}] ${message}`, extra);
}
function debugWarn(message, extra) {
if (!DEBUG_LOGGING) {
return;
}
if (extra === undefined) {
console.warn(`[${SCRIPT_ID}] ${message}`);
return;
}
console.warn(`[${SCRIPT_ID}] ${message}`, extra);
}
function debugError(message, extra) {
if (!DEBUG_LOGGING) {
return;
}
if (extra === undefined) {
console.error(`[${SCRIPT_ID}] ${message}`);
return;
}
console.error(`[${SCRIPT_ID}] ${message}`, extra);
}
async function bootstrap() {
debugLog("bootstrap start", { href: location.href });
await waitForBindings();
state.bindings = collectGameBindings();
debugLog("bindings ready", {
townId: state.bindings.Game && state.bindings.Game.townId,
hasITowns: Boolean(state.bindings.ITowns),
hasGpAjax: Boolean(state.bindings.gpAjax),
hasLayout: Boolean(state.bindings.Layout),
});
state.serverClockOffsetMs = computeServerClockOffsetMs();
state.unitCatalog = buildUnitCatalog();
state.data = loadState();
ensureUi();
debugLog("ui injected", {
launcherPresent: Boolean(document.getElementById(`${SCRIPT_ID}-launcher`)),
unitCount: state.unitCatalog.length,
});
bindUi();
bindCrossTabEvents();
renderAll();
startClock();
await resumeRunIfNeeded();
debugLog("bootstrap complete");
}
function collectGameBindings() {
const bindings = {
window: rootWindow,
ITowns: rootWindow.ITowns,
GPWindowMgr: rootWindow.GPWindowMgr,
Timestamp: rootWindow.Timestamp,
gpAjax: rootWindow.gpAjax,
Game: rootWindow.Game,
GameData: rootWindow.GameData,
HelperTown: rootWindow.HelperTown,
Layout: rootWindow.Layout,
};
const missing = Object.entries(bindings)
.filter(([key, value]) => key !== "window" && !value)
.map(([key]) => key);
if (missing.length) {
throw new Error(`Missing Grepolis bindings: ${missing.join(", ")}`);
}
return bindings;
}
async function waitForBindings() {
const deadline = Date.now() + BINDINGS_WAIT_TIMEOUT_MS;
let lastError = null;
let lastReportAt = 0;
while (Date.now() < deadline) {
try {
collectGameBindings();
return;
} catch (error) {
lastError = error;
if (Date.now() - lastReportAt >= 5000) {
lastReportAt = Date.now();
debugWarn("waiting for Grepolis bindings", normalizeError(error));
}
await sleep(BINDINGS_POLL_MS);
}
}
debugError("bindings wait timed out", lastError);
throw lastError || new Error("Grepolis bindings were not exposed in time.");
}
function buildUnitCatalog() {
const gameUnits = state.bindings.GameData && state.bindings.GameData.units ? state.bindings.GameData.units : {};
const seen = new Set();
const keys = [];
STATIC_UNIT_ORDER.forEach((key) => {
if (gameUnits[key] && shouldShowUnit(key, gameUnits[key])) {
seen.add(key);
keys.push(key);
}
});
Object.keys(gameUnits)
.filter((key) => !seen.has(key) && shouldShowUnit(key, gameUnits[key]))
.sort((left, right) => getUnitLabel(left).localeCompare(getUnitLabel(right)))
.forEach((key) => keys.push(key));
return keys.map((key) => ({
key,
label: getUnitLabel(key),
category: getUnitCategory(gameUnits[key]),
}));
}
function shouldShowUnit(key, unitData) {
if (!unitData) {
return false;
}
if (key === "militia" || key === "colonize_ship") {
return false;
}
return true;
}
function getUnitCategory(unitData) {
if (!unitData) {
return "unit";
}
if (unitData.is_naval) {
return "naval";
}
if (unitData.is_mythical) {
return "mythical";
}
return "ground";
}
function getUnitLabel(key) {
const unitData = state.bindings.GameData.units && state.bindings.GameData.units[key];
if (unitData && typeof unitData.name === "string" && unitData.name.trim()) {
return unitData.name.trim();
}
return key
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function getOwnerId() {
try {
const cached = sessionStorage.getItem(OWNER_ID_SESSION_KEY);
if (cached) {
return cached;
}
const next = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
sessionStorage.setItem(OWNER_ID_SESSION_KEY, next);
return next;
} catch (_error) {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
}
function defaultState() {
return {
profile: {
sourceTownId: null,
targetTownId: null,
units: {},
endAt: null,
active: false,
},
runtime: {
active: false,
startedAt: null,
nextSendAt: null,
waveSequence: 0,
pendingCommands: [],
lastError: "",
ownerId: null,
},
recentLog: [],
};
}
function loadState() {
const fallback = defaultState();
const parsed = safeParseJson(localStorage.getItem(STORAGE_STATE_KEY));
if (!parsed || typeof parsed !== "object") {
return fallback;
}
const profile = normalizeProfile(parsed.profile || fallback.profile);
const runtime = normalizeRuntime(parsed.runtime || fallback.runtime);
const recentLog = Array.isArray(parsed.recentLog) ? parsed.recentLog.slice(-LOG_LIMIT).map(normalizeLogEntry) : [];
return {
profile,
runtime,
recentLog,
};
}
function normalizeProfile(profile) {
const normalizedUnits = {};
const rawUnits = profile && typeof profile.units === "object" ? profile.units : {};
Object.keys(rawUnits).forEach((key) => {
const value = toInteger(rawUnits[key], 0);
if (value > 0) {
normalizedUnits[key] = value;
}
});
return {
sourceTownId: toNullableInteger(profile.sourceTownId),
targetTownId: toNullableInteger(profile.targetTownId),
units: normalizedUnits,
endAt: Number.isFinite(profile.endAt) ? profile.endAt : null,
active: Boolean(profile.active),
};
}
function normalizeRuntime(runtime) {
const pendingCommands = Array.isArray(runtime.pendingCommands)
? runtime.pendingCommands.map(normalizePendingCommand).filter(Boolean)
: [];
return {
active: Boolean(runtime.active),
startedAt: Number.isFinite(runtime.startedAt) ? runtime.startedAt : null,
nextSendAt: Number.isFinite(runtime.nextSendAt) ? runtime.nextSendAt : null,
waveSequence: clamp(toInteger(runtime.waveSequence, 0), 0, 999999),
pendingCommands,
lastError: typeof runtime.lastError === "string" ? runtime.lastError : "",
ownerId: typeof runtime.ownerId === "string" ? runtime.ownerId : null,
};
}
function normalizePendingCommand(entry) {
if (!entry || typeof entry !== "object") {
return null;
}
const sourceTownId = toNullableInteger(entry.sourceTownId);
const targetTownId = toNullableInteger(entry.targetTownId);
const sentAt = Number.isFinite(entry.sentAt) ? entry.sentAt : null;
const commandId = toNullableInteger(entry.commandId);
if (!commandId && (!sourceTownId || !targetTownId || !sentAt)) {
return null;
}
return {
commandId,
sourceTownId,
targetTownId,
sentAt,
nextCycleAt: Number.isFinite(entry.nextCycleAt) ? entry.nextCycleAt : null,
plannedCancelAt: Number.isFinite(entry.plannedCancelAt) ? entry.plannedCancelAt : null,
arrivalAt: Number.isFinite(entry.arrivalAt) ? entry.arrivalAt : null,
travelDurationMs: Number.isFinite(entry.travelDurationMs) ? entry.travelDurationMs : null,
expectedType: typeof entry.expectedType === "string" ? entry.expectedType : "",
cancelBehavior: entry && entry.cancelBehavior === "land" ? "land" : "cancel",
};
}
function normalizeLogEntry(entry) {
return {
at: Number.isFinite(entry && entry.at) ? entry.at : nowServerMs(),
level: entry && entry.level === "error" ? "error" : "info",
message: entry && typeof entry.message === "string" ? entry.message : "",
};
}
function saveState() {
localStorage.setItem(STORAGE_STATE_KEY, JSON.stringify(state.data));
}
function buildUnitsGrid() {
state.ui.unitsGrid.innerHTML = "";
state.ui.unitsInputs.clear();
state.ui.unitsAvailable.clear();
state.unitCatalog.forEach((unit) => {
const row = document.createElement("label");
row.className = `${SCRIPT_ID}__unitRow`;
row.dataset.unitKey = unit.key;
row.title = unit.label;
const iconWrap = document.createElement("span");
iconWrap.className = `${SCRIPT_ID}__unitIconWrap`;
const iconFrame = document.createElement("span");
iconFrame.className = `${SCRIPT_ID}__unitIconFrame`;
const icon = document.createElement("div");
icon.className = getUnitIconClassName(unit.key);
const iconFallback = document.createElement("span");
iconFallback.className = `${SCRIPT_ID}__unitIconFallback`;
iconFallback.textContent = unit.label.slice(0, 2).toUpperCase();
iconFrame.appendChild(icon);
iconFrame.appendChild(iconFallback);
iconWrap.appendChild(iconFrame);
const meta = document.createElement("span");
meta.className = `${SCRIPT_ID}__unitMeta`;
const available = document.createElement("span");
available.className = `${SCRIPT_ID}__available`;
available.textContent = "0";
const input = document.createElement("input");
input.type = "number";
input.min = String(UNIT_INPUT_MIN);
input.max = String(UNIT_INPUT_MAX);
input.step = "1";
input.className = `${SCRIPT_ID}__unitInput`;
input.dataset.unitKey = unit.key;
row.appendChild(iconWrap);
meta.appendChild(available);
meta.appendChild(input);
row.appendChild(meta);
state.ui.unitsGrid.appendChild(row);
state.ui.unitsInputs.set(unit.key, input);
state.ui.unitsAvailable.set(unit.key, available);
});
}
function getUnitIconClassName(unitKey) {
return `unit index_unit bold unit_icon40x40 ${unitKey} ${SCRIPT_ID}__unitIcon`;
}
function isControlDisabled(element) {
return !element || element.classList.contains("disabled") || element.getAttribute("aria-disabled") === "true";
}
function setControlDisabled(element, disabled) {
if (!element) {
return;
}
element.classList.toggle("disabled", Boolean(disabled));
element.setAttribute("aria-disabled", disabled ? "true" : "false");
if (disabled) {
element.tabIndex = -1;
return;
}
element.tabIndex = 0;
}
function bindCrossTabEvents() {
if ("BroadcastChannel" in window) {
state.channel = new BroadcastChannel(CHANNEL_NAME);
state.channel.addEventListener("message", (event) => {
handleBroadcastMessage(event.data);
});
}
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_LOCK_KEY) {
handleForeignLockChange(event.newValue);
}
if (event.key === STORAGE_STATE_KEY && !state.data.runtime.active) {
state.data = loadState();
renderAll();
}
});
}
function clearWindowUiRefs() {
state.ui.wnd = null;
state.ui.frame = null;
state.ui.sourceTown = null;
state.ui.targetTownId = null;
state.ui.endAt = null;
state.ui.serverTime = null;
state.ui.status = null;
state.ui.nextSend = null;
state.ui.activeLabel = null;
state.ui.unitsGrid = null;
state.ui.start = null;
state.ui.stop = null;
state.ui.banner = null;
state.ui.log = null;
state.ui.unitsInputs.clear();
state.ui.unitsAvailable.clear();
}
function getWindowContentHtml() {
return `
<div class="${SCRIPT_ID}__native">
<div class="${SCRIPT_ID}__nativeHeader">
<h3 class="${SCRIPT_ID}__heading">Settings</h3>
<div class="${SCRIPT_ID}__serverTime" data-role="server-time"></div>
</div>
<div class="${SCRIPT_ID}__layout">
<div class="${SCRIPT_ID}__leftCol">
<label class="${SCRIPT_ID}__field">
<span>Ciudad origen</span>
<select data-role="source-town"></select>
</label>
<label class="${SCRIPT_ID}__field">
<span>ID ciudad objetivo</span>
<input data-role="target-town-id" type="number" min="1" step="1" />
</label>
<label class="${SCRIPT_ID}__field">
<span>Hasta (hora servidor)</span>
<input data-role="end-at" type="text" placeholder="YYYY-MM-DD HH:mm:ss" />
</label>
<hr class="${SCRIPT_ID}__rule" />
<div class="${SCRIPT_ID}__status">
<div><strong>Estado</strong><span data-role="active-label"></span></div>
<div><strong>Proximo envio</strong><span data-role="next-send"></span></div>
</div>
<div class="${SCRIPT_ID}__actions">
<div class="button_new" data-role="start" role="button" tabindex="0" aria-disabled="false">
<div class="left"></div>
<div class="right"></div>
<div class="caption js-caption">Iniciar<div class="effect js-effect"></div></div>
</div>
<div class="button_new" data-role="stop" role="button" tabindex="0" aria-disabled="false">
<div class="left"></div>
<div class="right"></div>
<div class="caption js-caption">Parar<div class="effect js-effect"></div></div>
</div>
</div>
<div class="${SCRIPT_ID}__logWrap">
<h4 class="${SCRIPT_ID}__subheading">Registro reciente</h4>
<div class="${SCRIPT_ID}__log" data-role="log"></div>
</div>
</div>
<div class="${SCRIPT_ID}__rightCol">
<div class="${SCRIPT_ID}__unitsHeader">
<div>Tropas</div>
<div>Disponible / envio exacto</div>
</div>
<div class="${SCRIPT_ID}__units" data-role="units-grid"></div>
</div>
</div>
</div>
`;
}
function getWindowFrame(titleText) {
const titles = Array.from(document.getElementsByClassName("ui-dialog-title"));
const title = titles.find((element) => element.textContent.trim() === titleText);
if (!title) {
return null;
}
return title.parentElement.parentElement.children[1].children[4] || null;
}
function mountWindowUi(wnd) {
const frame = getWindowFrame("Attack Keeper");
if (!frame) {
throw new Error("Could not find the Grepolis window frame.");
}
frame.innerHTML = getWindowContentHtml();
frame.classList.add(`${SCRIPT_ID}__frame`);
state.ui.wnd = wnd;
state.ui.frame = frame;
state.ui.sourceTown = frame.querySelector("[data-role='source-town']");
state.ui.targetTownId = frame.querySelector("[data-role='target-town-id']");
state.ui.endAt = frame.querySelector("[data-role='end-at']");
state.ui.serverTime = frame.querySelector("[data-role='server-time']");
state.ui.nextSend = frame.querySelector("[data-role='next-send']");
state.ui.activeLabel = frame.querySelector("[data-role='active-label']");
state.ui.unitsGrid = frame.querySelector("[data-role='units-grid']");
state.ui.start = frame.querySelector("[data-role='start']");
state.ui.stop = frame.querySelector("[data-role='stop']");
state.ui.log = frame.querySelector("[data-role='log']");
buildUnitsGrid();
bindWindowUi();
renderAll();
}
function bindWindowUi() {
state.ui.start.addEventListener("click", () => {
if (isControlDisabled(state.ui.start)) {
return;
}
void handleStart();
});
state.ui.stop.addEventListener("click", () => {
if (isControlDisabled(state.ui.stop)) {
return;
}
void handleStop();
});
[state.ui.start, state.ui.stop].forEach((control) => {
control.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
control.click();
});
});
state.ui.sourceTown.addEventListener("change", () => {
syncProfileFromUi();
renderAvailability();
});
[
state.ui.targetTownId,
state.ui.endAt,
].forEach((element) => {
element.addEventListener("input", syncProfileFromUi);
element.addEventListener("change", syncProfileFromUi);
});
state.ui.unitsInputs.forEach((input) => {
input.addEventListener("input", syncProfileFromUi);
input.addEventListener("change", syncProfileFromUi);
});
}
function ensureUi() {
injectStyles();
const existingLauncher = document.getElementById(`${SCRIPT_ID}-launcher`);
if (existingLauncher) {
state.ui.launcher = existingLauncher;
return;
}
const launcher = document.createElement("button");
launcher.type = "button";
launcher.id = `${SCRIPT_ID}-launcher`;
launcher.textContent = "AK";
document.body.appendChild(launcher);
state.ui.launcher = launcher;
}
function bindUi() {
if (!state.ui.launcher || state.ui.launcher.dataset.bound === "true") {
return;
}
state.ui.launcher.dataset.bound = "true";
state.ui.launcher.addEventListener("click", togglePanel);
}
function injectStyles() {
if (document.getElementById(`${SCRIPT_ID}-styles`)) {
return;
}
const style = document.createElement("style");
style.id = `${SCRIPT_ID}-styles`;
style.textContent = `
#${SCRIPT_ID}-launcher {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 2147483646;
width: 40px;
height: 40px;
border: 1px solid #7b5315;
background: linear-gradient(180deg, #f5cf72 0%, #b8791f 100%);
color: #3b2408;
font: 700 13px/1 Tahoma, Verdana, sans-serif;
border-radius: 6px;
box-shadow: inset 0 1px 0 rgba(255, 247, 202, 0.85), 0 3px 10px rgba(0, 0, 0, 0.35);
cursor: pointer;
}
.${SCRIPT_ID}__frame {
padding: 10px 12px 14px;
background: #f5deb0;
color: #000;
font: 12px/1.35 Arial, sans-serif;
}
.${SCRIPT_ID}__native {
min-height: 560px;
}
.${SCRIPT_ID}__nativeHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.${SCRIPT_ID}__heading,
.${SCRIPT_ID}__subheading {
margin: 0;
color: #000;
font-weight: 700;
}
.${SCRIPT_ID}__heading {
font-size: 18px;
}
.${SCRIPT_ID}__subheading {
font-size: 13px;
}
.${SCRIPT_ID}__serverTime {
color: #5b4921;
font-size: 12px;
white-space: nowrap;
}
.${SCRIPT_ID}__layout {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 18px;
}
.${SCRIPT_ID}__leftCol,
.${SCRIPT_ID}__rightCol {
min-width: 0;
}
.${SCRIPT_ID}__rule {
margin: 14px 0;
border: 0;
border-top: 1px solid #bfb2a0;
}
.${SCRIPT_ID}__field {
display: block;
margin-bottom: 12px;
}
.${SCRIPT_ID}__field > span {
display: block;
margin-bottom: 5px;
color: #000;
font-weight: 700;
text-align: left;
}
.${SCRIPT_ID}__field input,
.${SCRIPT_ID}__field select,
.${SCRIPT_ID}__unitInput {
width: 100%;
box-sizing: border-box;
height: 28px;
padding: 4px 6px;
border: 1px solid #7b6a45;
border-radius: 0;
background: #f8edc7;
color: #000;
font: 12px/1.2 Arial, sans-serif;
box-shadow: inset 0 0 0 2px #d8c79b;
}
.${SCRIPT_ID}__status {
margin: 0 0 12px;
color: #000;
}
.${SCRIPT_ID}__status > div {
display: flex;
justify-content: space-between;
gap: 10px;
color: #000;
}
.${SCRIPT_ID}__status > div + div {
margin-top: 4px;
}
.${SCRIPT_ID}__actions {
display: flex;
gap: 8px;
align-items: center;
margin-top: 6px;
}
.${SCRIPT_ID}__actions .button_new {
margin: 0;
cursor: pointer;
}
.${SCRIPT_ID}__actions .button_new .caption {
min-width: 74px;
text-align: center;
font-weight: 700;
}
.${SCRIPT_ID}__actions .button_new.disabled {
opacity: 0.5;
cursor: default;
pointer-events: none;
}
.${SCRIPT_ID}__unitsHeader {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
color: #000;
font-weight: 700;
}
.${SCRIPT_ID}__units {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px 12px;
}
.${SCRIPT_ID}__unitRow {
display: grid;
grid-template-columns: 44px 148px;
gap: 10px;
align-items: center;
min-height: 44px;
padding: 2px 0;
border: 0;
border-radius: 0;
background: transparent;
}
.${SCRIPT_ID}__unitIconWrap {
display: flex;
align-items: center;
justify-content: center;
}
.${SCRIPT_ID}__unitIconFrame {
position: relative;
width: 44px;
height: 44px;
overflow: hidden;
}
.${SCRIPT_ID}__unitIcon {
position: absolute;
top: 1px;
left: 1px;
display: block;
width: 40px;
height: 40px;
z-index: 2;
margin: 0;
float: none;
overflow: hidden;
}
.${SCRIPT_ID}__unitIconFallback {
display: none;
}
.${SCRIPT_ID}__unitMeta {
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
gap: 6px;
align-items: center;
}
.${SCRIPT_ID}__available {
color: #000;
text-align: right;
font-weight: 700;
}
.${SCRIPT_ID}__unitRow--invalid {
background: transparent;
}
.${SCRIPT_ID}__unitRow--invalid .${SCRIPT_ID}__unitInput {
border-color: #b0482a;
background: #efc3b5;
box-shadow: inset 0 0 0 2px rgba(176, 72, 42, 0.2);
}
.${SCRIPT_ID}__logWrap {
margin-top: 14px;
}
.${SCRIPT_ID}__log {
min-height: 124px;
max-height: 200px;
overflow: auto;
border: 1px solid #c8a55b;
border-radius: 0;
background: rgba(250, 239, 204, 0.75);
}
.${SCRIPT_ID}__logEntry {
display: grid;
grid-template-columns: 132px 1fr;
gap: 10px;
padding: 7px 9px;
border-top: 1px solid rgba(123, 106, 69, 0.35);
color: #000;
}
.${SCRIPT_ID}__logEntry:first-child {
border-top: 0;
}
.${SCRIPT_ID}__logEntry[data-level="error"] {
background: rgba(198, 113, 83, 0.25);
}
.${SCRIPT_ID}__logAt {
color: #4d3a17;
font-weight: 700;
}
.${SCRIPT_ID}__logEmpty {
padding: 9px;
color: #5b4921;
text-align: center;
}
@media (max-width: 860px) {
.${SCRIPT_ID}__layout {
grid-template-columns: 1fr;
}
.${SCRIPT_ID}__units {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.${SCRIPT_ID}__units {
grid-template-columns: 1fr;
}
}
`;
document.head.appendChild(style);
}
function togglePanel() {
if (state.ui.frame && document.body.contains(state.ui.frame)) {
hidePanel();
return;
}
showPanel();
}
function showPanel() {
if (state.ui.frame && document.body.contains(state.ui.frame)) {
renderAll();
return;
}
const wnd = state.bindings.Layout.wnd.Create(state.bindings.Layout.wnd.TYPE_DIALOG, "Attack Keeper");
wnd.setContent("");
wnd.setTitle("Attack Keeper");
wnd.setWidth("1080");
wnd.setHeight(String(Math.max(620, Math.min(window.innerHeight - 120, 760))));
mountWindowUi(wnd);
}
function hidePanel() {
if (state.ui.wnd && typeof state.ui.wnd.close === "function") {
state.ui.wnd.close();
}
clearWindowUiRefs();
}
function startClock() {
if (state.timers.clock) {
return;
}
state.timers.clock = window.setInterval(() => {
renderServerTime();
renderStatus();
}, 1000);
renderServerTime();
}
function renderAll() {
if (!state.ui.frame || !document.body.contains(state.ui.frame)) {
return;
}
renderTownOptions();
populateForm();
renderAvailability();
renderStatus();
renderLog();
renderServerTime();
renderBanner();
}
function renderTownOptions() {
if (!state.ui.sourceTown) {
return;
}
const towns = getOwnTowns();
const currentValue = state.ui.sourceTown.value;
state.ui.sourceTown.innerHTML = "";
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "Selecciona una ciudad";
state.ui.sourceTown.appendChild(placeholder);
towns.forEach((town) => {
const option = document.createElement("option");
option.value = String(town.id);
option.textContent = `${town.name} (#${town.id})`;
state.ui.sourceTown.appendChild(option);
});
const preferred = state.data.profile.sourceTownId ? String(state.data.profile.sourceTownId) : currentValue;
if (preferred && state.ui.sourceTown.querySelector(`option[value="${preferred}"]`)) {
state.ui.sourceTown.value = preferred;
}
}
function populateForm() {
if (!state.ui.sourceTown) {
return;
}
const profile = state.data.profile;
state.ui.sourceTown.value = profile.sourceTownId ? String(profile.sourceTownId) : "";
state.ui.targetTownId.value = profile.targetTownId ? String(profile.targetTownId) : "";
state.ui.endAt.value = profile.endAt ? formatServerDateTime(profile.endAt) : "";
state.ui.unitsInputs.forEach((input, unitKey) => {
input.value = profile.units[unitKey] ? String(profile.units[unitKey]) : "";
});
}
function renderAvailability() {
if (!state.ui.sourceTown) {
return;
}
const sourceTownId = toNullableInteger(state.ui.sourceTown.value) || state.data.profile.sourceTownId;
const town = sourceTownId ? getTown(sourceTownId) : null;
const availableUnits = town && typeof town.units === "function" ? town.units() : {};
state.ui.unitsInputs.forEach((input, unitKey) => {
const available = toInteger(availableUnits[unitKey], 0);
const configured = toInteger(input.value, 0);
const row = input.closest(`.${SCRIPT_ID}__unitRow`);
const label = state.ui.unitsAvailable.get(unitKey);
if (label) {
label.textContent = String(available);
}
if (row) {
row.classList.toggle(`${SCRIPT_ID}__unitRow--invalid`, configured > available);
}
});
}
function renderStatus() {
if (!state.ui.activeLabel || !state.ui.nextSend) {
return;
}
const runtime = state.data.runtime;
state.ui.activeLabel.textContent = runtime.active ? "Activo" : "Inactivo";
state.ui.nextSend.textContent = runtime.nextSendAt && runtime.active
? formatServerDateTime(runtime.nextSendAt)
: "Sin programar";
setControlDisabled(state.ui.start, runtime.active);
setControlDisabled(state.ui.stop, !runtime.active && state.data.runtime.pendingCommands.length === 0);
}
function renderLog() {
if (!state.ui.log) {
return;
}
const entries = state.data.recentLog.slice().reverse();
state.ui.log.innerHTML = "";
if (!entries.length) {
const empty = document.createElement("div");
empty.className = `${SCRIPT_ID}__logEmpty`;
empty.textContent = "Sin eventos todavia.";
state.ui.log.appendChild(empty);
return;
}
entries.forEach((entry) => {
const row = document.createElement("div");
row.className = `${SCRIPT_ID}__logEntry`;
row.dataset.level = entry.level;
const at = document.createElement("div");
at.className = `${SCRIPT_ID}__logAt`;
at.textContent = formatServerDateTime(entry.at);
const message = document.createElement("div");
message.textContent = entry.message;
row.appendChild(at);
row.appendChild(message);
state.ui.log.appendChild(row);
});
}
function renderServerTime() {
if (!state.ui.serverTime) {
return;
}
state.ui.serverTime.textContent = `${formatServerDateTime(nowServerMs())}`;
}
function renderBanner() {
return;
}
function syncProfileFromUi() {
state.data.profile = normalizeProfile({
sourceTownId: toNullableInteger(state.ui.sourceTown.value),
targetTownId: toNullableInteger(state.ui.targetTownId.value),
endAt: parseServerDateTimeInput(state.ui.endAt.value),
active: state.data.runtime.active,
units: collectUnitsFromUi(),
});
saveState();
renderAvailability();
renderStatus();
}
function collectUnitsFromUi() {
const units = {};
state.ui.unitsInputs.forEach((input, key) => {
const value = clamp(toInteger(input.value, 0), UNIT_INPUT_MIN, UNIT_INPUT_MAX);
if (value > 0) {
units[key] = value;
}
});
return units;
}
async function handleStart() {
try {
debugLog("start requested");
syncProfileFromUi();
await prepareStart();
} catch (error) {
debugError("start failed", error);
await stopWithAlert(normalizeError(error), { preservePending: true, cancelPending: false });
}
}
async function prepareStart() {
ensureBindingsHealthy();
reconcileInactivePendingCommands();
if (!acquireTabLock()) {
throw new Error("Another Grepolis tab currently owns the attack lock.");
}
const profile = state.data.profile;
validateProfile(profile);
validateAvailableUnits(profile);
const now = nowServerMs();
state.data.profile.active = true;
state.data.runtime.active = true;
state.data.runtime.startedAt = now;
state.data.runtime.nextSendAt = null;
state.data.runtime.ownerId = state.ownerId;
state.data.runtime.lastError = "";
appendLog("info", `Run started from ${resolveTownLabel(profile.sourceTownId)} to Town #${profile.targetTownId}.`);
const firstSent = await sendAttack(profile, now);
const firstNextSendAt = computeNextSendAt(firstSent.sentAt, firstSent.travelDurationMs);
const firstEntry = createPendingCommandEntry(state.data.runtime, profile, firstSent, firstNextSendAt);
state.data.runtime.pendingCommands.push(firstEntry);
state.data.runtime.nextSendAt = firstNextSendAt;
appendLog("info", buildSendLogMessage(firstSent, firstEntry));
saveState();
renderAll();
startHeartbeat();
}
async function handleStop() {
const reason = "Stopped by user.";
debugLog("stop requested");
appendLog("info", reason);
await stopRun(reason, {
alert: false,
cancelPending: true,
preservePending: false,
keepError: false,
});
}
function startHeartbeat() {
if (state.timers.heartbeat) {
return;
}
state.timers.heartbeat = window.setInterval(() => {
void heartbeatTick();
}, HEARTBEAT_MS);
void heartbeatTick();
}
function stopHeartbeat() {
if (state.timers.heartbeat) {
window.clearInterval(state.timers.heartbeat);
state.timers.heartbeat = null;
}
}
async function heartbeatTick() {
if (state.heartbeatBusy || !state.data.runtime.active) {
return;
}
state.heartbeatBusy = true;
try {
ensureBindingsHealthy();
if (!refreshTabLock()) {
throw new Error("Lock lost to another Grepolis tab.");
}
await reconcilePendingCommands();
await ensureSourceTownForDueCancellation();
await cancelDueCommands();
await maybeSendNextAttack();
await finalizeIfFinished();
saveState();
renderAll();
} catch (error) {
debugError("heartbeat failed", error);
const reason = normalizeError(error);
if (isRecoverableRuntimeError(reason)) {
await handleRecoverableRuntimeError(reason);
} else {
await stopWithAlert(reason, { preservePending: true, cancelPending: false });
}
} finally {
state.heartbeatBusy = false;
}
}
async function finalizeIfFinished() {
const now = nowServerMs();
if (!state.data.runtime.active) {
return;
}
if (state.data.profile.endAt && now >= state.data.profile.endAt && state.data.runtime.pendingCommands.length === 0) {
appendLog("info", "Cutoff reached. No pending commands remain.");
await stopRun("", {
alert: false,
cancelPending: false,
preservePending: false,
keepError: false,
});
}
}
async function maybeSendNextAttack() {
const runtime = state.data.runtime;
const profile = state.data.profile;
if (!runtime.active || !runtime.nextSendAt || !profile.endAt) {
return;
}
const now = nowServerMs();
if (runtime.nextSendAt >= profile.endAt) {
return;
}
if (runtime.nextSendAt > now) {
return;
}
const cycleAt = runtime.nextSendAt;
const sent = await sendAttack(profile, cycleAt);
const nextSendAt = computeNextSendAt(sent.sentAt, sent.travelDurationMs);
const entry = createPendingCommandEntry(runtime, profile, sent, nextSendAt);
runtime.pendingCommands.push(entry);
runtime.nextSendAt = nextSendAt;
appendLog("info", buildSendLogMessage(sent, entry));
}
async function sendAttack(profile, cycleAt) {
await switchToTownIfNeeded(profile.sourceTownId);
validateProfile(profile);
validateAvailableUnits(profile);
const beforeSnapshot = snapshotOutgoingMovements();
const attackData = await openAttackWindow(profile);
const travelDurationMs = extractTravelDurationMs(attackData);
const payload = buildAttackPayload(profile, attackData);
const sentAt = nowServerMs();
appendLog(
"info",
`Dispatching attack from ${resolveTownLabel(profile.sourceTownId)} to Town #${profile.targetTownId} for cycle ${formatServerDateTime(cycleAt)}.`
);
debugLog("dispatching attack", {
sourceTownId: profile.sourceTownId,
targetTownId: profile.targetTownId,
cycleAt,
payload,
});
let sendCallbackTimedOut = false;
let response = null;
try {
await humanDelay("before send_units");
response = await ajaxPost("town_info", "send_units", payload);
if (response && response.success === false) {
throw new Error(extractAjaxMessage(response) || "send_units returned success=false.");
}
} catch (error) {
const message = normalizeError(error);
if (message === "Request timed out: town_info/send_units") {
sendCallbackTimedOut = true;
appendLog("info", "send_units timed out waiting for callback. Checking outgoing movements.");
} else {
throw error;
}
}
const createdFromResponse = extractCreatedCommandFromResponse(response, {
sourceTownId: profile.sourceTownId,
targetTownId: profile.targetTownId,
sentAt,
travelDurationMs,
beforeSnapshot,
});
if (createdFromResponse) {
appendLog("info", `Resolved command ${createdFromResponse.commandId} directly from send_units response.`);
return {
commandId: createdFromResponse.commandId,
sentAt,
arrivalAt: createdFromResponse.arrivalAt || (travelDurationMs ? sentAt + travelDurationMs : null),
travelDurationMs,
expectedType: createdFromResponse.type || "attack",
};
}
try {
const created = await resolveCreatedCommand({
beforeSnapshot,
sentAt,
sourceTownId: profile.sourceTownId,
targetTownId: profile.targetTownId,
}, sendCallbackTimedOut ? COMMAND_RESOLVE_TIMEOUT_MS + 10000 : COMMAND_RESOLVE_TIMEOUT_MS);
if (sendCallbackTimedOut) {
appendLog("info", `send_units callback timed out, but command ${created.commandId} was detected in movements.`);
}
return {
commandId: created.commandId,
sentAt,
arrivalAt: created.arrivalAtMs || (travelDurationMs ? sentAt + travelDurationMs : null),
travelDurationMs: created.arrivalAtMs ? Math.max(0, created.arrivalAtMs - sentAt) : travelDurationMs,
expectedType: created.type,
};
} catch (error) {
const resolutionMessage = normalizeError(error);
const responseMessage = extractAjaxMessage(response);
const details = [
"Attack dispatch could not be confirmed.",
responseMessage ? `Server message: ${responseMessage}.` : "",
resolutionMessage ? `Resolver message: ${resolutionMessage}.` : "",
`Source ${resolveTownLabel(profile.sourceTownId)} -> Town #${profile.targetTownId}.`,
].filter(Boolean);
throw new Error(details.join(" "));
}
}
async function openAttackWindow(profile) {
await humanDelay("before attack window");
const response = await ajaxGet("town_info", "attack", {
id: profile.targetTownId,
town_id: profile.sourceTownId,
nl_init: REQUEST_NL_INIT,
});
if (!response || !response.json) {
throw new Error("Attack window payload was empty.");
}
const data = response.json;
const responseMessage = extractAjaxMessage(response) || extractAjaxMessage(data);
if (response.success === false || data.success === false) {
throw new Error(responseMessage || "Attack window request failed.");
}
if (data.controller_type !== "town_info") {
throw new Error(responseMessage || `Unexpected attack controller: ${data.controller_type || "unknown"}`);
}
if (!SUPPORTED_ATTACK_TYPES.has(data.type)) {
throw new Error(responseMessage || `Unsupported attack type: ${data.type || "unknown"}`);
}
if (toNullableInteger(data.target_id) !== profile.targetTownId) {
throw new Error(responseMessage || "Attack window target does not match the configured target.");
}
return data;
}
function buildAttackPayload(profile, attackData) {
const payload = {
id: profile.targetTownId,
type: "attack",
town_id: profile.sourceTownId,
nl_init: REQUEST_NL_INIT,
};
const configuredUnits = profile.units;
const availableUnits = attackData.units || {};
let totalUnits = 0;
Object.keys(configuredUnits).forEach((unitKey) => {
const requested = configuredUnits[unitKey];
const availableRecord = availableUnits[unitKey];
const available = availableRecord && Number.isFinite(availableRecord.count) ? availableRecord.count : toInteger(availableRecord, 0);
if (requested > 0) {
if (available < requested) {
throw new Error(`Unit ${getUnitLabel(unitKey)} is no longer available in the requested amount.`);
}
payload[unitKey] = requested;
totalUnits += requested;
}
});
if (!totalUnits) {
throw new Error("At least one unit must be configured.");
}
if (Array.isArray(attackData.strategies) && attackData.strategies.length) {
payload.attacking_strategy = attackData.strategies.slice();
}
if (attackData.spell && attackData.spell !== "no_power") {
payload.power_id = attackData.spell;
}
return payload;
}
async function resolveCreatedCommand(context, timeoutMs) {
const deadline = Date.now() + (Number.isFinite(timeoutMs) ? timeoutMs : COMMAND_TIMEOUT_MS);
while (Date.now() < deadline) {
const afterSnapshot = snapshotOutgoingMovements();
const matches = afterSnapshot.movements
.filter((movement) => isResolvableAttackMovement(movement))
.filter((movement) => !context.beforeSnapshot.sourceRefs.has(movement.sourceRef))
.map((movement) => ({
movement,
score: scoreMovementCandidate(movement, context),
}))
.filter((entry) => entry.score > 0)
.sort((left, right) => right.score - left.score)
.map((entry) => entry.movement);
if (matches.length) {
return matches[0];
}
await sleep(250);
}
throw new Error("Could not resolve the created command from the command toolbar.");
}
function extractCreatedCommandFromResponse(response, context) {
const candidates = [];
function visit(value, path) {
if (!value || typeof value !== "object") {
return;
}
if (Array.isArray(value)) {
value.forEach((entry, index) => visit(entry, `${path}[${index}]`));
return;
}
const commandId = toNullableInteger(
value.command_id !== undefined ? value.command_id :
value.commandId !== undefined ? value.commandId :
null
);
const targetTownId = toNullableInteger(
value.target_town_id !== undefined ? value.target_town_id :
value.targetTownId !== undefined ? value.targetTownId :
value.to_town_id !== undefined ? value.to_town_id :
value.destination_town_id !== undefined ? value.destination_town_id :
null
);
const sourceTownId = toNullableInteger(
value.home_town_id !== undefined ? value.home_town_id :
value.homeTownId !== undefined ? value.homeTownId :
value.from_town_id !== undefined ? value.from_town_id :
value.town_id !== undefined ? value.town_id :
null
);
const type = String(
value.type !== undefined ? value.type :
value.command_type !== undefined ? value.command_type :
value.movement_type !== undefined ? value.movement_type :
""
);
const arrivalAt = toNullableInteger(
value.arrival_at !== undefined ? value.arrival_at :
value.arrivalAt !== undefined ? value.arrivalAt :
value.arrival_timestamp !== undefined ? value.arrival_timestamp :
value.destination_time !== undefined ? value.destination_time :
null
);
if (commandId) {
let score = 10;
if (targetTownId && targetTownId === context.targetTownId) {
score += 4;
}
if (sourceTownId && sourceTownId === context.sourceTownId) {
score += 4;
}
if (isAttackMovementType(type)) {
score += 2;
}
candidates.push({
commandId,
targetTownId,
sourceTownId,
type,
arrivalAt: arrivalAt && arrivalAt < 1000000000000 ? arrivalAt * 1000 : arrivalAt,
score,
path,
});
}
Object.keys(value).forEach((key) => visit(value[key], path ? `${path}.${key}` : key));
}
visit(response, "");
if (!candidates.length) {
return null;
}
const plausibleCandidates = candidates.filter((candidate) => isPlausibleCreatedCommandCandidate(candidate, context));
plausibleCandidates.sort((left, right) => right.score - left.score);
debugLog("send_units response command candidates", candidates);
debugLog("send_units plausible command candidates", plausibleCandidates);
return plausibleCandidates.length ? plausibleCandidates[0] : null;
}
function isPlausibleCreatedCommandCandidate(candidate, context) {
if (!candidate || !context) {
return false;
}
if (candidate.commandId && context.beforeSnapshot && context.beforeSnapshot.commandIds.has(candidate.commandId)) {
return false;
}
const trackedCommandIds = new Set(
state.data.runtime.pendingCommands
.map((entry) => entry.commandId)
.filter(Boolean)
);
if (candidate.commandId && trackedCommandIds.has(candidate.commandId)) {
return false;
}
if (candidate.targetTownId && context.targetTownId && candidate.targetTownId !== context.targetTownId) {
return false;
}
if (candidate.sourceTownId && context.sourceTownId && candidate.sourceTownId !== context.sourceTownId) {
return false;
}
if (candidate.type && !isAttackMovementType(candidate.type)) {
return false;
}
if (Number.isFinite(candidate.arrivalAt) && Number.isFinite(context.sentAt) && Number.isFinite(context.travelDurationMs)) {
const expectedArrivalAt = context.sentAt + context.travelDurationMs;
const deltaMs = Math.abs(candidate.arrivalAt - expectedArrivalAt);
if (deltaMs > 120000) {
return false;
}
}
return true;
}
function extractTravelDurationMs(value) {
const directMs = normalizeDurationCandidate(
readNestedValue(value, [
["duration"],
["travel_duration"],
["travelDuration"],
["travel_time"],
["travelTime"],
["time_to_target"],
["timeToTarget"],
["command", "duration"],
["json", "duration"],
])
);
if (directMs) {
return directMs;
}
let best = null;
visitObject(value, (entry, path) => {
const pathText = path.join(".").toLowerCase();
if (!pathText) {
return;
}
if (
pathText.includes("duration") ||
pathText.includes("travel") ||
pathText.includes("runtime") ||
pathText.includes("time_to_target")
) {
const candidate = normalizeDurationCandidate(entry);
if (candidate && (!best || candidate < best)) {
best = candidate;
}
}
});
return best;
}
async function cancelDueCommands() {
const now = nowServerMs();
const due = state.data.runtime.pendingCommands
.filter((entry) => entry.plannedCancelAt && entry.plannedCancelAt <= now)
.sort((left, right) => left.plannedCancelAt - right.plannedCancelAt);
for (const entry of due) {
if (!entry.commandId) {
const resolved = resolvePendingCommandEntry(entry);
if (resolved) {
entry.commandId = resolved.commandId;
entry.arrivalAt = resolved.arrivalAtMs || entry.arrivalAt;
entry.travelDurationMs = entry.arrivalAt && entry.sentAt ? entry.arrivalAt - entry.sentAt : entry.travelDurationMs;
}
}
if (!entry.commandId) {
entry.plannedCancelAt = null;
appendLog("info", `Command from ${formatServerDateTime(entry.sentAt)} could not be resolved in time. It will be allowed to land.`);
continue;
}
await cancelTrackedCommand(entry);
}
}
async function ensureSourceTownForDueCancellation() {
const runtime = state.data.runtime;
if (!runtime || !Array.isArray(runtime.pendingCommands) || !runtime.pendingCommands.length) {
return;
}
const now = nowServerMs();
const imminent = runtime.pendingCommands
.filter((entry) => Number.isFinite(entry.plannedCancelAt))
.filter((entry) => entry.plannedCancelAt - now <= PRE_CANCEL_SWITCH_MS)
.sort((left, right) => left.plannedCancelAt - right.plannedCancelAt)[0];
if (!imminent || !imminent.sourceTownId) {
return;
}
if (toNullableInteger(state.bindings.Game.townId) === imminent.sourceTownId) {
return;
}
await switchToTownIfNeeded(imminent.sourceTownId, { humanDelayEnabled: false });
}
async function cancelTrackedCommand(entry) {
const live = findMovementByCommandId(entry.commandId);
if (!live) {
appendLog("info", `Command ${entry.commandId} is no longer visible in the client. Attempting direct cancellation from the saved source town.`);
}
appendLog("info", `Cancelling command ${entry.commandId}.`);
try {
await cancelCommand(entry);
removePendingCommand(entry.commandId);
appendLog("info", `Command ${entry.commandId} cancelled.`);
} catch (error) {
const reason = normalizeError(error);
if (isMissingCommandCancellationError(reason) || (entry.arrivalAt && nowServerMs() >= entry.arrivalAt)) {
removePendingCommand(entry.commandId);
appendLog("info", `Command ${entry.commandId} could not be cancelled because it is no longer available. It was dropped from tracking.`);
return;
}
throw error;
}
}
async function cancelCommand(entry) {
const sourceTownId = entry.sourceTownId || state.data.profile.sourceTownId;
await ajaxPost("command_info", "cancel_command", {
id: entry.commandId,
town_id: sourceTownId,
});
const gone = await waitForCommandToDisappear(entry.commandId, sourceTownId, COMMAND_TIMEOUT_MS);
if (!gone) {
throw new Error(`Command ${entry.commandId} did not disappear after cancellation.`);
}
}
async function waitForCommandToDisappear(commandId, townId, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const stillExists = await commandExistsInOverview(commandId, townId);
if (!stillExists) {
return true;
}
await sleep(250);
}
return false;
}
async function commandExistsInOverview(commandId, townId) {
if (!commandId || !townId) {
return false;
}
const response = await ajaxGet("town_overviews", "command_overview", {
town_id: townId,
nl_init: REQUEST_NL_INIT,
});
return responseContainsCommandId(response, commandId);
}
function responseContainsCommandId(response, commandId) {
const needle = String(commandId);
let found = false;
visitObject(response, (entry) => {
if (found || entry === null || entry === undefined) {
return;
}
if (typeof entry === "number" && String(entry) === needle) {
found = true;
return;
}
if (typeof entry === "string" && entry.includes(needle)) {
found = true;
}
});
return found;
}
async function reconcilePendingCommands() {
const nextPending = [];
for (const entry of state.data.runtime.pendingCommands) {
if (!entry.commandId) {
const resolved = resolvePendingCommandEntry(entry);
if (resolved) {
entry.commandId = resolved.commandId;
entry.arrivalAt = resolved.arrivalAtMs || entry.arrivalAt;
entry.travelDurationMs = entry.arrivalAt && entry.sentAt ? entry.arrivalAt - entry.sentAt : entry.travelDurationMs;
entry.expectedType = resolved.type || entry.expectedType;
if (entry.plannedCancelAt === null && entry.cancelBehavior !== "land") {
entry.plannedCancelAt = computePlannedCancelAt(entry.sentAt, entry.arrivalAt, entry.travelDurationMs, entry.cancelBehavior);
}
appendLog("info", `Resolved pending attack ${entry.commandId} after dispatch.`);
}
}
const live = entry.commandId ? findMovementByCommandId(entry.commandId) : null;
if (live) {
entry.arrivalAt = live.arrivalAtMs || entry.arrivalAt;
entry.travelDurationMs = entry.arrivalAt && entry.sentAt ? entry.arrivalAt - entry.sentAt : entry.travelDurationMs;
nextPending.push(entry);
} else {
if (!entry.commandId && entry.arrivalAt && nowServerMs() < entry.arrivalAt) {
nextPending.push(entry);
} else if (entry.commandId && shouldKeepMissingTrackedCommand(entry)) {
nextPending.push(entry);
} else if (!entry.commandId && entry.arrivalAt && nowServerMs() >= entry.arrivalAt) {
appendLog("info", `Unresolved attack from ${formatServerDateTime(entry.sentAt)} has already landed.`);
} else {
appendLog("info", describeMissingTrackedCommand(entry));
}
}
}
state.data.runtime.pendingCommands = nextPending;
}
function describeMissingTrackedCommand(entry) {
if (!entry || !entry.commandId) {
return "Tracked command is no longer present and was dropped from tracking.";
}
const modelMovement = findMovementModelByCommandId(entry.commandId);
if (modelMovement) {
const type = String(
typeof modelMovement.getType === "function"
? modelMovement.getType()
: modelMovement.attributes && modelMovement.attributes.type
).toLowerCase();
if (type.includes("abort")) {
return `Command ${entry.commandId} was cancelled and is now returning.`;
}
if (type.includes("return")) {
return `Command ${entry.commandId} is returning and was dropped from active tracking.`;
}
return `Command ${entry.commandId} changed state to ${type || "unknown"} and was dropped from active tracking.`;
}
if (entry.arrivalAt && nowServerMs() >= entry.arrivalAt) {
return `Command ${entry.commandId} has already landed.`;
}
return `Command ${entry.commandId} is missing from the client models and was dropped from tracking.`;
}
function shouldKeepMissingTrackedCommand(entry) {
if (!entry || !entry.commandId) {
return false;
}
const now = nowServerMs();
if (Number.isFinite(entry.plannedCancelAt) && entry.plannedCancelAt > now) {
return true;
}
if (Number.isFinite(entry.arrivalAt) && entry.arrivalAt > now) {
return true;
}
return false;
}
function reconcileInactivePendingCommands() {
if (state.data.runtime.active) {
return;
}
const stillLive = state.data.runtime.pendingCommands.filter((entry) => findMovementByCommandId(entry.commandId));
if (stillLive.length) {
throw new Error("Tracked live commands from a previous run are still active. Clear them before starting again.");
}
if (state.data.runtime.pendingCommands.length) {
state.data.runtime.pendingCommands = [];
saveState();
}
}
async function resumeRunIfNeeded() {
if (!state.data.runtime.active) {
return;
}
if (state.data.runtime.ownerId && state.data.runtime.ownerId !== state.ownerId) {
const currentLock = getStoredLock();
if (currentLock && !isLockExpired(currentLock) && currentLock.ownerId !== state.ownerId) {
appendLog("info", "Run is active in another tab. This tab will remain passive.");
state.data.runtime.active = false;
state.data.profile.active = false;
saveState();
renderAll();
return;
}
}
if (!acquireTabLock()) {
appendLog("info", "Could not reacquire the lock after reload.");
state.data.runtime.active = false;
state.data.profile.active = false;
saveState();
renderAll();
return;
}
appendLog("info", "Run resumed after page reload.");
await reconcilePendingCommands();
saveState();
renderAll();
startHeartbeat();
}
async function stopWithAlert(reason, options) {
debugError("stopWithAlert", reason);
appendLog("error", reason);
await stopRun(reason, {
alert: true,
cancelPending: false,
preservePending: options && options.preservePending !== undefined ? options.preservePending : true,
keepError: true,
});
}
async function handleRecoverableRuntimeError(reason) {
const runtime = state.data.runtime;
const now = nowServerMs();
runtime.lastError = reason;
if (!Number.isFinite(runtime.nextSendAt) || runtime.nextSendAt <= now) {
runtime.nextSendAt = now + RETRY_AFTER_ERROR_MS;
}
appendLog("error", `${reason} Retrying at ${formatServerDateTime(runtime.nextSendAt)}.`);
saveState();
renderAll();
}
async function stopRun(reason, options) {
const settings = Object.assign(
{
alert: false,
cancelPending: false,
preservePending: true,
keepError: false,
},
options || {}
);
stopHeartbeat();
if (settings.cancelPending) {
const pendingCopy = state.data.runtime.pendingCommands.slice();
for (const entry of pendingCopy) {
try {
if (!entry.commandId) {
const resolved = resolvePendingCommandEntry(entry);
if (resolved) {
entry.commandId = resolved.commandId;
}
}
if (!entry.commandId) {
appendLog("info", `Pending attack from ${formatServerDateTime(entry.sentAt)} could not be resolved for cancellation.`);
continue;
}
await cancelCommand(entry);
removePendingCommand(entry.commandId);
} catch (error) {
appendLog("error", `Failed to cancel ${entry.commandId}: ${normalizeError(error)}`);
}
}
}
state.data.profile.active = false;
state.data.runtime.active = false;
state.data.runtime.startedAt = null;
state.data.runtime.nextSendAt = null;
state.data.runtime.waveSequence = 0;
state.data.runtime.ownerId = null;
if (!settings.preservePending) {
state.data.runtime.pendingCommands = [];
}
state.data.runtime.lastError = settings.keepError ? reason : "";
releaseTabLock();
saveState();
renderAll();
if (settings.alert && reason) {
showBanner(reason);
} else if (!settings.keepError) {
hideBanner();
}
}
function validateProfile(profile) {
if (!profile.sourceTownId) {
throw new Error("Select a source town.");
}
if (!profile.targetTownId) {
throw new Error("Target town ID is required.");
}
if (profile.sourceTownId === profile.targetTownId) {
throw new Error("Source and target towns must differ.");
}
if (!profile.endAt || profile.endAt <= nowServerMs()) {
throw new Error("End time must be in the future and expressed in server time.");
}
const totalUnits = Object.values(profile.units).reduce((sum, value) => sum + value, 0);
if (!totalUnits) {
throw new Error("Configure at least one unit amount.");
}
Object.entries(profile.units).forEach(([unitKey, value]) => {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`Invalid amount for ${getUnitLabel(unitKey)}.`);
}
});
}
function validateAvailableUnits(profile) {
const town = getTown(profile.sourceTownId);
if (!town) {
throw new Error(`Source town ${profile.sourceTownId} is not available in ITowns.`);
}
const availableUnits = typeof town.units === "function" ? town.units() : {};
Object.entries(profile.units).forEach(([unitKey, value]) => {
if (toInteger(availableUnits[unitKey], 0) < value) {
throw new Error(`Not enough ${getUnitLabel(unitKey)} in ${resolveTownLabel(profile.sourceTownId)}.`);
}
});
}
function ensureBindingsHealthy() {
state.bindings = collectGameBindings();
}
async function switchToTownIfNeededInternal(townId, options) {
const targetTownId = toNullableInteger(townId);
const settings = Object.assign(
{
humanDelayEnabled: true,
},
options || {}
);
if (!targetTownId) {
throw new Error("Source town is invalid.");
}
if (toNullableInteger(state.bindings.Game.townId) === targetTownId) {
return;
}
const helperTown = state.bindings.HelperTown || {};
const candidates = [
["setCurrentTown", state.bindings.ITowns],
["switchTown", helperTown],
["townSwitch", helperTown],
["switchToTown", helperTown],
["goToTown", helperTown],
["activateTown", helperTown],
];
let switched = false;
let lastError = null;
for (const [methodName, context] of candidates) {
if (!context || typeof context[methodName] !== "function") {
continue;
}
try {
if (settings.humanDelayEnabled) {
await humanDelay("before town switch");
}
const result = context[methodName](targetTownId);
if (result && typeof result.then === "function") {
await result;
}
debugLog("town switched locally", {
fromTownId: toNullableInteger(state.bindings.Game.townId),
toTownId: targetTownId,
method: methodName,
});
switched = true;
break;
} catch (error) {
lastError = error;
}
}
if (!switched) {
throw new Error(
lastError
? `Could not switch to source town ${targetTownId}: ${normalizeError(lastError)}`
: `Could not switch to source town ${targetTownId}. No town switch API was available.`
);
}
const deadline = Date.now() + COMMAND_TIMEOUT_MS;
while (Date.now() < deadline) {
if (toNullableInteger(state.bindings.Game.townId) === targetTownId) {
return;
}
ensureBindingsHealthy();
await sleep(100);
}
throw new Error(`Town switch did not complete for source town ${targetTownId}.`);
}
async function switchToTownIfNeeded(townId, options) {
const settings = options === undefined
? { humanDelayEnabled: true }
: options;
return switchToTownIfNeededInternal(townId, settings);
}
function getTown(townId) {
if (!townId) {
return null;
}
if (typeof state.bindings.ITowns.getTown === "function") {
return state.bindings.ITowns.getTown(townId);
}
return state.bindings.ITowns.towns ? state.bindings.ITowns.towns[townId] : null;
}
function getOwnTowns() {
const raw = state.bindings.ITowns.towns || {};
return Object.values(raw)
.map((town) => ({
id: town.id,
name: town.name,
}))
.filter((town) => town.id && town.name)
.sort((left, right) => left.name.localeCompare(right.name));
}
function resolveTownLabel(townId) {
const town = getTown(townId);
return town && town.name ? town.name : `Town #${townId}`;
}
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
async function humanDelay(reason) {
const delayMs = randomInt(HUMAN_DELAY_MIN_MS, HUMAN_DELAY_MAX_MS);
debugLog("human delay", { reason, delayMs });
await sleep(delayMs);
}
function sleep(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function nowServerMs() {
return state.bindings.Timestamp.now() * 1000;
}
function computeServerClockOffsetMs() {
const serverTime = document.querySelector(SERVER_TIME_SELECTOR);
if (!serverTime || !serverTime.textContent) {
return 0;
}
const match = serverTime.textContent.trim().match(/(\d{2}):(\d{2}):(\d{2})\s+(\d{2})\/(\d{2})\/(\d{4})/);
if (!match) {
return 0;
}
const [, hour, minute, second, day, month, year] = match;
const wallUtc = Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute),
Number(second)
);
return wallUtc - nowServerMs();
}
function formatServerDateTime(epochMs) {
if (!Number.isFinite(epochMs)) {
return "Not set";
}
const shifted = new Date(epochMs + state.serverClockOffsetMs);
const year = shifted.getUTCFullYear();
const month = pad2(shifted.getUTCMonth() + 1);
const day = pad2(shifted.getUTCDate());
const hour = pad2(shifted.getUTCHours());
const minute = pad2(shifted.getUTCMinutes());
const second = pad2(shifted.getUTCSeconds());
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
function parseServerDateTimeInput(value) {
if (!value || !value.trim()) {
return null;
}
const trimmed = value.trim();
let match = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/);
if (match) {
const [, year, month, day, hour, minute, second = "00"] = match;
return Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute),
Number(second)
) - state.serverClockOffsetMs;
}
match = trimmed.match(/^(\d{2})\/(\d{2})\/(\d{4})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/);
if (match) {
const [, day, month, year, hour, minute, second = "00"] = match;
return Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute),
Number(second)
) - state.serverClockOffsetMs;
}
return null;
}
function pad2(value) {
return String(value).padStart(2, "0");
}
function snapshotOutgoingMovements() {
const toolbarMovements = getToolbarMovementElements().map(describeToolbarMovement).filter(Boolean);
const modelMovements = getMovementModels().map(describeMovementModel).filter(Boolean);
const movements = mergeMovementSnapshots(toolbarMovements, modelMovements);
return {
commandIds: new Set(movements.map((movement) => movement.commandId).filter(Boolean)),
sourceRefs: new Set(movements.map((movement) => movement.sourceRef).filter(Boolean)),
movements,
};
}
function scoreMovementCandidate(movement, context) {
if (!movement || !isResolvableAttackMovement(movement)) {
return 0;
}
let score = 0;
if (movement.commandId && !context.beforeSnapshot.commandIds.has(movement.commandId)) {
score += 8;
}
if (movement.targetTownId === context.targetTownId) {
score += 8;
}
if (movement.homeTownId === context.sourceTownId) {
score += 8;
}
if (Array.isArray(movement.townIds)) {
if (context.targetTownId && movement.townIds.includes(context.targetTownId)) {
score += 4;
}
if (context.sourceTownId && movement.townIds.includes(context.sourceTownId)) {
score += 4;
}
}
if (movement.targetTownId === null) {
score += 1;
}
if (movement.homeTownId === null) {
score += 1;
}
score += 4;
if (movement.startedAtMs) {
const delta = Math.abs(movement.startedAtMs - context.sentAt);
if (delta <= 15000) {
score += 8;
} else if (delta <= 60000) {
score += 4;
} else if (delta <= 180000) {
score += 2;
} else {
score -= 4;
}
}
return score;
}
function getTravelDurationMs(sentAt, arrivalAt, fallbackMs) {
if (Number.isFinite(fallbackMs) && fallbackMs > 0) {
return fallbackMs;
}
if (Number.isFinite(sentAt) && Number.isFinite(arrivalAt) && arrivalAt > sentAt) {
return arrivalAt - sentAt;
}
return null;
}
function isShortRangePressureRoute(travelDurationMs) {
return Number.isFinite(travelDurationMs) && travelDurationMs <= CANCELLATION_LOCK_MS;
}
function getAttackIntervalMs(travelDurationMs) {
return isShortRangePressureRoute(travelDurationMs)
? SHORT_RANGE_ATTACK_INTERVAL_MS
: ATTACK_INTERVAL_MS;
}
function getCancelBehaviorForWave(travelDurationMs, waveSequence) {
if (!isShortRangePressureRoute(travelDurationMs)) {
return "land";
}
void waveSequence;
return "cancel";
}
function createPendingCommandEntry(runtime, profile, sent, nextCycleAt) {
const waveSequence = clamp(toInteger(runtime.waveSequence, 0) + 1, 1, 999999);
const travelDurationMs = getTravelDurationMs(sent.sentAt, sent.arrivalAt, sent.travelDurationMs);
const cancelBehavior = getCancelBehaviorForWave(travelDurationMs, waveSequence);
runtime.waveSequence = waveSequence;
return {
commandId: sent.commandId,
sourceTownId: profile.sourceTownId,
targetTownId: profile.targetTownId,
sentAt: sent.sentAt,
nextCycleAt,
plannedCancelAt: computePlannedCancelAt(sent.sentAt, sent.arrivalAt, travelDurationMs, cancelBehavior),
arrivalAt: sent.arrivalAt,
travelDurationMs,
expectedType: sent.expectedType,
cancelBehavior,
};
}
function computePlannedCancelAt(sentAt, arrivalAt, travelDurationMs, cancelBehavior) {
if (cancelBehavior === "land") {
return null;
}
if (!Number.isFinite(sentAt) || !Number.isFinite(arrivalAt) || !Number.isFinite(travelDurationMs)) {
return null;
}
if (isShortRangePressureRoute(travelDurationMs)) {
const earliestCancelAt = Math.max(sentAt, arrivalAt - SHORT_RANGE_CANCEL_EARLIEST_MS);
const latestCancelAt = Math.max(earliestCancelAt, arrivalAt - SHORT_RANGE_CANCEL_LATEST_MS);
return randomTimeBetween(earliestCancelAt, latestCancelAt);
}
const latestCancelAt = arrivalAt - CANCELLATION_LOCK_MS;
const baseCancelAt = latestCancelAt - CANCELLATION_SAFETY_MS;
return clampTimeWithJitter(baseCancelAt, sentAt, latestCancelAt);
}
function computeNextSendAt(referenceAt, travelDurationMs) {
if (!Number.isFinite(referenceAt)) {
return null;
}
const intervalMs = getAttackIntervalMs(travelDurationMs);
return Math.max(referenceAt, referenceAt + intervalMs + randomSignedJitterMs());
}
function randomSignedJitterMs() {
return randomInt(-EXECUTION_JITTER_MS, EXECUTION_JITTER_MS);
}
function randomTimeBetween(minAt, maxAt) {
if (!Number.isFinite(minAt) || !Number.isFinite(maxAt)) {
return null;
}
if (maxAt <= minAt) {
return minAt;
}
return randomInt(Math.trunc(minAt), Math.trunc(maxAt));
}
function clampTimeWithJitter(baseAt, minAt, maxAt) {
if (!Number.isFinite(baseAt)) {
return null;
}
const jitteredAt = baseAt + randomSignedJitterMs();
const safeMinAt = Number.isFinite(minAt) ? minAt : jitteredAt;
const safeMaxAt = Number.isFinite(maxAt) ? maxAt : jitteredAt;
return Math.min(Math.max(jitteredAt, safeMinAt), safeMaxAt);
}
function buildSendLogMessage(sent, entry) {
const parts = [
`Sent attack ${sent.commandId || "pending"} at ${formatServerDateTime(sent.sentAt)}.`,
];
if (sent.untracked) {
parts.push("The command could not be resolved from Grepolis models, so this wave will remain untracked.");
return parts.join(" ");
}
if (Number.isFinite(sent.arrivalAt)) {
parts.push(`Arrival ${formatServerDateTime(sent.arrivalAt)}.`);
}
if (entry && entry.cancelBehavior === "land") {
parts.push(
isShortRangePressureRoute(entry.travelDurationMs)
? `This short-range wave will be allowed to land to keep pressure inside ${Math.round(PRESSURE_WINDOW_MS / 60000)} minutes.`
: "This long-range wave will be allowed to land because pressure under 5 minutes cannot be maintained by cancellation."
);
} else if (entry && Number.isFinite(entry.plannedCancelAt)) {
parts.push(`Cancel planned for ${formatServerDateTime(entry.plannedCancelAt)}.`);
} else {
parts.push("No cancellation was scheduled for this wave.");
}
return parts.join(" ");
}
function resolvePendingCommandEntry(entry) {
const trackedCommandIds = new Set(
state.data.runtime.pendingCommands
.filter((candidate) => candidate !== entry)
.map((candidate) => candidate.commandId)
.filter(Boolean)
);
const candidates = snapshotOutgoingMovements().movements
.filter((movement) => isResolvableAttackMovement(movement, entry.expectedType))
.filter((movement) => !movement.commandId || !trackedCommandIds.has(movement.commandId))
.map((movement) => ({
movement,
score: scoreTrackedEntryCandidate(entry, movement),
}))
.filter((candidate) => candidate.score > 0)
.sort((left, right) => right.score - left.score);
return candidates.length ? candidates[0].movement : null;
}
function scoreTrackedEntryCandidate(entry, movement) {
if (!isResolvableAttackMovement(movement, entry && entry.expectedType)) {
return 0;
}
let score = 0;
if (movement.targetTownId === entry.targetTownId) {
score += 8;
}
if (movement.homeTownId === entry.sourceTownId) {
score += 8;
}
if (Array.isArray(movement.townIds)) {
if (entry.targetTownId && movement.townIds.includes(entry.targetTownId)) {
score += 4;
}
if (entry.sourceTownId && movement.townIds.includes(entry.sourceTownId)) {
score += 4;
}
}
score += 4;
if (movement.startedAtMs && entry.sentAt) {
const delta = Math.abs(movement.startedAtMs - entry.sentAt);
if (delta <= 15000) {
score += 8;
} else if (delta <= 60000) {
score += 4;
} else if (delta <= 180000) {
score += 1;
} else {
score -= 4;
}
}
if (movement.arrivalAtMs && entry.arrivalAt) {
const delta = Math.abs(movement.arrivalAtMs - entry.arrivalAt);
if (delta <= 15000) {
score += 6;
} else if (delta <= 60000) {
score += 3;
} else {
score -= 2;
}
}
return score;
}
function normalizeDurationCandidate(value) {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return value > 100000 ? value : value * 1000;
}
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
let match = trimmed.match(/^(\d{1,2}):(\d{2}):(\d{2})$/);
if (match) {
return ((Number(match[1]) * 60 + Number(match[2])) * 60 + Number(match[3])) * 1000;
}
match = trimmed.match(/^(\d+)\s*s$/i);
if (match) {
return Number(match[1]) * 1000;
}
return null;
}
function readNestedValue(root, paths) {
for (const path of paths) {
let current = root;
let found = true;
for (const key of path) {
if (!current || typeof current !== "object" || !(key in current)) {
found = false;
break;
}
current = current[key];
}
if (found && current !== undefined && current !== null) {
return current;
}
}
return null;
}
function visitObject(value, visitor, path) {
if (!value || typeof value !== "object") {
return;
}
visitor(value, path || []);
if (Array.isArray(value)) {
value.forEach((entry, index) => visitObject(entry, visitor, (path || []).concat(String(index))));
return;
}
Object.keys(value).forEach((key) => visitObject(value[key], visitor, (path || []).concat(key)));
}
function getToolbarMovementElements() {
const selectors = [
"#toolbar_activity_commands_list .command",
"#toolbar_activity_commands_list [id^='movement']",
"#toolbar_activity_commands_list .js-dropdown-item-list > div",
];
const elements = [];
const seen = new Set();
selectors.forEach((selector) => {
document.querySelectorAll(selector).forEach((element) => {
if (!(element instanceof HTMLElement) || seen.has(element)) {
return;
}
seen.add(element);
elements.push(element);
});
});
return elements;
}
function describeToolbarMovement(element) {
const commandId = extractToolbarCommandId(element);
if (!commandId) {
return null;
}
const townIds = extractToolbarTownIds(element);
const type = extractToolbarMovementType(element);
const arrivalAtSec = toNullableInteger(element.dataset && element.dataset.timestamp);
const startedAtSec = toNullableInteger(element.dataset && element.dataset.starttime);
return {
model: null,
sourceRef: element.id || element,
commandId,
targetTownId: townIds.length > 1 ? townIds[townIds.length - 1] : null,
homeTownId: townIds.length > 0 ? townIds[0] : null,
townIds,
type,
incoming: false,
startedAtMs: startedAtSec && startedAtSec > 0 ? startedAtSec * 1000 : null,
arrivalAtMs: arrivalAtSec && arrivalAtSec > 0 ? arrivalAtSec * 1000 : null,
};
}
function extractToolbarCommandId(element) {
const movementId = extractToolbarMovementId(element);
const modelCommandId = movementId ? getCommandIdForMovementId(movementId) : null;
if (modelCommandId) {
return modelCommandId;
}
const candidates = [];
if (element.id) {
candidates.push(element.id);
}
if (element.dataset) {
candidates.push(element.dataset.commandId, element.dataset.command_id, element.dataset.movementId, element.dataset.id);
}
const cancelControl = element.querySelector(".cancel, [data-command-id], [data-command_id]");
if (cancelControl instanceof HTMLElement) {
if (cancelControl.dataset) {
candidates.push(cancelControl.dataset.commandId, cancelControl.dataset.command_id, cancelControl.dataset.id);
}
if (cancelControl.id) {
candidates.push(cancelControl.id);
}
candidates.push(cancelControl.getAttribute("href"), cancelControl.getAttribute("onclick"));
}
for (const candidate of candidates) {
const commandId = extractIntegerToken(candidate);
if (commandId) {
return commandId;
}
}
return null;
}
function extractToolbarMovementId(element) {
const candidates = [];
if (element.id) {
candidates.push(element.id);
}
if (element.dataset) {
candidates.push(element.dataset.movementId, element.dataset.movement_id, element.dataset.id);
}
for (const candidate of candidates) {
const movementId = extractIntegerToken(candidate);
if (movementId) {
return movementId;
}
}
return null;
}
function getCommandIdForMovementId(movementId) {
const movementModel = findMovementModelById(movementId);
if (!movementModel) {
return null;
}
if (typeof movementModel.getCommandId === "function") {
return toNullableInteger(movementModel.getCommandId());
}
return toNullableInteger(
movementModel.command_id !== undefined ? movementModel.command_id :
movementModel.commandId !== undefined ? movementModel.commandId :
movementModel.attributes && movementModel.attributes.command_id !== undefined ? movementModel.attributes.command_id :
movementModel.attributes && movementModel.attributes.commandId !== undefined ? movementModel.attributes.commandId :
null
);
}
function findMovementModelById(movementId) {
if (!movementId || !state.bindings || !state.bindings.window) {
return null;
}
const movementCollections = state.bindings.window.MM && typeof state.bindings.window.MM.getCollections === "function"
? state.bindings.window.MM.getCollections()
: null;
if (!movementCollections || typeof movementCollections !== "object") {
return null;
}
for (const [key, collections] of Object.entries(movementCollections)) {
if (!key || !key.toLowerCase().startsWith("movements")) {
continue;
}
if (!Array.isArray(collections)) {
continue;
}
for (const collection of collections) {
if (!collection || !Array.isArray(collection.models)) {
continue;
}
const directMatch = collection.models.find((model) => {
if (!model) {
return false;
}
const candidateId = toNullableInteger(
model.id !== undefined ? model.id :
model.attributes && model.attributes.id !== undefined ? model.attributes.id :
null
);
return candidateId === movementId;
});
if (directMatch) {
return directMatch;
}
}
}
return null;
}
function findMovementModelByCommandId(commandId) {
if (!commandId || !state.bindings || !state.bindings.window) {
return null;
}
const movementCollections = state.bindings.window.MM && typeof state.bindings.window.MM.getCollections === "function"
? state.bindings.window.MM.getCollections()
: null;
if (!movementCollections || typeof movementCollections !== "object") {
return null;
}
for (const [key, collections] of Object.entries(movementCollections)) {
if (!key || !key.toLowerCase().startsWith("movements")) {
continue;
}
if (!Array.isArray(collections)) {
continue;
}
for (const collection of collections) {
if (!collection || !Array.isArray(collection.models)) {
continue;
}
const directMatch = collection.models.find((model) => {
if (!model) {
return false;
}
const candidateCommandId = toNullableInteger(
typeof model.getCommandId === "function"
? model.getCommandId()
: model.command_id !== undefined ? model.command_id :
model.commandId !== undefined ? model.commandId :
model.attributes && model.attributes.command_id !== undefined ? model.attributes.command_id :
model.attributes && model.attributes.commandId !== undefined ? model.attributes.commandId :
null
);
return candidateCommandId === commandId;
});
if (directMatch) {
return directMatch;
}
}
}
return null;
}
function extractToolbarTownIds(element) {
const townIds = [];
element.querySelectorAll(".gp_town_link").forEach((link) => {
const href = link.getAttribute("href") || "";
const hash = href.includes("#") ? href.split("#").pop() : "";
if (!hash) {
return;
}
try {
const decoded = JSON.parse(atob(hash));
const townId = toNullableInteger(decoded && decoded.id);
if (townId && !townIds.includes(townId)) {
townIds.push(townId);
}
} catch (_error) {
// Ignore malformed hashes in toolbar links.
}
});
return townIds;
}
function extractToolbarMovementType(element) {
const imageIcon = element.querySelector("img");
const spriteIcon = element.querySelector(".icon");
const sources = [
element.className,
imageIcon && imageIcon.className,
imageIcon && imageIcon.getAttribute("src"),
spriteIcon && spriteIcon.className,
];
for (const source of sources) {
if (typeof source !== "string") {
continue;
}
const lowered = source.toLowerCase();
if (lowered.includes("attack")) {
return "attack";
}
if (lowered.includes("support")) {
return "support";
}
if (lowered.includes("return")) {
return "return";
}
}
return "";
}
function extractIntegerToken(value) {
if (typeof value === "number" && Number.isFinite(value)) {
return Math.trunc(value);
}
if (typeof value !== "string") {
return null;
}
const match = value.match(/(\d{3,})/);
return match ? toNullableInteger(match[1]) : null;
}
function isAttackMovementType(type) {
return typeof type === "string" && type.toLowerCase().includes("attack");
}
function isResolvableAttackMovement(movement, fallbackType) {
if (!movement) {
return false;
}
const effectiveType = typeof movement.type === "string" && movement.type
? movement.type
: fallbackType || "";
if (!isAttackMovementType(effectiveType)) {
return false;
}
return true;
}
function findMovementByCommandId(commandId) {
const toolbarMovement = getToolbarMovementElements()
.map(describeToolbarMovement)
.find((movement) => movement && movement.commandId === commandId);
if (toolbarMovement) {
return toolbarMovement;
}
const modelMovement = findMovementModelByCommandId(commandId);
if (modelMovement) {
return describeMovementModel(modelMovement);
}
return snapshotOutgoingMovements().movements.find((movement) => movement.commandId === commandId) || null;
}
function describeMovementModel(model) {
if (!model) {
return null;
}
const attrs = model.attributes || model;
const type = String(
typeof model.getType === "function"
? model.getType()
: attrs.type || attrs.command_type || ""
).toLowerCase();
return {
model,
sourceRef: attrs.id || model.cid || model,
commandId: toNullableInteger(
typeof model.getCommandId === "function"
? model.getCommandId()
: attrs.command_id !== undefined ? attrs.command_id :
attrs.commandId !== undefined ? attrs.commandId :
null
),
targetTownId: toNullableInteger(
typeof model.getTargetTownId === "function"
? model.getTargetTownId()
: attrs.target_town_id !== undefined ? attrs.target_town_id :
attrs.targetTownId !== undefined ? attrs.targetTownId :
null
),
homeTownId: toNullableInteger(
typeof model.getHomeTownId === "function"
? model.getHomeTownId()
: attrs.home_town_id !== undefined ? attrs.home_town_id :
attrs.homeTownId !== undefined ? attrs.homeTownId :
null
),
townIds: [],
type,
incoming: typeof model.isIncomingMovement === "function" ? Boolean(model.isIncomingMovement()) : false,
startedAtMs: Number.isFinite(attrs.started_at) ? attrs.started_at * 1000 : null,
arrivalAtMs: Number.isFinite(attrs.arrival_at) ? attrs.arrival_at * 1000 : null,
};
}
function getMovementModels() {
if (!state.bindings || !state.bindings.window) {
return [];
}
const movementCollections = state.bindings.window.MM && typeof state.bindings.window.MM.getCollections === "function"
? state.bindings.window.MM.getCollections()
: null;
if (!movementCollections || typeof movementCollections !== "object") {
return [];
}
const models = [];
for (const [key, collections] of Object.entries(movementCollections)) {
if (!key || !key.toLowerCase().startsWith("movements")) {
continue;
}
if (!Array.isArray(collections)) {
continue;
}
collections.forEach((collection) => {
if (!collection || !Array.isArray(collection.models)) {
return;
}
collection.models.forEach((model) => {
if (model) {
models.push(model);
}
});
});
}
return models;
}
function mergeMovementSnapshots() {
const merged = new Map();
Array.from(arguments).forEach((bucket) => {
if (!Array.isArray(bucket)) {
return;
}
bucket.forEach((movement) => {
if (!movement) {
return;
}
const key = movement.commandId ? `command:${movement.commandId}` : `ref:${String(movement.sourceRef)}`;
const existing = merged.get(key);
if (!existing || countMovementSignals(movement) > countMovementSignals(existing)) {
merged.set(key, movement);
}
});
});
return Array.from(merged.values());
}
function countMovementSignals(movement) {
if (!movement) {
return 0;
}
let score = 0;
if (movement.commandId) {
score += 2;
}
if (movement.targetTownId) {
score += 2;
}
if (movement.homeTownId) {
score += 2;
}
if (movement.startedAtMs) {
score += 1;
}
if (movement.arrivalAtMs) {
score += 1;
}
if (movement.type) {
score += 1;
}
if (Array.isArray(movement.townIds) && movement.townIds.length) {
score += 1;
}
return score;
}
function removePendingCommand(commandId) {
state.data.runtime.pendingCommands = state.data.runtime.pendingCommands.filter((entry) => entry.commandId !== commandId);
}
function appendLog(level, message) {
const entry = normalizeLogEntry({
at: nowServerMs(),
level,
message: String(message || ""),
});
state.data.recentLog.push(entry);
if (state.data.recentLog.length > LOG_LIMIT) {
state.data.recentLog = state.data.recentLog.slice(-LOG_LIMIT);
}
saveState();
renderLog();
renderStatus();
}
function showBanner(message) {
void message;
}
function hideBanner() {
return;
}
function safeParseJson(value) {
if (!value || typeof value !== "string") {
return null;
}
try {
return JSON.parse(value);
} catch (_error) {
return null;
}
}
function toInteger(value, fallback) {
if (value === "" || value === null || value === undefined) {
return fallback;
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return fallback;
}
return Math.trunc(numeric);
}
function toNullableInteger(value) {
if (value === "" || value === null || value === undefined) {
return null;
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return null;
}
return Math.trunc(numeric);
}
function clamp(value, min, max) {
if (!Number.isFinite(value)) {
return min;
}
return Math.min(Math.max(value, min), max);
}
function normalizeError(error) {
if (!error) {
return "Unknown error.";
}
if (typeof error === "string") {
return error;
}
if (error instanceof Error && error.message) {
return error.message;
}
if (typeof error.message === "string" && error.message) {
return error.message;
}
if (typeof error.error === "string" && error.error) {
return error.error;
}
try {
return JSON.stringify(error);
} catch (_stringifyError) {
return String(error);
}
}
function isMissingCommandCancellationError(reason) {
if (typeof reason !== "string" || !reason.trim()) {
return false;
}
const normalized = reason.toLowerCase();
return (
normalized.includes("la orden no existe") ||
normalized.includes("command does not exist") ||
normalized.includes("command no longer exists") ||
normalized.includes("did not disappear after cancellation")
);
}
function isRecoverableRuntimeError(reason) {
if (typeof reason !== "string" || !reason.trim()) {
return false;
}
const normalized = reason.toLowerCase();
if (
normalized.includes("lock lost") ||
normalized.includes("another grepolis tab") ||
normalized.includes("source town is invalid") ||
normalized.includes("could not switch to source town") ||
normalized.includes("town switch did not complete")
) {
return false;
}
return (
normalized.includes("attack dispatch could not be confirmed") ||
normalized.includes("could not resolve the created command from the command toolbar") ||
normalized.includes("request timed out") ||
normalized.includes("timed out waiting for callback")
);
}
function extractAjaxMessage(response) {
if (!response || typeof response !== "object") {
return "";
}
const directCandidates = [
response.message,
response.error,
response.msg,
response.error_msg,
];
for (const candidate of directCandidates) {
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
}
if (Array.isArray(response.notifications)) {
for (const notification of response.notifications) {
const message = notification && (notification.message || notification.text || notification.error);
if (typeof message === "string" && message.trim()) {
return message.trim();
}
}
}
const nestedMessage = findNestedAjaxMessage(response);
if (nestedMessage) {
return nestedMessage;
}
if (response.json && typeof response.json === "object") {
return extractAjaxMessage(response.json);
}
return "";
}
function findNestedAjaxMessage(response) {
let found = "";
visitObject(response, (entry) => {
if (found || !entry || typeof entry !== "object") {
return;
}
const candidates = [
entry.message,
entry.error,
entry.msg,
entry.error_msg,
entry.text,
entry.description,
entry.localized_message,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) {
found = candidate.trim();
return;
}
}
});
return found;
}
function ajaxGet(controller, action, data) {
return ajax("ajaxGet", controller, action, data);
}
function ajaxPost(controller, action, data) {
return ajax("ajaxPost", controller, action, data);
}
function ajax(method, controller, action, data) {
return new Promise((resolve, reject) => {
const fn = state.bindings.gpAjax && state.bindings.gpAjax[method];
if (typeof fn !== "function") {
reject(new Error(`gpAjax.${method} is not available.`));
return;
}
let settled = false;
const timeoutId = window.setTimeout(() => {
if (settled) {
return;
}
settled = true;
reject(new Error(`Request timed out: ${controller}/${action}`));
}, AJAX_TIMEOUT_MS);
const finish = (error, response) => {
if (settled) {
return;
}
settled = true;
window.clearTimeout(timeoutId);
if (error) {
const normalized = normalizeAjaxFailure(error, response, controller, action);
reject(normalized);
return;
}
if (hasAjaxFailure(response)) {
reject(normalizeAjaxFailure(null, response, controller, action));
return;
}
resolve(response);
};
try {
const request = fn.call(state.bindings.gpAjax, controller, action, data || {}, false, function (response) {
finish(null, response);
});
if (request && typeof request.then === "function") {
request.then(
(response) => finish(null, response),
(error) => finish(error)
);
return;
}
if (request && typeof request.done === "function") {
request.done((response) => finish(null, response));
if (typeof request.fail === "function") {
request.fail((xhr, _textStatus, errorThrown) => {
finish(errorThrown || xhr, xhr);
});
}
}
} catch (error) {
finish(error);
}
});
}
function hasAjaxFailure(response) {
return Boolean(
response && (
response.success === false ||
(response.json && response.json.success === false)
)
);
}
function normalizeAjaxFailure(error, response, controller, action) {
const responseMessage = extractAjaxMessage(response);
const errorMessage = normalizeError(error);
const message = responseMessage || errorMessage || `Request failed: ${controller}/${action}`;
return message instanceof Error ? message : new Error(String(message));
}
function getStoredLock() {
const parsed = safeParseJson(localStorage.getItem(STORAGE_LOCK_KEY));
if (!parsed || typeof parsed !== "object") {
return null;
}
if (typeof parsed.ownerId !== "string" || !parsed.ownerId) {
return null;
}
return {
ownerId: parsed.ownerId,
updatedAt: toInteger(parsed.updatedAt, 0),
expiresAt: toInteger(parsed.expiresAt, 0),
};
}
function isLockExpired(lock) {
return !lock || !Number.isFinite(lock.expiresAt) || lock.expiresAt <= Date.now();
}
function acquireTabLock() {
const current = getStoredLock();
if (current && !isLockExpired(current) && current.ownerId !== state.ownerId) {
return false;
}
const now = Date.now();
const next = {
ownerId: state.ownerId,
updatedAt: now,
expiresAt: now + LOCK_TTL_MS,
};
localStorage.setItem(STORAGE_LOCK_KEY, JSON.stringify(next));
const stored = getStoredLock();
const acquired = Boolean(stored && stored.ownerId === state.ownerId && !isLockExpired(stored));
if (acquired) {
broadcast({ type: "lock-updated", ownerId: state.ownerId, lock: stored });
}
return acquired;
}
function refreshTabLock() {
const current = getStoredLock();
if (!current || isLockExpired(current) || current.ownerId !== state.ownerId) {
return false;
}
const now = Date.now();
const next = {
ownerId: state.ownerId,
updatedAt: now,
expiresAt: now + LOCK_TTL_MS,
};
localStorage.setItem(STORAGE_LOCK_KEY, JSON.stringify(next));
broadcast({ type: "lock-updated", ownerId: state.ownerId, lock: next });
return true;
}
function releaseTabLock() {
const current = getStoredLock();
if (!current || current.ownerId !== state.ownerId) {
return;
}
localStorage.removeItem(STORAGE_LOCK_KEY);
broadcast({ type: "lock-released", ownerId: state.ownerId });
}
function handleBroadcastMessage(message) {
if (!message || message.ownerId === state.ownerId) {
return;
}
if ((message.type === "lock-updated" || message.type === "lock-released") && state.data.runtime.active) {
const lock = getStoredLock();
if (lock && !isLockExpired(lock) && lock.ownerId !== state.ownerId) {
void stopWithAlert("Lock lost to another Grepolis tab.", {
preservePending: true,
cancelPending: false,
});
return;
}
}
if (message.type === "lock-updated" || message.type === "lock-released") {
renderStatus();
}
}
function handleForeignLockChange(newValue) {
const lock = newValue ? safeParseJson(newValue) : null;
if (state.data.runtime.active && lock && lock.ownerId && lock.ownerId !== state.ownerId && !isLockExpired(lock)) {
void stopWithAlert("Lock lost to another Grepolis tab.", {
preservePending: true,
cancelPending: false,
});
return;
}
renderStatus();
}
function broadcast(message) {
if (!state.channel) {
return;
}
try {
state.channel.postMessage(message);
} catch (_error) {
// Ignore transient channel failures.
}
}
})();