Grepolis Recruit Optimizer

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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);
})();