Greasy Fork is available in English.
Provides an overview of all troops (effects/created/production), gives the possibility to save which and how many troops needs to be created in a city
// ==UserScript==
// @name Grepolis Recruit Optimizer
// @version 1.0.0
// @include https://*.grepolis.com/game/*
// @author The Invincble
// @description Provides an overview of all troops (effects/created/production), gives the possibility to save which and how many troops needs to be created in a city
// @grant none
// @namespace https://greasyfork.org/users/1604198
// ==/UserScript==
(function () {
'use strict';
const TARGET_EFFECT_ID = 'unit_training_boost';
const STORAGE_KEY = 'grepo_unit_targets_scoped_v2';
const draftTargets = {};
function getTownId() {
return Number(Game?.townId || ITown?.town_id || 0);
}
function isValidGroupId(groupId) {
return groupId !== undefined && groupId !== null && String(groupId) !== '' && String(groupId) !== '-1';
}
function getGroupId() {
const townId = String(getTownId());
try {
const models = MM.getModels?.() || {};
for (const modelGroup of Object.values(models)) {
for (const model of Object.values(modelGroup || {})) {
const a = model.attributes || {};
const modelTownId = a.town_id || a.townId || a.townid;
const modelGroupId = a.town_group_id || a.group_id || a.groupId || a.townGroupId;
if (String(modelTownId) === townId && isValidGroupId(modelGroupId)) {
return String(modelGroupId);
}
}
}
} catch {}
try {
const collections = MM.getCollections?.() || {};
for (const collectionGroup of Object.values(collections)) {
for (const collection of Object.values(collectionGroup || {})) {
for (const model of collection.models || []) {
const a = model.attributes || {};
const modelTownId = a.town_id || a.townId || a.townid;
const modelGroupId = a.town_group_id || a.group_id || a.groupId || a.townGroupId;
if (String(modelTownId) === townId && isValidGroupId(modelGroupId)) {
return String(modelGroupId);
}
}
}
}
} catch {}
return 'default';
}
function getTargets() {
try {
const data = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
data.all ||= {};
data.groups ||= {};
data.cities ||= {};
return data;
} catch {
return { all: {}, groups: {}, cities: {} };
}
}
function saveTargets(data) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
function getTargetData(unit) {
const data = getTargets();
const townId = String(getTownId());
const groupId = String(getGroupId());
if (data.cities?.[townId]?.[unit] !== undefined) {
return { value: Number(data.cities[townId][unit]) || 0, scope: 'city' };
}
if (data.groups?.[groupId]?.[unit] !== undefined) {
return { value: Number(data.groups[groupId][unit]) || 0, scope: 'group' };
}
return { value: 0, scope: 'none' };
}
function setDraft(unit, value) {
const townId = String(getTownId());
draftTargets[townId] ||= {};
draftTargets[townId][unit] = value;
}
function getDraft(unit) {
return draftTargets[String(getTownId())]?.[unit];
}
function saveAllVisibleTargets(scope) {
const data = getTargets();
const townId = String(getTownId());
const groupId = String(getGroupId());
if (scope === 'city') data.cities[townId] ||= {};
if (scope === 'group') data.groups[groupId] ||= {};
getTabs().forEach(tab => {
const input = tab.querySelector('.uti-target');
if (!input) return;
const value = Number(input.value) || 0;
if (scope === 'city') data.cities[townId][tab.id] = value;
if (scope === 'group') data.groups[groupId][tab.id] = value;
input.dataset.dirty = '0';
input.classList.remove('dirty');
setDraft(tab.id, input.value);
});
saveTargets(data);
loadSavedInputs();
refreshTotalsOnly();
}
function getBaseAmount(tab, unit) {
const totalEl = tab.querySelector('.unit_order_total');
if (totalEl) return Number(totalEl.textContent.trim()) || 0;
const img = tab.querySelector(`[data-unit_id="${unit}"]`);
return Number(img?.getAttribute('data-unit_count') || 0);
}
function calculateRemainingOrder(order) {
const now = Math.floor(Date.now() / 1000);
const a = order.attributes || {};
if (!a.count) return 0;
if (now < a.created_at) return Number(a.count || 0);
if (now >= a.to_be_completed_at) return 0;
const duration = a.to_be_completed_at - a.created_at;
const timePerUnit = duration / a.count;
const elapsed = now - a.created_at;
const completed = Math.floor(elapsed / timePerUnit);
return Math.max(Number(a.count || 0) - completed, 0);
}
function getQueuedUnits() {
const townId = getTownId();
const queued = {};
const orders = MM.getModels?.().UnitOrder || {};
Object.values(orders).forEach(order => {
const a = order.attributes || {};
if (Number(a.town_id) !== townId) return;
const unit = order.getUnitId?.() || a.unit_type || a.unit_id || a.type;
if (!unit) return;
const remaining = calculateRemainingOrder(order);
if (remaining <= 0) return;
queued[unit] = (queued[unit] || 0) + remaining;
});
return queued;
}
function getBoostUnits() {
const townId = getTownId();
const boosted = {};
const collections = MM.getCollections?.() || {};
Object.values(collections).forEach(groups => {
Object.values(groups || {}).forEach(collection => {
(collection.models || []).forEach(model => {
const a = model.attributes || {};
if (a.power_id !== TARGET_EFFECT_ID) return;
if (Number(a.town_id) !== townId) return;
const type = a.configuration?.type;
const amount = Number(a.configuration?.amount || 0);
const endAt = Number(a.end_at || 0);
if (!type || !amount || !endAt) return;
const seconds = Math.max(0, endAt - Math.floor(Date.now() / 1000));
const remaining = Math.ceil(seconds / 3600) * amount;
boosted[type] = (boosted[type] || 0) + remaining;
});
});
});
return boosted;
}
function injectStyle() {
if (document.getElementById('uti-bottom-clean-style')) return;
const style = document.createElement('style');
style.id = 'uti-bottom-clean-style';
style.textContent = `
#units {
position: relative !important;
overflow: visible !important;
margin-top: 70px !important;
}
#units .unit_tab {
position: relative !important;
overflow: visible !important;
}
.uti-bottom-box {
position: absolute;
left: 0;
top: -68px;
width: 57px;
text-align: center;
z-index: 50;
font-family: Arial, sans-serif;
}
.uti-row,
.uti-target,
.uti-missing {
width: 57px;
height: 20px;
box-sizing: border-box;
border: 1px solid #8a672c;
font-size: 11px;
font-weight: bold;
text-align: center;
}
.uti-row {
line-height: 20px;
color: #000;
}
.uti-target {
margin-top: 2px;
background: #fff0bd;
color: #000;
padding: 0;
outline: none;
}
.uti-target.dirty {
background: #fff6d0;
border-color: #b36b00;
}
.uti-missing {
line-height: 20px;
margin-top: 2px;
font-size: 12px;
}
.uti-missing.need {
background: #f2b59d;
color: #990000;
}
.uti-missing.done {
background: #c7eca0;
color: #2b7807;
}
.uti-missing.extra {
background: #f7c97b;
color: #8a4b00;
}
.uti-global-actions {
position: absolute;
top: -68px;
height: 44px;
display: flex;
flex-direction: column;
gap: 3px;
z-index: 999999;
font-family: Arial, sans-serif;
}
.uti-save-btn {
height: 20px;
min-width: 78px;
padding: 0 8px;
border: 1px solid #8a672c;
border-radius: 2px;
background: #0F213A;
color: wheat;
font-size: 10px;
font-weight: bold;
cursor: pointer;
box-sizing: border-box;
text-align: center;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.6);
}
.uti-save-btn:hover {
background: linear-gradient(#fff9dc, #e6c985);
color: #0F213A;
}
.uti-info-icon {
position: absolute;
top: 47px;
left: 0;
width: 19px;
height: 19px;
line-height: 19px;
border-radius: 50%;
background: #0F213A;
border: 1px solid #8a672c;
color: wheat;
font-size: 13px;
font-weight: bold;
text-align: center;
cursor: help;
box-shadow: 1px 1px 3px rgba(0,0,0,0.35);
}
.uti-info-tooltip {
display: none;
position: absolute;
top: 24px;
left: 0;
width: 380px;
padding: 10px 14px;
background: #fff0bd;
border: 2px solid #8a672c;
color: #000;
font-size: 13px;
font-weight: normal;
text-align: left;
line-height: 18px;
box-shadow: 2px 2px 8px rgba(0,0,0,0.45);
z-index: 1000000;
}
.uti-info-icon:hover .uti-info-tooltip,
.uti-info-icon.uti-open .uti-info-tooltip {
display: block;
}
.uti-info-tooltip strong {
display: block;
font-size: 15px;
margin-bottom: 5px;
}
`;
document.head.appendChild(style);
}
function getTabs() {
return [...document.querySelectorAll('#units .unit_tab')]
.filter(tab =>
tab.id &&
tab.style.display !== 'none' &&
!tab.classList.contains('unavailable')
);
}
function createMissingUi(loadSaved = false) {
injectStyle();
getTabs().forEach(tab => {
let box = tab.querySelector('.uti-bottom-box');
if (!box) {
box = document.createElement('div');
box.className = 'uti-bottom-box';
box.innerHTML = `
<div class="uti-row">0</div>
<input class="uti-target" type="text">
<div class="uti-missing done">0</div>
`;
tab.appendChild(box);
const input = box.querySelector('.uti-target');
input.dataset.dirty = '0';
input.addEventListener('input', () => {
input.dataset.dirty = '1';
input.classList.add('dirty');
setDraft(tab.id, input.value);
refreshTotalsOnly();
});
['click', 'mousedown', 'mouseup', 'keydown', 'keyup', 'keypress'].forEach(evt => {
input.addEventListener(evt, e => e.stopPropagation());
});
}
const input = box.querySelector('.uti-target');
if (loadSaved) {
const saved = getTargetData(tab.id).value;
input.value = saved || '';
input.dataset.dirty = '0';
input.classList.remove('dirty');
setDraft(tab.id, input.value);
} else {
const draft = getDraft(tab.id);
if (draft !== undefined && document.activeElement !== input) {
input.value = draft;
}
}
});
createGlobalActions();
}
function createGlobalActions() {
const units = document.querySelector('#units');
if (!units) return;
let actions = units.querySelector('.uti-global-actions');
if (!actions) {
actions = document.createElement('div');
actions.className = 'uti-global-actions';
actions.innerHTML = `
<button class="uti-save-btn" title="Save these targets for this city only">Save City</button>
<button class="uti-save-btn" title="Save these targets for this city group">Save Group</button>
<div class="uti-info-icon">
i
<div class="uti-info-tooltip">
<strong>Explanation:</strong>
• Top number = Total units, including city units, training queue and effects<br>
• White input = Your target amount<br>
• Bottom number = Remaining amount to train<br>
• Green ✓ = Target reached<br>
• Orange +number = You have more than your target<br>
• Save City overrides Save Group
</div>
</div>
`;
const buttons = actions.querySelectorAll('button');
buttons[0].addEventListener('click', e => {
e.stopPropagation();
saveAllVisibleTargets('city');
});
buttons[1].addEventListener('click', e => {
e.stopPropagation();
saveAllVisibleTargets('group');
});
const info = actions.querySelector('.uti-info-icon');
info.addEventListener('click', e => {
e.stopPropagation();
info.classList.toggle('uti-open');
});
document.addEventListener('click', () => {
info.classList.remove('uti-open');
});
units.appendChild(actions);
}
const tabs = getTabs();
const lastTab = tabs[tabs.length - 1];
if (lastTab) {
const unitsRect = units.getBoundingClientRect();
const lastRect = lastTab.getBoundingClientRect();
const newLeft = `${lastRect.right - unitsRect.left + 8}px`;
if (actions.style.left !== newLeft) {
actions.style.left = newLeft;
}
}
}
function refreshTotalsOnly() {
const queued = getQueuedUnits();
const boosted = getBoostUnits();
getTabs().forEach(tab => {
const box = tab.querySelector('.uti-bottom-box');
if (!box) return;
const totalEl = box.querySelector('.uti-row');
const input = box.querySelector('.uti-target');
const missingEl = box.querySelector('.uti-missing');
if (!totalEl || !input || !missingEl) return;
const unit = tab.id;
const base = getBaseAmount(tab, unit);
const total = base + (queued[unit] || 0) + (boosted[unit] || 0);
const target = Number(input.value) || 0;
const missing = Math.max(target - total, 0);
const extra = Math.max(total - target, 0);
const totalText = String(total);
if (totalEl.textContent !== totalText) {
totalEl.textContent = totalText;
}
let text;
let cls;
if (!target || target <= 0) {
text = '0';
cls = 'uti-missing done';
} else if (extra > 0) {
text = `+${extra}`;
cls = 'uti-missing extra';
} else if (missing > 0) {
text = String(missing);
cls = 'uti-missing need';
} else {
text = '✓';
cls = 'uti-missing done';
}
if (missingEl.textContent !== text) {
missingEl.textContent = text;
}
if (missingEl.className !== cls) {
missingEl.className = cls;
}
});
}
let lastTownId = null;
let lastGroupId = null;
let lastUnitOrderExists = false;
let lastVisibleUnits = '';
function watcher() {
const unitOrderExists = !!document.querySelector('#unit_order');
if (!unitOrderExists) {
lastUnitOrderExists = false;
return;
}
const currentTownId = getTownId();
const currentGroupId = getGroupId();
const tabs = getTabs();
const visibleUnits = tabs.map(tab => tab.id).join('|');
const townChanged = lastTownId !== currentTownId;
const groupChanged = lastGroupId !== currentGroupId;
const windowOpened = !lastUnitOrderExists;
const unitListChanged = lastVisibleUnits !== visibleUnits;
const uiMissing = tabs.some(tab => !tab.querySelector('.uti-bottom-box'));
const actionsMissing = !document.querySelector('#units .uti-global-actions');
if (windowOpened || townChanged || groupChanged || unitListChanged) {
createMissingUi(true);
} else if (uiMissing || actionsMissing) {
createMissingUi(false);
}
refreshTotalsOnly();
lastTownId = currentTownId;
lastGroupId = currentGroupId;
lastVisibleUnits = visibleUnits;
lastUnitOrderExists = true;
}
setInterval(watcher, 500);
})();