Compare commits
91 Commits
b7bf1cf9ea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ae0322facb | |||
| 74b51e74ca | |||
| 51d15118ed | |||
| 7f380324d4 | |||
| b824144a6a | |||
| 7e98f1292e | |||
| 0743df0eeb | |||
| 1746bfd078 | |||
| 2a20ca11b4 | |||
| 781a2b92de | |||
| bedf25b6f6 | |||
| 4f7e0fae51 | |||
| 138841b027 | |||
| e9cc81b582 | |||
| a153b397d3 | |||
| 51485c0048 | |||
| cf23f38a6e | |||
| 11f30f4c6a | |||
| eb31072c87 | |||
| 47381a9304 | |||
| 76b991a62b | |||
| f5231a2524 | |||
| 84de7082ec | |||
| 83b8c85557 | |||
| d8ba139d07 | |||
| 6157ae1034 | |||
| 4272edf432 | |||
| 502b330ac5 | |||
| 1c65043eb3 | |||
| 5c4d415fdd | |||
| f22b92ae89 | |||
| 0f54ef9191 | |||
| 45e71ed90b | |||
| 66dcb71a8d | |||
| b36b11393f | |||
| 90ce6a029d | |||
| c9e6522f12 | |||
| 1cb5dca3c2 | |||
| 2552d3d075 | |||
| 5f6855ec69 | |||
| 05785c294e | |||
| 614029e527 | |||
| a572feef14 | |||
| b18e2e8f97 | |||
| 2769091b74 | |||
| f4a0e18686 | |||
| d6c2252f5c | |||
| bcda80e127 | |||
| 731a7b2f3b | |||
| f82893164e | |||
| efa63f761f | |||
| ae37674bcc | |||
| cf3c2e7b4f | |||
| 2921dff257 | |||
| 2a73e46a7b | |||
| 76ad37c1db | |||
| f250fbd5b6 | |||
| bb01b90889 | |||
| 0643422a30 | |||
| 2517538b88 | |||
| 1db8d744c8 | |||
| d952e7ca56 | |||
| edd7666905 | |||
| 76ab83620b | |||
| 53f1176ef8 | |||
| ef6946365c | |||
| 0ef0bef036 | |||
| 1717de8373 | |||
| 8ed964f3bb | |||
| bfdfaa142c | |||
| 8b42c7c2f9 | |||
| adb42c1649 | |||
| e8fd35105f | |||
| 5bff9a287d | |||
| a8b3e9f5ea | |||
| a5d57d55fc | |||
| 929af21d08 | |||
| 7beece5aaa | |||
| d20537983e | |||
| 037a84d6bb | |||
| ab1ba5c0ab | |||
| 95b38b1212 | |||
| b688b66275 | |||
| b577c95f7c | |||
| 8fd711f5a1 | |||
| 4fa6127aea | |||
| a143345831 | |||
| 8a64a7b4fc | |||
| 853525d8ad | |||
| 22a379c2a1 | |||
| e565c88eeb |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@ instance/
|
||||
.DS_Store
|
||||
grepo.db
|
||||
data/
|
||||
3rdparty/
|
||||
77
GrepoRemoteLoader.user.js
Normal file
77
GrepoRemoteLoader.user.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// ==UserScript==
|
||||
// @name Grepolis Remote Loader
|
||||
// @namespace http://tampermonkey.net/
|
||||
// @version 4.0.1
|
||||
// @description Dynamically loads the Grepolis Remote Control bot from the server
|
||||
// @author Dimitrios
|
||||
// @match https://*.grepolis.com/game/*
|
||||
// @grant unsafeWindow
|
||||
// @grant GM_xmlhttpRequest
|
||||
// @connect grepo.haunter-pets.top
|
||||
// @updateURL https://git.haunter-pets.top/haunter/grepo-remote/raw/branch/main/GrepoRemoteLoader.user.js
|
||||
// @downloadURL https://git.haunter-pets.top/haunter/grepo-remote/raw/branch/main/GrepoRemoteLoader.user.js
|
||||
// ==/UserScript==
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const BASE_URL = 'https://grepo.haunter-pets.top';
|
||||
const MAX_TRIES = 3;
|
||||
const RETRY_BASE = 3000; // 3 s, then 6 s
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// CLAN_KEY: Set this to your clan's unique key.
|
||||
// Leave it empty ('') and the bot will refuse to run.
|
||||
// ----------------------------------------------------------------
|
||||
const CLAN_KEY = ''; // <-- paste your clan key here
|
||||
|
||||
if (!CLAN_KEY || CLAN_KEY.trim() === '') {
|
||||
console.error('[Loader] ❌ CLAN_KEY is not set. Bot will not start. Contact your admin for the key.');
|
||||
return; // stop everything
|
||||
}
|
||||
|
||||
function loadBot(attempt) {
|
||||
attempt = attempt || 1;
|
||||
console.log(`[Loader] Fetching bot code (attempt ${attempt}/${MAX_TRIES})...`);
|
||||
|
||||
GM_xmlhttpRequest({
|
||||
method: 'GET',
|
||||
url: `${BASE_URL}/api/bot?t=${Date.now()}`,
|
||||
headers: {
|
||||
'X-Clan-Key': CLAN_KEY
|
||||
},
|
||||
onload: function(response) {
|
||||
if (response.status === 200) {
|
||||
console.log('[Loader] Bot code downloaded. Executing...');
|
||||
try {
|
||||
window.__GRC_CLAN_KEY = CLAN_KEY; // make key available inside the bot's scope
|
||||
eval(response.responseText);
|
||||
} catch (e) {
|
||||
console.error('[Loader] Execution error:', e);
|
||||
}
|
||||
} else if (attempt < MAX_TRIES) {
|
||||
const delay = RETRY_BASE * attempt;
|
||||
console.warn(`[Loader] Server returned ${response.status}. Retrying in ${delay / 1000}s...`);
|
||||
setTimeout(() => loadBot(attempt + 1), delay);
|
||||
} else {
|
||||
console.error(`[Loader] Failed after ${MAX_TRIES} attempts (status ${response.status}). Give up.`);
|
||||
}
|
||||
},
|
||||
onerror: function() {
|
||||
if (attempt < MAX_TRIES) {
|
||||
const delay = RETRY_BASE * attempt;
|
||||
console.warn(`[Loader] Connection error. Retrying in ${delay / 1000}s...`);
|
||||
setTimeout(() => loadBot(attempt + 1), delay);
|
||||
} else {
|
||||
console.error(`[Loader] Failed after ${MAX_TRIES} attempts. Check your server.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => loadBot(1));
|
||||
} else {
|
||||
loadBot(1);
|
||||
}
|
||||
})();
|
||||
@@ -1,7 +1,7 @@
|
||||
// ==UserScript==
|
||||
// @name Grepolis Remote Control
|
||||
// @namespace http://tampermonkey.net/
|
||||
// @version 3.5.8
|
||||
// @version 3.5.9
|
||||
// @description Polls grepo.haunter-pets.top for remote commands and executes them in-game (Multi-Player)
|
||||
// @author Dimitrios
|
||||
// @match https://*.grepolis.com/game/*
|
||||
@@ -485,6 +485,10 @@
|
||||
skipped++; continue;
|
||||
}
|
||||
|
||||
if (paused) {
|
||||
return { ok: false, msg: 'Aborted due to pause/captcha' };
|
||||
}
|
||||
|
||||
log(`Farm ${action}: farm_id=${farm.attributes.id} level=${level} town=${town_id}`);
|
||||
try {
|
||||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||||
@@ -496,8 +500,8 @@
|
||||
isLocked ? unlocked++ : upgraded++;
|
||||
} catch (e) { errors++; }
|
||||
|
||||
// Random delay between actions: 800ms – 2000ms
|
||||
await sleep(randInt(800, 2000));
|
||||
// Random delay between actions: 1200ms – 2500ms
|
||||
await sleep(randInt(1200, 2500));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -626,6 +630,10 @@
|
||||
log(`Farm: ${readyFarms.length} ready on island of town ${town_id}`);
|
||||
|
||||
for (const farm of readyFarms) {
|
||||
if (paused) {
|
||||
return { ok: false, msg: 'Aborted due to pause/captcha' };
|
||||
}
|
||||
|
||||
try {
|
||||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||||
model_url: `FarmTownPlayerRelation/${farm.relation_id}`,
|
||||
@@ -636,8 +644,8 @@
|
||||
claimed++;
|
||||
} catch (e) { errors++; }
|
||||
|
||||
// Random per-claim delay: 500ms – 1500ms (never below 500ms)
|
||||
await sleep(randInt(500, 1500));
|
||||
// Random per-claim delay: 1000ms – 2200ms
|
||||
await sleep(randInt(1000, 2200));
|
||||
}
|
||||
|
||||
// Refresh map icons after claiming (same as original)
|
||||
@@ -645,6 +653,7 @@
|
||||
|
||||
// Random between-island delay: 30s – 90s (only if more islands remain)
|
||||
if (i < islandList.length - 1) {
|
||||
if (paused) return { ok: false, msg: 'Aborted due to pause/captcha' };
|
||||
const gap = randInt(30000, 90000);
|
||||
log(`Farm: island done. Waiting ${(gap / 1000).toFixed(0)}s before next island...`);
|
||||
await sleep(gap);
|
||||
@@ -698,6 +707,8 @@
|
||||
log(`Waiting ${reactionMs}ms before firing buildUp (reaction time)...`);
|
||||
await sleep(reactionMs);
|
||||
|
||||
if (paused) return { ok: false, msg: 'Aborted due to pause/captcha' };
|
||||
|
||||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||||
model_url: 'BuildingOrder',
|
||||
action_name: 'buildUp',
|
||||
@@ -733,6 +744,8 @@
|
||||
log(`Waiting ${reactionMs}ms before firing recruit (reaction time)...`);
|
||||
await sleep(reactionMs);
|
||||
|
||||
if (paused) return { ok: false, msg: 'Aborted due to pause/captcha' };
|
||||
|
||||
uw.gpAjax.ajaxPost(endpoint, 'build', {
|
||||
unit_id,
|
||||
amount: parseInt(amount) || 1,
|
||||
@@ -759,6 +772,8 @@
|
||||
log(`Waiting ${reactionMs}ms before firing market offer (reaction time)...`);
|
||||
await sleep(reactionMs);
|
||||
|
||||
if (paused) return { ok: false, msg: 'Aborted due to pause/captcha' };
|
||||
|
||||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||||
model_url: 'CreateOffers/' + town_id,
|
||||
action_name: 'createOffer',
|
||||
@@ -772,47 +787,141 @@
|
||||
return { ok: true, msg: `Market offer posted: ${offer} ${offer_type} => ${demand} ${demand_type}` };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Execute: Research (Academy)
|
||||
// ----------------------------------------------------------------
|
||||
async function executeResearch(cmd) {
|
||||
const { town_id, payload } = cmd;
|
||||
const { research_id } = payload;
|
||||
|
||||
const town = uw.ITowns?.getTown?.(town_id) || uw.ITowns?.towns?.[town_id];
|
||||
if (!town) {
|
||||
return { ok: false, msg: `Town ${town_id} not found` };
|
||||
}
|
||||
|
||||
const reactionMs = randInt(800, 2500);
|
||||
log(`Waiting ${reactionMs}ms before firing research (reaction time)...`);
|
||||
await sleep(reactionMs);
|
||||
|
||||
if (paused) return { ok: false, msg: 'Aborted due to pause/captcha' };
|
||||
|
||||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||||
model_url: 'ResearchOrder',
|
||||
action_name: 'research',
|
||||
arguments: { id: research_id },
|
||||
town_id: town_id
|
||||
});
|
||||
|
||||
await sleep(500);
|
||||
return { ok: true, msg: `Research ${research_id} queued` };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Execute: Culture (Αγορά)
|
||||
// Fires a Γιορτή πόλης (party) or Παρέλαση θριάμβου (triumph)
|
||||
// celebration for a specific town via building_place/start_celebration.
|
||||
// ----------------------------------------------------------------
|
||||
async function executeCultureCommand(cmd) {
|
||||
const { town_id, payload } = cmd;
|
||||
const { celebration_type } = payload || {};
|
||||
|
||||
if (!celebration_type || !town_id) {
|
||||
return { ok: false, msg: 'Invalid culture payload: missing celebration_type or town_id' };
|
||||
}
|
||||
if (!['party', 'triumph'].includes(celebration_type)) {
|
||||
return { ok: false, msg: `Unknown celebration_type: ${celebration_type}` };
|
||||
}
|
||||
|
||||
const label = celebration_type === 'party' ? 'Γιορτή πόλης' : 'Παρέλαση θριάμβου';
|
||||
log(`[αγορά] Εκτέλεση ${label} για πόλη ${town_id}`);
|
||||
|
||||
// Validate town exists in game memory
|
||||
const town = uw.ITowns?.towns?.[town_id];
|
||||
if (!town) {
|
||||
return { ok: false, msg: `Η πόλη ${town_id} δεν βρέθηκε στη μνήμη του παιχνιδιού.` };
|
||||
}
|
||||
|
||||
// Double-check: no active celebration of this type running
|
||||
try {
|
||||
const celebModels = uw.MM.getModels()?.Celebration;
|
||||
if (celebModels) {
|
||||
const nowTs = Math.floor(Date.now() / 1000);
|
||||
for (const cel of Object.values(celebModels)) {
|
||||
const a = cel.attributes;
|
||||
if (String(a.town_id) === String(town_id)
|
||||
&& a.celebration_type === celebration_type
|
||||
&& (a.finished_at ?? 0) > nowTs) {
|
||||
return { ok: false, msg: `${label} ήδη ενεργή στην πόλη ${town_id}.` };
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { /* model not loaded — proceed; server already validated */ }
|
||||
|
||||
const reactionMs = randInt(800, 2500);
|
||||
log(`[αγορά] Waiting ${reactionMs}ms (reaction time)...`);
|
||||
await sleep(reactionMs);
|
||||
|
||||
if (paused) return { ok: false, msg: 'Aborted due to pause/captcha' };
|
||||
|
||||
// Fire the Grepolis AJAX
|
||||
let ajaxSuccess = false;
|
||||
let ajaxError = null;
|
||||
|
||||
await new Promise(resolve => {
|
||||
try {
|
||||
uw.gpAjax.ajaxPost(
|
||||
'building_place',
|
||||
'start_celebration',
|
||||
{ town_id: parseInt(town_id, 10), celebration_type },
|
||||
false,
|
||||
() => { ajaxSuccess = true; resolve(); },
|
||||
(err) => { ajaxError = err ? JSON.stringify(err) : 'Άγνωστο σφάλμα AJAX'; resolve(); }
|
||||
);
|
||||
} catch (e) {
|
||||
ajaxError = String(e);
|
||||
resolve();
|
||||
}
|
||||
// 12-second safety timeout
|
||||
setTimeout(() => { if (!ajaxSuccess && !ajaxError) ajaxError = 'Timeout (12s)'; resolve(); }, 12000);
|
||||
});
|
||||
|
||||
if (ajaxSuccess) {
|
||||
log(`[αγορά] ✅ ${label} ξεκίνησε επιτυχώς (πόλη ${town_id})`);
|
||||
return { ok: true, msg: `${label} ξεκίνησε επιτυχώς.` };
|
||||
} else {
|
||||
log(`[αγορά] ❌ Αποτυχία: ${ajaxError}`);
|
||||
return { ok: false, msg: ajaxError || 'Αποτυχία εκτέλεσης εορτής.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Poll for and execute pending commands (build + recruit + market)
|
||||
// ----------------------------------------------------------------
|
||||
async function pollAndExecute() {
|
||||
if (paused) return;
|
||||
const player_id = uw.Game?.player_id;
|
||||
const world_id = uw.Game?.world_id;
|
||||
if (!player_id) return;
|
||||
|
||||
let cmdData;
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/api/commands/pending?player_id=${player_id}`);
|
||||
const res = await fetch(`${BASE_URL}/api/commands/pending?player_id=${player_id}&world_id=${world_id}`);
|
||||
cmdData = await res.json();
|
||||
} catch (e) {
|
||||
log(`Poll failed: ${e}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build queue, Recruit queue and Market queue are independent
|
||||
const buildCmd = cmdData.build;
|
||||
// Build queue: one command per town (all towns build in the same poll cycle)
|
||||
const buildCmds = cmdData.builds || [];
|
||||
const recruitCmd = cmdData.recruit;
|
||||
const marketCmd = cmdData.market;
|
||||
const researchCmd = cmdData.research;
|
||||
const farmCmd = cmdData.farm;
|
||||
const farmUpgradeCmd = cmdData.farm_upgrade;
|
||||
const cultureCmd = cmdData.culture;
|
||||
|
||||
|
||||
// Auto-farm: if enabled, claim all ready farms (no explicit command needed)
|
||||
const farmSettings = cmdData.farm_settings || {};
|
||||
if (farmSettings.enabled && !farmCmd) {
|
||||
const nowTs = Math.floor(Date.now() / 1000);
|
||||
// Check if any town has ready farms before triggering
|
||||
const hasFarms = Object.values(uw.ITowns?.towns || {}).some(t => {
|
||||
try {
|
||||
const coll = uw.MM.getOnlyCollectionByName('FarmTownPlayerRelation');
|
||||
return coll?.models?.some(r => r.attributes.relation_status === 1 && (r.attributes.lootable_at || 0) <= nowTs);
|
||||
} catch (e) { return false; }
|
||||
});
|
||||
if (hasFarms) {
|
||||
log('⚡ Auto-farm: ready farms detected, triggering loot...');
|
||||
await executeFarmLoot({ payload: { loot_option: farmSettings.loot_option } });
|
||||
pushState();
|
||||
}
|
||||
}
|
||||
|
||||
if (cmdData.sync_requested) {
|
||||
log('Sync requested by server — pushing state immediately');
|
||||
@@ -822,33 +931,117 @@
|
||||
const execute = async (cmd) => {
|
||||
if (!cmd) return;
|
||||
log(`Executing command #${cmd.id} — type:${cmd.type} town:${cmd.town_id}`);
|
||||
if (paused) {
|
||||
log(`[Paused] Ignoring command #${cmd.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
if (cmd.type === 'build') result = await executeBuild(cmd);
|
||||
else if (cmd.type === 'recruit') result = await executeRecruit(cmd);
|
||||
else if (cmd.type === 'market_offer') result = await executeMarketOffer(cmd);
|
||||
else if (cmd.type === 'research') result = await executeResearch(cmd);
|
||||
else if (cmd.type === 'farm_loot') result = await executeFarmLoot(cmd);
|
||||
else if (cmd.type === 'farm_upgrade') result = await executeFarmUpgrade(cmd);
|
||||
else if (cmd.type === 'culture') result = await executeCultureCommand(cmd);
|
||||
else result = { ok: false, msg: `Unknown type: ${cmd.type}` };
|
||||
} catch (e) {
|
||||
result = { ok: false, msg: `Exception: ${e.message}` };
|
||||
result = { ok: false, msg: `Exception: ${e}` };
|
||||
}
|
||||
const finalStatus = result.requeue ? 'pending' : (result.ok ? 'done' : 'failed');
|
||||
log(`Command #${cmd.id}: ${finalStatus === 'done' ? '✅' : finalStatus === 'pending' ? '⏳' : '❌'} ${result.msg}`);
|
||||
reportResult(cmd.id, finalStatus, result.msg);
|
||||
};
|
||||
|
||||
// Run concurrently — they do NOT block each other
|
||||
await Promise.all([execute(buildCmd), execute(recruitCmd), execute(marketCmd)]);
|
||||
if (farmCmd) await execute(farmCmd);
|
||||
if (farmUpgradeCmd) await execute(farmUpgradeCmd);
|
||||
// Execute ALL town build commands (one per town, sequential with inter-town delay)
|
||||
for (let i = 0; i < buildCmds.length; i++) {
|
||||
await execute(buildCmds[i]);
|
||||
if (i < buildCmds.length - 1) {
|
||||
// Random gap between towns so it doesn't look like a macro
|
||||
const gap = randInt(1500, 3000);
|
||||
log(`Build: town done. Waiting ${gap}ms before next town...`);
|
||||
await sleep(gap);
|
||||
}
|
||||
}
|
||||
await execute(recruitCmd);
|
||||
await execute(marketCmd);
|
||||
await execute(researchCmd);
|
||||
await execute(farmCmd);
|
||||
await execute(farmUpgradeCmd);
|
||||
await execute(cultureCmd);
|
||||
|
||||
// Auto-farm: if enabled, claim all ready farms (no explicit command needed)
|
||||
const farmSettings = cmdData.farm_settings || {};
|
||||
if (farmSettings.enabled && !farmCmd) {
|
||||
const nowTs = Math.floor(Date.now() / 1000);
|
||||
// Check if ANY farm relation is ready
|
||||
let readyFarms = [];
|
||||
try {
|
||||
const coll = uw.MM.getOnlyCollectionByName('FarmTownPlayerRelation');
|
||||
readyFarms = coll?.models?.filter(r =>
|
||||
r.attributes.relation_status === 1 &&
|
||||
(r.attributes.lootable_at || 0) <= nowTs
|
||||
) || [];
|
||||
} catch (e) { /* silent */ }
|
||||
|
||||
if (readyFarms.length > 0) {
|
||||
// Check if the CURRENT town's warehouse is full (>95%)
|
||||
let allFull = true;
|
||||
let claimedAny = false;
|
||||
|
||||
// Iterate over all towns that have ready farms
|
||||
const towns = Object.values(uw.ITowns?.towns || {});
|
||||
for (const town of towns) {
|
||||
const storage = town.resources?.()?.storage || town.get?.('storage') || 0;
|
||||
const wood = town.resources?.()?.wood || town.get?.('wood') || 0;
|
||||
const stone = town.resources?.()?.stone || town.get?.('stone') || 0;
|
||||
const iron = town.resources?.()?.iron || town.get?.('iron') || 0;
|
||||
if (!storage) continue;
|
||||
|
||||
const maxRes = Math.max(wood, stone, iron);
|
||||
const pct = maxRes / storage;
|
||||
|
||||
if (pct < 0.95) {
|
||||
// This town has room — loot using its town_id context
|
||||
allFull = false;
|
||||
log(`⚡ Auto-farm: looting into town ${town.get?.('name')} (${Math.round(pct*100)}% full)`);
|
||||
await executeFarmLoot({ payload: { loot_option: farmSettings.loot_option } });
|
||||
claimedAny = true;
|
||||
pushState();
|
||||
break; // one loot pass is enough per poll cycle
|
||||
}
|
||||
}
|
||||
|
||||
if (allFull) {
|
||||
log('⚠️ Auto-farm: ALL warehouses are full (>95%) — skipping loot this cycle');
|
||||
// Report full status to backend so farm.html can show notice
|
||||
try {
|
||||
await fetch(`${BASE_URL}/api/farm_status?player_id=${uw.Game.player_id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ warehouse_full: true })
|
||||
});
|
||||
} catch(e) {}
|
||||
} else if (claimedAny) {
|
||||
// Clear the full flag
|
||||
try {
|
||||
await fetch(`${BASE_URL}/api/farm_status?player_id=${uw.Game.player_id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ warehouse_full: false })
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Boot
|
||||
// ----------------------------------------------------------------
|
||||
window.addEventListener('load', () => {
|
||||
log('Grepolis Remote Control v3.5 loaded');
|
||||
log('Grepolis Remote Control v3.5.9 loaded');
|
||||
|
||||
// Start captcha watcher immediately
|
||||
detectCaptcha();
|
||||
|
||||
@@ -61,4 +61,4 @@ By default, the server runs on `http://localhost:5050` (or your configured domai
|
||||
This is an automation tool. Using scripts like this may violate the game's Terms of Service. Use responsibly and at your own risk.
|
||||
|
||||
---
|
||||
*Created with ❤️ for Grepolis players.*
|
||||
*Created with ❤️ for Grepolis players.* . .
|
||||
|
||||
245
abandoned_features/07_attack_planner.js
Normal file
245
abandoned_features/07_attack_planner.js
Normal file
@@ -0,0 +1,245 @@
|
||||
// ================================================================
|
||||
// 07_attack_planner.js — Coordinated Timed Attack Executor
|
||||
// Depends on: 00_config.js (BASE_URL, apiFetch, log, sleep)
|
||||
//
|
||||
// Flow:
|
||||
// 1. Every ~10s polls /api/<world>/attack_plans/active
|
||||
// 2. For each active participant assigned to this player:
|
||||
// a. Measures clock offset via /api/server_time (once per plan)
|
||||
// b. Sets a precision countdown using setTimeout
|
||||
// c. Shows a toolbar countdown badge in Grepolis
|
||||
// d. At T=0: opens attack window, pre-fills units, fires
|
||||
// e. Reports status back to backend
|
||||
//
|
||||
// Code borrowed from: attack-planner.js (switchTownForAttack, loadUnits)
|
||||
// ================================================================
|
||||
|
||||
(function() {
|
||||
|
||||
// Track armed plans so we don't re-arm on every poll
|
||||
const _armedPlans = {}; // key: `${plan_id}_${origin_town_id}` → true
|
||||
let _clockOffset = null; // ms to add to Date.now() to get server time
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _measureClockOffset — one GET to /api/server_time, measures drift
|
||||
// Returns offset in ms (server_ms - client_ms at midpoint)
|
||||
// ----------------------------------------------------------------
|
||||
async function _measureClockOffset() {
|
||||
try {
|
||||
const before = Date.now();
|
||||
const res = await apiFetch(`${BASE_URL}/api/server_time`);
|
||||
const after = Date.now();
|
||||
const data = await res.json();
|
||||
const latency = (after - before) / 2;
|
||||
_clockOffset = data.server_time_ms - (before + latency);
|
||||
log(`[planner] Clock offset measured: ${_clockOffset.toFixed(0)}ms`);
|
||||
} catch (e) {
|
||||
_clockOffset = 0;
|
||||
log(`[planner] Clock sync failed, using 0 offset: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _serverNow — current time adjusted by measured clock offset
|
||||
// ----------------------------------------------------------------
|
||||
function _serverNow() {
|
||||
return Date.now() + (_clockOffset || 0);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _openAttackWindow — borrowed from attack-planner.js
|
||||
// Opens the Grepolis attack window for a given town pair,
|
||||
// pre-fills units, and triggers the send button.
|
||||
// This is identical to what a human player does manually.
|
||||
// ----------------------------------------------------------------
|
||||
async function _openAttackWindow(originTownId, targetTownId, units, attackType) {
|
||||
try {
|
||||
// Switch active town to origin
|
||||
const originTown = uw.ITowns?.towns?.[originTownId];
|
||||
if (!originTown) throw new Error(`Town ${originTownId} not found`);
|
||||
|
||||
if (uw.ITowns.getCurrentTown()?.id !== originTownId) {
|
||||
uw.ITowns.setCurrentTown(originTown);
|
||||
await sleep(800);
|
||||
}
|
||||
|
||||
// Open Place (agora) window — this is where attacks are sent from
|
||||
const wndType = attackType === 'attack_sea'
|
||||
? uw.GPWindowMgr.TYPE_PLACE
|
||||
: uw.GPWindowMgr.TYPE_PLACE;
|
||||
|
||||
uw.GPWindowMgr.Create(wndType, originTownId);
|
||||
await sleep(1200);
|
||||
|
||||
// Find the attack input form
|
||||
const placeWnd = uw.GPWindowMgr.getOpenedWindow(wndType);
|
||||
if (!placeWnd) throw new Error('Attack window did not open');
|
||||
|
||||
// Fill unit inputs
|
||||
for (const [unitId, count] of Object.entries(units)) {
|
||||
if (!count || count <= 0) continue;
|
||||
const input = placeWnd.getJQElement()
|
||||
.find(`input[name="${unitId}"], input[id="${unitId}"]`)
|
||||
.first();
|
||||
if (input.length) {
|
||||
input.val(count).trigger('change').trigger('input');
|
||||
}
|
||||
}
|
||||
await sleep(400);
|
||||
|
||||
log(`[planner] ✅ Attack window filled for town ${originTownId}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
log(`[planner] ❌ Failed to open attack window: ${e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _reportStatus — tell backend what happened
|
||||
// ----------------------------------------------------------------
|
||||
async function _reportStatus(worldId, planId, townId, status) {
|
||||
try {
|
||||
const player_id = uw.Game?.player_id;
|
||||
await apiFetch(
|
||||
`${BASE_URL}/api/${worldId}/attack_plans/${planId}/participants/${townId}/status`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status, player_id }),
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
log(`[planner] reportStatus failed: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _armParticipant — set up the countdown for one attack
|
||||
// ----------------------------------------------------------------
|
||||
async function _armParticipant(plan, worldId) {
|
||||
const key = `${plan.plan_id}_${plan.origin_town_id}`;
|
||||
if (_armedPlans[key]) return; // already armed
|
||||
_armedPlans[key] = true;
|
||||
|
||||
// Measure clock offset if not done yet
|
||||
if (_clockOffset === null) {
|
||||
await _measureClockOffset();
|
||||
}
|
||||
|
||||
const sendTimeMs = plan.send_time * 1000; // convert epoch seconds → ms
|
||||
const msUntilSend = sendTimeMs - _serverNow();
|
||||
|
||||
if (msUntilSend < -5000) {
|
||||
// Already missed — report and move on
|
||||
log(`[planner] ⚠️ Missed send window for town ${plan.origin_town_name} (${msUntilSend}ms late)`);
|
||||
await _reportStatus(worldId, plan.plan_id, plan.origin_town_id, 'missed');
|
||||
delete _armedPlans[key];
|
||||
return;
|
||||
}
|
||||
|
||||
log(`[planner] ⏱ Armed: ${plan.origin_town_name} → ${plan.target_town_name} in ${(msUntilSend/1000).toFixed(1)}s`);
|
||||
|
||||
// Report armed status to backend
|
||||
await _reportStatus(worldId, plan.plan_id, plan.origin_town_id, 'armed');
|
||||
|
||||
// Update toolbar badge
|
||||
_updateBadge(plan.origin_town_name, plan.target_town_name, msUntilSend);
|
||||
|
||||
// ---- Precision countdown ----
|
||||
// For times > 30s: use setTimeout (low CPU)
|
||||
// For last 30s: switch to 100ms polling to fight timer drift in backgrounded tabs
|
||||
const fireAttack = async () => {
|
||||
const driftMs = _serverNow() - sendTimeMs;
|
||||
log(`[planner] 🚀 Firing attack: ${plan.origin_town_name} → ${plan.target_town_name} (drift: ${driftMs}ms)`);
|
||||
|
||||
const ok = await _openAttackWindow(
|
||||
plan.origin_town_id,
|
||||
plan.target_town_id || null,
|
||||
plan.units || {},
|
||||
plan.attack_type
|
||||
);
|
||||
|
||||
await _reportStatus(
|
||||
worldId, plan.plan_id, plan.origin_town_id,
|
||||
ok ? 'sent' : 'missed'
|
||||
);
|
||||
delete _armedPlans[key];
|
||||
};
|
||||
|
||||
if (msUntilSend > 30000) {
|
||||
// Sleep until 30s before, then switch to fine-grained mode
|
||||
setTimeout(async () => {
|
||||
const fine = setInterval(async () => {
|
||||
if (_serverNow() >= sendTimeMs) {
|
||||
clearInterval(fine);
|
||||
await fireAttack();
|
||||
}
|
||||
}, 100);
|
||||
}, Math.max(0, msUntilSend - 30000));
|
||||
} else {
|
||||
// Already within 30s — go straight to fine-grained
|
||||
const fine = setInterval(async () => {
|
||||
if (_serverNow() >= sendTimeMs) {
|
||||
clearInterval(fine);
|
||||
await fireAttack();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _updateBadge — shows a toolbar countdown (reuses existing GRC indicator)
|
||||
// ----------------------------------------------------------------
|
||||
function _updateBadge(originName, targetName, msLeft) {
|
||||
const secs = Math.ceil(msLeft / 1000);
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
const cd = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
||||
log(`[planner] 🎯 ${originName} → ${targetName} T-${cd}`);
|
||||
// The toolbar element is managed by 01_ui.js — we just log for now.
|
||||
// A future update can inject a dedicated DOM widget.
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// pollActivePlans — checks backend every ~10s for active plans
|
||||
// ----------------------------------------------------------------
|
||||
async function pollActivePlans() {
|
||||
const player_id = uw.Game?.player_id;
|
||||
const world_id = uw.Game?.world_id;
|
||||
if (!player_id || !world_id) return;
|
||||
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`${BASE_URL}/api/${world_id}/attack_plans/active?player_id=${player_id}`
|
||||
);
|
||||
const plans = await res.json();
|
||||
|
||||
if (!Array.isArray(plans) || plans.length === 0) return;
|
||||
|
||||
log(`[planner] Found ${plans.length} active plan participant(s)`);
|
||||
for (const plan of plans) {
|
||||
await _armParticipant(plan, world_id);
|
||||
}
|
||||
} catch (e) {
|
||||
log(`[planner] Poll failed: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// initAttackPlanner — called from boot()
|
||||
// ----------------------------------------------------------------
|
||||
function initAttackPlanner() {
|
||||
// Start polling after 8s (game models settled)
|
||||
setTimeout(() => {
|
||||
pollActivePlans();
|
||||
// Then poll every 10-15s with jitter
|
||||
jitterLoop(pollActivePlans, 10000, 15000);
|
||||
log('[planner] ✅ Attack planner module active');
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
window._grcInitAttackPlanner = initAttackPlanner;
|
||||
|
||||
})();
|
||||
462
abandoned_features/attack_planner.html
Normal file
462
abandoned_features/attack_planner.html
Normal file
@@ -0,0 +1,462 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="el">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>Attack Planner — Grepolis Remote</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--bg:#0f0f1a;--surf:#181824;--border:#2a2a40;--text:#e0e0e0;--muted:#666;
|
||||
--gold:#c8a44a;--red:#e05555;--green:#4acc64;--blue:#6fcfcf;--yellow:#f0c040;--orange:#e07830}
|
||||
body{min-height:100vh;background:var(--bg);font-family:'Inter',sans-serif;color:var(--text);padding:1.5rem 2rem}
|
||||
.topbar{display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem}
|
||||
.topbar h1{font-size:1.6rem;font-weight:800;background:linear-gradient(135deg,#c8a44a,#f0c96e);
|
||||
-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;flex:1}
|
||||
.back{color:var(--muted);text-decoration:none;font-size:.85rem}
|
||||
.back:hover{color:var(--text)}
|
||||
.layout{display:grid;grid-template-columns:340px 1fr;gap:1.5rem;align-items:start}
|
||||
@media(max-width:900px){.layout{grid-template-columns:1fr}}
|
||||
.card{background:var(--surf);border:1px solid var(--border);border-radius:14px;padding:1.25rem;margin-bottom:1rem}
|
||||
.ct{font-size:.9rem;font-weight:700;color:var(--gold);margin-bottom:.85rem;padding-bottom:.6rem;border-bottom:1px solid var(--border)}
|
||||
label{display:block;font-size:.78rem;color:var(--muted);margin-bottom:.25rem;margin-top:.65rem}
|
||||
label:first-of-type{margin-top:0}
|
||||
input,select{width:100%;padding:8px 11px;background:#0f0f1a;border:1px solid var(--border);
|
||||
border-radius:7px;color:var(--text);font-size:.85rem;font-family:inherit}
|
||||
input:focus,select:focus{outline:none;border-color:var(--gold)}
|
||||
select option{background:#181824}
|
||||
.btn{padding:8px 16px;border:none;border-radius:7px;font-family:inherit;font-weight:600;font-size:.82rem;cursor:pointer;transition:all .2s}
|
||||
.btn-gold{background:var(--gold);color:#0f0f1a}.btn-gold:hover{background:#e0b85a}
|
||||
.btn-red{background:rgba(224,85,85,.15);color:var(--red);border:1px solid rgba(224,85,85,.35)}.btn-red:hover{background:rgba(224,85,85,.25)}
|
||||
.btn-green{background:rgba(74,204,100,.15);color:var(--green);border:1px solid rgba(74,204,100,.35)}.btn-green:hover{background:rgba(74,204,100,.25)}
|
||||
.btn-blue{background:rgba(111,207,207,.12);color:var(--blue);border:1px solid rgba(111,207,207,.3)}.btn-blue:hover{background:rgba(111,207,207,.22)}
|
||||
.btn-sm{padding:4px 11px;font-size:.75rem}
|
||||
.mt{margin-top:.65rem}
|
||||
.sep{height:1px;background:var(--border);margin:.85rem 0}
|
||||
table{width:100%;border-collapse:collapse;font-size:.8rem}
|
||||
th{padding:7px 10px;text-align:left;color:var(--muted);font-size:.7rem;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--border)}
|
||||
td{padding:8px 10px;border-bottom:1px solid rgba(255,255,255,.04);vertical-align:middle}
|
||||
tr:last-child td{border-bottom:none}
|
||||
.plan-row{cursor:pointer}.plan-row:hover td{background:rgba(200,164,74,.04)}
|
||||
.plan-row.selected td{background:rgba(200,164,74,.08)}
|
||||
.badge{display:inline-block;padding:2px 7px;border-radius:5px;font-size:.7rem;font-weight:700}
|
||||
.badge-draft{background:rgba(102,102,102,.2);color:#888;border:1px solid #444}
|
||||
.badge-active{background:rgba(74,204,100,.15);color:var(--green);border:1px solid rgba(74,204,100,.3)}
|
||||
.badge-cancelled{background:rgba(224,85,85,.1);color:var(--red);border:1px solid rgba(224,85,85,.2)}
|
||||
.badge-completed{background:rgba(111,207,207,.12);color:var(--blue);border:1px solid rgba(111,207,207,.3)}
|
||||
.badge-pending{background:rgba(240,192,64,.1);color:var(--yellow);border:1px solid rgba(240,192,64,.3)}
|
||||
.badge-armed{background:rgba(224,120,48,.12);color:var(--orange);border:1px solid rgba(224,120,48,.3)}
|
||||
.badge-sent{background:rgba(74,204,100,.15);color:var(--green);border:1px solid rgba(74,204,100,.3)}
|
||||
.badge-missed{background:rgba(224,85,85,.15);color:var(--red);border:1px solid rgba(224,85,85,.3)}
|
||||
.empty{text-align:center;padding:1.5rem;color:var(--muted);font-size:.85rem}
|
||||
.err{background:rgba(224,85,85,.1);border:1px solid rgba(224,85,85,.3);color:var(--red);padding:9px 13px;border-radius:7px;font-size:.8rem;margin-top:.65rem}
|
||||
.ok{background:rgba(111,207,207,.08);border:1px solid rgba(111,207,207,.25);color:var(--blue);padding:9px 13px;border-radius:7px;font-size:.8rem;margin-top:.65rem}
|
||||
.warn{background:rgba(240,192,64,.07);border:1px solid rgba(240,192,64,.25);color:var(--yellow);padding:9px 13px;border-radius:7px;font-size:.8rem;margin-top:.5rem}
|
||||
.unit-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(115px,1fr));gap:.4rem;margin-top:.5rem}
|
||||
.ui{display:flex;flex-direction:column;gap:2px}
|
||||
.ui label{font-size:.7rem;margin:0}
|
||||
.ui input{padding:5px 7px;font-size:.8rem}
|
||||
.town-meta{font-size:.73rem;color:var(--muted);margin-top:3px}
|
||||
.plan-header{display:flex;gap:.5rem;align-items:flex-start;flex-wrap:wrap;margin-bottom:.85rem}
|
||||
.plan-stat{background:#0f0f1a;border:1px solid var(--border);border-radius:8px;padding:8px 13px;font-size:.78rem;flex:1;min-width:120px}
|
||||
.plan-stat strong{display:block;font-size:1rem;color:var(--gold);font-weight:700}
|
||||
.cd{font-family:monospace;font-weight:700;color:var(--yellow)}
|
||||
.fok{color:var(--green)}.ferr{color:var(--red)}
|
||||
.section-label{font-size:.78rem;font-weight:700;color:var(--gold);margin-bottom:.4rem}
|
||||
#msg{display:none;margin-top:.65rem}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
window.__GRC_CLAN_KEY = "{{ clan_key }}";
|
||||
window.PLAYER_ID = "{{ player_id }}";
|
||||
window.WORLD_ID = "{{ world_id }}";
|
||||
</script>
|
||||
|
||||
<div class="topbar">
|
||||
<a href="/player/{{ player_id }}/{{ world_id }}" class="back">← Hub</a>
|
||||
<h1>⚔️ Attack Planner — {{ world_id }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
|
||||
<!-- ═══════════════ LEFT: Plan list + Create ═══════════════ -->
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="ct">➕ Νέο Πλάνο</div>
|
||||
<label>Όνομα Πλάνου</label>
|
||||
<input id="p-name" placeholder="π.χ. Επίθεση στον Leonidas">
|
||||
<label>Στόχος (όνομα)</label>
|
||||
<input id="p-tname" placeholder="π.χ. Athens Colony">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem">
|
||||
<div><label>Στόχος X</label><input id="p-tx" type="number" min="0" max="999"></div>
|
||||
<div><label>Στόχος Y</label><input id="p-ty" type="number" min="0" max="999"></div>
|
||||
</div>
|
||||
<label>Ώρα Άφιξης (τοπική)</label>
|
||||
<input id="p-arr" type="datetime-local">
|
||||
<div class="warn">⏱ Τουλάχιστον 2 λεπτά στο μέλλον</div>
|
||||
<div class="mt"><button class="btn btn-gold" onclick="createPlan()">Δημιουργία →</button></div>
|
||||
<div id="msg"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ct">📋 Πλάνα — {{ world_id }}</div>
|
||||
<div id="plans-list"><div class="empty">Φόρτωση…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════ RIGHT: Plan detail + Add participant ═══════════════ -->
|
||||
<div id="right-panel">
|
||||
<div class="card" style="border-color:#2a3060">
|
||||
<div class="empty" style="color:#444">← Επίλεξε πλάνο για λεπτομέρειες</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const PID = window.PLAYER_ID;
|
||||
const WID = window.WORLD_ID;
|
||||
const KEY = window.__GRC_CLAN_KEY;
|
||||
let selPlan = null;
|
||||
let allTowns = []; // all clan towns for this world
|
||||
|
||||
function hdrs(){ return {'Content-Type':'application/json','X-Clan-Key':KEY}; }
|
||||
function ts(u){ return u ? new Date(u*1000).toLocaleString('el-GR') : '–'; }
|
||||
function badge(s){ return `<span class="badge badge-${s}">${s}</span>`; }
|
||||
function showMsg(el,txt,err){el.style.display='block';el.className=err?'err':'ok';el.textContent=txt;}
|
||||
|
||||
// ─── unit groups ───
|
||||
const LAND = ['swordsman','slinger','archer','hoplite','horseman','chariot','catapult'];
|
||||
const NAVAL = ['bireme','attack_ship','demolition_ship','transport_ship','colonize_ship'];
|
||||
|
||||
// ─── Load all clan towns for this world ───
|
||||
async function loadClanTowns(){
|
||||
try{
|
||||
const r = await fetch(`/dashboard/clan-towns?world_id=${WID}`);
|
||||
allTowns = await r.json();
|
||||
}catch(e){ allTowns=[]; }
|
||||
}
|
||||
|
||||
// ─── Load plans list ───
|
||||
async function loadPlans(){
|
||||
const r = await fetch(`/api/${WID}/attack_plans`);
|
||||
const data = await r.json();
|
||||
const el = document.getElementById('plans-list');
|
||||
if(!Array.isArray(data)||!data.length){
|
||||
el.innerHTML='<div class="empty">Δεν υπάρχουν πλάνα.</div>'; return;
|
||||
}
|
||||
let h=`<table><thead><tr><th>Πλάνο</th><th>Στόχος</th><th>Άφιξη</th><th>Status</th><th>Συμμ.</th></tr></thead><tbody>`;
|
||||
for(const p of data){
|
||||
const sel = selPlan===p.id ? ' selected':'';
|
||||
h+=`<tr class="plan-row${sel}" onclick="selectPlan(${p.id})">
|
||||
<td><strong>${p.plan_name}</strong></td>
|
||||
<td style="font-size:.75rem">${p.target_town_name||'–'} ${p.target_x?`(${p.target_x},${p.target_y})`:''}</td>
|
||||
<td style="font-size:.72rem">${ts(p.target_arrival_time)}</td>
|
||||
<td>${badge(p.status)}</td>
|
||||
<td style="text-align:center">${p.participant_count}</td>
|
||||
</tr>`;
|
||||
}
|
||||
h+='</tbody></table>';
|
||||
el.innerHTML=h;
|
||||
}
|
||||
|
||||
// ─── Select plan ───
|
||||
window.selectPlan = async function(id){
|
||||
selPlan = id;
|
||||
await loadPlans();
|
||||
await renderPlanDetail(id);
|
||||
};
|
||||
|
||||
async function renderPlanDetail(id){
|
||||
const r = await fetch(`/api/${WID}/attack_plans/${id}`);
|
||||
const plan = await r.json();
|
||||
if(plan.error){ return; }
|
||||
|
||||
const parts = plan.participants||[];
|
||||
const isDraft = plan.status==='draft';
|
||||
|
||||
// Header stats
|
||||
let hdr = `
|
||||
<div class="plan-stat"><span style="color:var(--muted);font-size:.7rem">ΣΤΟΧΟΣ</span>
|
||||
<strong>${plan.target_town_name||'–'}</strong>
|
||||
<span style="font-size:.75rem;color:var(--muted)">(${plan.target_x||'?'}, ${plan.target_y||'?'})</span>
|
||||
</div>
|
||||
<div class="plan-stat"><span style="color:var(--muted);font-size:.7rem">ΑΦΙΞΗ</span>
|
||||
<strong style="font-size:.85rem">${ts(plan.target_arrival_time)}</strong>
|
||||
</div>
|
||||
<div class="plan-stat"><span style="color:var(--muted);font-size:.7rem">STATUS</span>
|
||||
<strong>${badge(plan.status)}</strong>
|
||||
</div>
|
||||
<div class="plan-stat"><span style="color:var(--muted);font-size:.7rem">ΣΥΜΜΕΤΕΧΟΝΤΕΣ</span>
|
||||
<strong>${parts.length}</strong>
|
||||
</div>`;
|
||||
|
||||
// Action buttons
|
||||
let btns = '';
|
||||
if(isDraft){
|
||||
btns=`<button class="btn btn-green btn-sm" onclick="armPlan(${id})">▶ ARM</button>`;
|
||||
}
|
||||
btns+=` <button class="btn btn-red btn-sm" onclick="cancelPlan(${id})">✕ Ακύρωση</button>`;
|
||||
|
||||
// Participants table
|
||||
let ptable = '<div class="empty">Χωρίς συμμετέχοντες ακόμη.</div>';
|
||||
if(parts.length){
|
||||
let latest = 0;
|
||||
let ptrows = '';
|
||||
for(const p of parts){
|
||||
if(p.return_time>latest) latest=p.return_time;
|
||||
const units = p.units ? Object.entries(p.units).filter(([,v])=>v>0).map(([k,v])=>`${k}:${v}`).join(', ') : '–';
|
||||
ptrows+=`<tr>
|
||||
<td>
|
||||
<div style="font-weight:600">${p.origin_town_name||p.origin_town_id}</div>
|
||||
<div style="font-size:.72rem;color:var(--muted)">${p.player_name||p.player_id||''}</div>
|
||||
</td>
|
||||
<td style="font-size:.72rem;color:var(--blue)">${units||'–'}</td>
|
||||
<td>${p.transport_needed?`🚢 ${p.transport_count}`:'–'}</td>
|
||||
<td style="font-size:.72rem">${ts(p.send_time)}</td>
|
||||
<td style="font-size:.72rem">${ts(p.return_time)}</td>
|
||||
<td>${badge(p.status)}</td>
|
||||
<td>${p.is_feasible?'<span class="fok">✅</span>':`<span class="ferr" title="${p.error_msg||''}">❌</span>`}</td>
|
||||
<td><button class="btn btn-red btn-sm" onclick="removeParticipant(${id},'${p.origin_town_id}')">✕</button></td>
|
||||
</tr>`;
|
||||
}
|
||||
ptable=`
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Πόλη / Παίκτης</th><th>Στρατός</th><th>Πλοία</th>
|
||||
<th>Αποστολή</th><th>Επιστροφή</th><th>Status</th><th>OK</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>${ptrows}</tbody>
|
||||
</table>`;
|
||||
if(latest){
|
||||
ptable+=`<div class="ok" style="margin-top:.65rem">🏠 Τελευταία επιστροφή: <strong>${ts(latest)}</strong></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add participant form
|
||||
const addForm = buildAddForm(id, isDraft);
|
||||
|
||||
document.getElementById('right-panel').innerHTML=`
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.85rem">
|
||||
<div class="ct" style="margin:0;border:none;padding:0">📌 ${plan.plan_name}</div>
|
||||
<div style="display:flex;gap:.4rem">${btns}</div>
|
||||
</div>
|
||||
<div class="plan-header">${hdr}</div>
|
||||
<div class="sep"></div>
|
||||
<div class="section-label">👥 Συμμετέχοντες</div>
|
||||
${ptable}
|
||||
</div>
|
||||
${isDraft ? addForm : ''}
|
||||
`;
|
||||
|
||||
// Populate player dropdown
|
||||
if(isDraft) populatePlayerDropdown();
|
||||
}
|
||||
|
||||
// ─── Build "Add Participant" form ───
|
||||
function buildAddForm(planId, isDraft){
|
||||
if(!isDraft) return '';
|
||||
|
||||
// Build unit inputs (land + naval groups)
|
||||
let landInputs = LAND.map(u=>`
|
||||
<div class="ui"><label>${u}</label>
|
||||
<input type="number" id="u_${u}" min="0" value="0"></div>`).join('');
|
||||
let navalInputs = NAVAL.map(u=>`
|
||||
<div class="ui"><label>${u}</label>
|
||||
<input type="number" id="u_${u}" min="0" value="0"></div>`).join('');
|
||||
|
||||
return `
|
||||
<div class="card" id="add-form">
|
||||
<div class="ct">👤 Προσθήκη Συμμετέχοντα</div>
|
||||
|
||||
<label>Παίκτης</label>
|
||||
<select id="ap-player" onchange="onPlayerChange()">
|
||||
<option value="">— Επίλεξε παίκτη —</option>
|
||||
</select>
|
||||
|
||||
<label>Πόλη</label>
|
||||
<select id="ap-town" onchange="onTownChange()">
|
||||
<option value="">— Επίλεξε πόλη —</option>
|
||||
</select>
|
||||
<div class="town-meta" id="ap-meta"></div>
|
||||
|
||||
<div class="sep"></div>
|
||||
|
||||
<div class="section-label">🗡 Χερσαίος Στρατός</div>
|
||||
<div class="unit-grid">${landInputs}</div>
|
||||
|
||||
<div class="section-label" style="margin-top:.75rem">⚓ Ναυτικός Στρατός</div>
|
||||
<div class="unit-grid">${navalInputs}</div>
|
||||
|
||||
<div class="warn" style="margin-top:.65rem">
|
||||
💡 Αφήσε 0 σε μονάδες που δεν συμμετέχουν. Τα πλοία μεταφοράς υπολογίζονται αυτόματα.
|
||||
</div>
|
||||
|
||||
<div class="mt" style="display:flex;gap:.5rem">
|
||||
<button class="btn btn-gold" onclick="addParticipant(${planId})">Υπολογισμός & Προσθήκη</button>
|
||||
<button class="btn btn-blue" onclick="fillFromGame()">📥 Φόρτωση από game</button>
|
||||
</div>
|
||||
<div id="ap-result"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ─── Populate player dropdown from allTowns ───
|
||||
function populatePlayerDropdown(){
|
||||
const sel = document.getElementById('ap-player');
|
||||
if(!sel) return;
|
||||
const seen = {};
|
||||
allTowns.forEach(t=>{ seen[t.player_id]=t.player; });
|
||||
while(sel.options.length>1) sel.remove(1);
|
||||
for(const [pid,pname] of Object.entries(seen)){
|
||||
const opt=document.createElement('option');
|
||||
opt.value=pid; opt.textContent=pname||pid;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Player change → filter town dropdown ───
|
||||
window.onPlayerChange = function(){
|
||||
const pid = document.getElementById('ap-player').value;
|
||||
const tsel = document.getElementById('ap-town');
|
||||
while(tsel.options.length>1) tsel.remove(1);
|
||||
document.getElementById('ap-meta').textContent='';
|
||||
if(!pid) return;
|
||||
allTowns.filter(t=>t.player_id===pid).forEach(t=>{
|
||||
const opt=document.createElement('option');
|
||||
opt.value=t.town_id;
|
||||
opt.textContent=`${t.town_name} (${t.x}, ${t.y})`;
|
||||
tsel.appendChild(opt);
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Town change → show meta + fill units from game data ───
|
||||
window.onTownChange = function(){
|
||||
const tid = document.getElementById('ap-town').value;
|
||||
const meta = document.getElementById('ap-meta');
|
||||
if(!tid){ meta.textContent=''; return; }
|
||||
const town = allTowns.find(t=>String(t.town_id)===String(tid));
|
||||
if(!town){ return; }
|
||||
meta.innerHTML=`🗺 X:<strong>${town.x}</strong> Y:<strong>${town.y}</strong> Sea:<strong>${town.sea}</strong>`;
|
||||
};
|
||||
|
||||
// ─── Fill units from game data (if available) ───
|
||||
window.fillFromGame = function(){
|
||||
const tid = document.getElementById('ap-town').value;
|
||||
if(!tid){ return; }
|
||||
const town = allTowns.find(t=>String(t.town_id)===String(tid));
|
||||
if(!town||!town.units) return;
|
||||
const all=[...LAND,...NAVAL];
|
||||
all.forEach(u=>{
|
||||
const el=document.getElementById(`u_${u}`);
|
||||
if(el && town.units[u]!==undefined) el.value=town.units[u]||0;
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Add participant ───
|
||||
window.addParticipant = async function(planId){
|
||||
const result=document.getElementById('ap-result');
|
||||
const tid = document.getElementById('ap-town').value;
|
||||
if(!tid){ result.innerHTML='<div class="err">❌ Επίλεξε πόλη</div>'; return; }
|
||||
const town = allTowns.find(t=>String(t.town_id)===String(tid));
|
||||
if(!town){ result.innerHTML='<div class="err">❌ Πόλη δεν βρέθηκε</div>'; return; }
|
||||
|
||||
const units={};
|
||||
[...LAND,...NAVAL].forEach(u=>{
|
||||
const v=parseInt(document.getElementById(`u_${u}`)?.value)||0;
|
||||
if(v>0) units[u]=v;
|
||||
});
|
||||
if(!Object.keys(units).length){ result.innerHTML='<div class="err">❌ Πρόσθεσε τουλάχιστον 1 μονάδα</div>'; return; }
|
||||
|
||||
const body={
|
||||
requester_player_id: PID,
|
||||
player_id: town.player_id,
|
||||
player_name: town.player,
|
||||
origin_town_id: town.town_id,
|
||||
origin_town_name: town.town_name,
|
||||
origin_x: town.x,
|
||||
origin_y: town.y,
|
||||
origin_sea: town.sea,
|
||||
units
|
||||
};
|
||||
|
||||
const r = await fetch(`/api/${WID}/attack_plans/${planId}/participants`,
|
||||
{method:'POST', headers:hdrs(), body:JSON.stringify(body)});
|
||||
const data = await r.json();
|
||||
|
||||
if(data.is_feasible!==undefined){
|
||||
if(data.is_feasible){
|
||||
result.innerHTML=`<div class="ok">
|
||||
✅ Feasible — Χρόνος: ${Math.floor(data.travel_time_secs/60)}λεπτά
|
||||
| Πλοία: ${data.transport_count||0}
|
||||
| Αποστολή: ${ts(data.send_time)}
|
||||
| Επιστροφή: ${ts(data.return_time)}
|
||||
</div>`;
|
||||
} else {
|
||||
result.innerHTML=`<div class="err">❌ ${data.error_msg}</div>`;
|
||||
}
|
||||
renderPlanDetail(planId);
|
||||
} else {
|
||||
result.innerHTML=`<div class="err">❌ ${data.error||'Σφάλμα'}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Remove participant ───
|
||||
window.removeParticipant = async function(planId,townId){
|
||||
if(!confirm('Αφαίρεση;')) return;
|
||||
await fetch(`/api/${WID}/attack_plans/${planId}/participants/${townId}`,
|
||||
{method:'DELETE', headers:hdrs(), body:JSON.stringify({requester_player_id:PID})});
|
||||
renderPlanDetail(planId); loadPlans();
|
||||
};
|
||||
|
||||
// ─── Arm plan ───
|
||||
window.armPlan = async function(id){
|
||||
const r=await fetch(`/api/${WID}/attack_plans/${id}/arm`,
|
||||
{method:'POST', headers:hdrs(), body:JSON.stringify({player_id:PID})});
|
||||
const d=await r.json();
|
||||
alert(d.ok?'✅ Πλάνο ενεργοποιήθηκε!':'❌ '+d.error);
|
||||
renderPlanDetail(id); loadPlans();
|
||||
};
|
||||
|
||||
// ─── Cancel plan ───
|
||||
window.cancelPlan = async function(id){
|
||||
if(!confirm('Ακύρωση πλάνου;')) return;
|
||||
await fetch(`/api/${WID}/attack_plans/${id}/cancel`,
|
||||
{method:'POST', headers:hdrs(), body:JSON.stringify({player_id:PID})});
|
||||
selPlan=null; loadPlans();
|
||||
document.getElementById('right-panel').innerHTML=
|
||||
'<div class="card" style="border-color:#2a3060"><div class="empty" style="color:#444">← Επίλεξε πλάνο για λεπτομέρειες</div></div>';
|
||||
};
|
||||
|
||||
// ─── Create plan ───
|
||||
window.createPlan = async function(){
|
||||
const msg=document.getElementById('msg');
|
||||
const dt=document.getElementById('p-arr').value;
|
||||
if(!dt){ showMsg(msg,'Επίλεξε ώρα άφιξης',true); return; }
|
||||
const r=await fetch(`/api/${WID}/attack_plans`,{method:'POST',headers:hdrs(),body:JSON.stringify({
|
||||
player_id:PID,
|
||||
plan_name:document.getElementById('p-name').value.trim()||'Επίθεση',
|
||||
target_town_name:document.getElementById('p-tname').value.trim(),
|
||||
target_x:parseFloat(document.getElementById('p-tx').value)||null,
|
||||
target_y:parseFloat(document.getElementById('p-ty').value)||null,
|
||||
target_arrival_time:Math.floor(new Date(dt).getTime()/1000)
|
||||
})});
|
||||
const d=await r.json();
|
||||
if(d.ok){ showMsg(msg,`✅ Πλάνο δημιουργήθηκε (ID:${d.plan_id})`,false); loadPlans(); }
|
||||
else showMsg(msg,'❌ '+d.error,true);
|
||||
};
|
||||
|
||||
// ─── Init ───
|
||||
async function init(){
|
||||
await loadClanTowns();
|
||||
await loadPlans();
|
||||
setInterval(loadPlans, 15000);
|
||||
setInterval(()=>{ if(selPlan) renderPlanDetail(selPlan); }, 15000);
|
||||
}
|
||||
init();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
646
abandoned_features/attack_planner.py
Normal file
646
abandoned_features/attack_planner.py
Normal file
@@ -0,0 +1,646 @@
|
||||
"""
|
||||
routes/attack_planner.py — Coordinated Attack Planner Blueprint
|
||||
|
||||
Endpoints:
|
||||
GET /api/server_time Clock sync
|
||||
POST /api/<world>/attack_plans Create plan
|
||||
GET /api/<world>/attack_plans List plans
|
||||
GET /api/<world>/attack_plans/<plan_id> Get plan details
|
||||
POST /api/<world>/attack_plans/<plan_id>/arm Arm plan (lock & activate)
|
||||
POST /api/<world>/attack_plans/<plan_id>/cancel Cancel plan
|
||||
POST /api/<world>/attack_plans/<plan_id>/participants Add participant
|
||||
DELETE /api/<world>/attack_plans/<plan_id>/participants/<town_id> Remove participant
|
||||
POST /api/<world>/attack_plans/<plan_id>/participants/<town_id>/cancel Self-cancel
|
||||
POST /api/<world>/attack_plans/<plan_id>/participants/<town_id>/status Bot status report
|
||||
|
||||
Access control:
|
||||
- 'attack_planner_admin' feature: can create/arm/cancel plans, add participants
|
||||
- 'attack_planner' feature: can view plans, self-cancel, report status
|
||||
- Default new members: neither feature (off by default)
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from db import get_db
|
||||
from datetime import datetime
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
import logging
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
attack_planner = Blueprint('attack_planner', __name__)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/server_time
|
||||
# Used by Tampermonkey to measure clock offset (no auth required,
|
||||
# it's just a timestamp — no sensitive data).
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/server_time', methods=['GET'])
|
||||
def server_time():
|
||||
return jsonify({'server_time_ms': int(time.time() * 1000)})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _get_clan_from_request():
|
||||
key = request.headers.get('X-Clan-Key', '').strip()
|
||||
if not key:
|
||||
return None
|
||||
conn = get_db()
|
||||
clan = conn.execute('SELECT * FROM clans WHERE clan_key = ?', (key,)).fetchone()
|
||||
conn.close()
|
||||
return clan
|
||||
|
||||
|
||||
def _get_member_features(clan_id, player_id):
|
||||
"""Return set of feature strings for this player in this clan."""
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
'SELECT features FROM clan_members WHERE clan_id = ? AND player_id = ?',
|
||||
(clan_id, str(player_id))
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return set()
|
||||
return set(f.strip() for f in (row['features'] or '').split(',') if f.strip())
|
||||
|
||||
|
||||
def _has_feature(clan_id, player_id, feature):
|
||||
return feature in _get_member_features(clan_id, player_id)
|
||||
|
||||
|
||||
def _get_world_data(world_id):
|
||||
"""Fetch world_speed and unit_speeds from kv_store."""
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT value FROM kv_store WHERE key = ?",
|
||||
(f'world_data_{world_id}',)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
return json.loads(row['value'])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _calculate_participant(origin_x, origin_y, origin_sea,
|
||||
target_x, target_y, target_sea,
|
||||
units, world_data):
|
||||
"""
|
||||
Calculate travel_time, send_time, return_time, attack_type,
|
||||
transport_needed, transport_count for one participant.
|
||||
|
||||
Returns dict with all calculated fields, or sets is_feasible=False with error_msg.
|
||||
"""
|
||||
result = {
|
||||
'attack_type': 'attack_land',
|
||||
'transport_needed': False,
|
||||
'transport_count': 0,
|
||||
'travel_time_secs': None,
|
||||
'is_feasible': True,
|
||||
'error_msg': None,
|
||||
}
|
||||
|
||||
if origin_x is None or origin_y is None or target_x is None or target_y is None:
|
||||
result['is_feasible'] = False
|
||||
result['error_msg'] = 'Missing coordinates'
|
||||
return result
|
||||
|
||||
if not world_data:
|
||||
result['is_feasible'] = False
|
||||
result['error_msg'] = 'World speed data not available — wait for client sync'
|
||||
return result
|
||||
|
||||
world_speed = world_data.get('world_speed', 1.0)
|
||||
unit_speeds = world_data.get('unit_speeds', {})
|
||||
|
||||
# Distance (Pythagorean)
|
||||
distance = math.sqrt((target_x - origin_x) ** 2 + (target_y - origin_y) ** 2)
|
||||
|
||||
# Determine attack type: sea if different sea zone
|
||||
is_sea = (origin_sea is not None and target_sea is not None and origin_sea != target_sea)
|
||||
result['attack_type'] = 'attack_sea' if is_sea else 'attack_land'
|
||||
|
||||
# Find slowest unit speed
|
||||
min_speed = None
|
||||
total_land_pop = 0
|
||||
has_units = False
|
||||
|
||||
for unit_id, count in (units or {}).items():
|
||||
if not isinstance(count, int) or count <= 0:
|
||||
continue
|
||||
ud = unit_speeds.get(unit_id)
|
||||
if not ud:
|
||||
continue
|
||||
has_units = True
|
||||
speed = ud.get('speed', 1.0)
|
||||
if min_speed is None or speed < min_speed:
|
||||
min_speed = speed
|
||||
if not ud.get('is_naval', False):
|
||||
total_land_pop += count * ud.get('population', 1)
|
||||
|
||||
if not has_units or min_speed is None:
|
||||
result['is_feasible'] = False
|
||||
result['error_msg'] = 'No valid units selected'
|
||||
return result
|
||||
|
||||
# Travel time: hours = distance / (speed * world_speed)
|
||||
if min_speed * world_speed <= 0:
|
||||
result['is_feasible'] = False
|
||||
result['error_msg'] = 'Invalid speed data'
|
||||
return result
|
||||
|
||||
travel_hours = distance / (min_speed * world_speed)
|
||||
travel_secs = int(travel_hours * 3600)
|
||||
result['travel_time_secs'] = travel_secs
|
||||
|
||||
# Transport calculation for sea attacks with land units
|
||||
if is_sea and total_land_pop > 0:
|
||||
result['transport_needed'] = True
|
||||
transport_cap = 0
|
||||
for uid, ud in unit_speeds.items():
|
||||
if ud.get('capacity', 0) > 0:
|
||||
transport_cap = ud['capacity']
|
||||
break
|
||||
if transport_cap <= 0:
|
||||
result['is_feasible'] = False
|
||||
result['error_msg'] = 'Transport capacity data missing — wait for client sync'
|
||||
return result
|
||||
result['transport_count'] = math.ceil(total_land_pop / transport_cap)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/<world_id>/attack_plans
|
||||
# Create a new plan (draft). Requires attack_planner_admin feature.
|
||||
# Body: { player_id, plan_name, target_town_name, target_x, target_y,
|
||||
# target_sea, target_arrival_time }
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/<world_id>/attack_plans', methods=['POST'])
|
||||
def create_plan(world_id):
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
player_id = str(data.get('player_id', '')).strip()
|
||||
|
||||
if not _has_feature(clan['id'], player_id, 'attack_planner_admin'):
|
||||
return jsonify({'error': 'No attack_planner_admin permission'}), 403
|
||||
|
||||
plan_name = data.get('plan_name', '').strip() or 'Επίθεση'
|
||||
target_name = data.get('target_town_name', '').strip()
|
||||
target_x = data.get('target_x')
|
||||
target_y = data.get('target_y')
|
||||
arrival = data.get('target_arrival_time') # unix epoch int
|
||||
|
||||
if not arrival:
|
||||
return jsonify({'error': 'target_arrival_time required'}), 400
|
||||
|
||||
now = int(time.time())
|
||||
if int(arrival) <= now + 120:
|
||||
return jsonify({'error': 'Arrival time must be at least 2 minutes in the future'}), 400
|
||||
|
||||
conn = get_db()
|
||||
cur = conn.execute('''
|
||||
INSERT INTO attack_plans
|
||||
(world_id, plan_name, created_by_player_id,
|
||||
target_town_name, target_x, target_y,
|
||||
target_arrival_time, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'draft')
|
||||
''', (world_id, plan_name, player_id, target_name, target_x, target_y, int(arrival)))
|
||||
plan_id = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return jsonify({'ok': True, 'plan_id': plan_id})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/<world_id>/attack_plans
|
||||
# List all non-cancelled plans for this world.
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/<world_id>/attack_plans', methods=['GET'])
|
||||
@login_required
|
||||
def list_plans(world_id):
|
||||
conn = get_db()
|
||||
rows = conn.execute('''
|
||||
SELECT ap.*,
|
||||
COUNT(app.id) as participant_count
|
||||
FROM attack_plans ap
|
||||
LEFT JOIN attack_plan_participants app ON app.plan_id = ap.id
|
||||
WHERE ap.world_id = ? AND ap.status != 'cancelled'
|
||||
GROUP BY ap.id
|
||||
ORDER BY ap.target_arrival_time ASC
|
||||
''', (world_id,)).fetchall()
|
||||
conn.close()
|
||||
return jsonify([dict(r) for r in rows])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/<world_id>/attack_plans/<plan_id>
|
||||
# Full plan details including participants.
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/<world_id>/attack_plans/<int:plan_id>', methods=['GET'])
|
||||
@login_required
|
||||
def get_plan(world_id, plan_id):
|
||||
conn = get_db()
|
||||
plan = conn.execute(
|
||||
'SELECT * FROM attack_plans WHERE id = ? AND world_id = ?',
|
||||
(plan_id, world_id)
|
||||
).fetchone()
|
||||
if not plan:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Plan not found'}), 404
|
||||
|
||||
participants = conn.execute(
|
||||
'SELECT * FROM attack_plan_participants WHERE plan_id = ? ORDER BY send_time ASC',
|
||||
(plan_id,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
result = dict(plan)
|
||||
result['participants'] = []
|
||||
for p in participants:
|
||||
row = dict(p)
|
||||
try:
|
||||
row['units'] = json.loads(row['units'])
|
||||
except Exception:
|
||||
row['units'] = {}
|
||||
result['participants'].append(row)
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/<world_id>/attack_plans/<plan_id>/participants
|
||||
# Add a participant town to the plan.
|
||||
# Body: { player_id, origin_town_id, origin_town_name, origin_x, origin_y,
|
||||
# origin_sea, units: {unit_id: count, ...} }
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/<world_id>/attack_plans/<int:plan_id>/participants', methods=['POST'])
|
||||
def add_participant(world_id, plan_id):
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
requester_id = str(data.get('requester_player_id', '')).strip()
|
||||
|
||||
if not _has_feature(clan['id'], requester_id, 'attack_planner_admin'):
|
||||
return jsonify({'error': 'No attack_planner_admin permission'}), 403
|
||||
|
||||
conn = get_db()
|
||||
plan = conn.execute(
|
||||
"SELECT * FROM attack_plans WHERE id = ? AND world_id = ? AND status = 'draft'",
|
||||
(plan_id, world_id)
|
||||
).fetchone()
|
||||
if not plan:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Plan not found or not in draft status'}), 404
|
||||
|
||||
player_id = str(data.get('player_id', '')).strip()
|
||||
origin_town_id = str(data.get('origin_town_id', '')).strip()
|
||||
origin_town_name = data.get('origin_town_name', '')
|
||||
origin_x = data.get('origin_x')
|
||||
origin_y = data.get('origin_y')
|
||||
origin_sea = data.get('origin_sea')
|
||||
units = data.get('units', {})
|
||||
|
||||
if not player_id or not origin_town_id:
|
||||
conn.close()
|
||||
return jsonify({'error': 'player_id and origin_town_id required'}), 400
|
||||
|
||||
# Get target sea from plan (we store it? No — we need to infer from coordinates)
|
||||
# Approximate: sea = floor(x/100)*10 + floor(y/100)
|
||||
target_x = plan['target_x']
|
||||
target_y = plan['target_y']
|
||||
target_sea = None
|
||||
if target_x is not None and target_y is not None:
|
||||
target_sea = int(math.floor(target_x / 100)) * 10 + int(math.floor(target_y / 100))
|
||||
|
||||
world_data = _get_world_data(world_id)
|
||||
calc = _calculate_participant(
|
||||
origin_x, origin_y, origin_sea,
|
||||
target_x, target_y, target_sea,
|
||||
units, world_data
|
||||
)
|
||||
|
||||
arrival = plan['target_arrival_time']
|
||||
send_time = None
|
||||
return_time = None
|
||||
if calc['travel_time_secs'] is not None:
|
||||
send_time = arrival - calc['travel_time_secs']
|
||||
return_time = arrival + calc['travel_time_secs']
|
||||
|
||||
# Validate: send_time must be at least 2 min in future
|
||||
now = int(time.time())
|
||||
if send_time <= now + 120:
|
||||
calc['is_feasible'] = False
|
||||
calc['error_msg'] = (
|
||||
f"Send time is too soon ({(send_time - now)//60}m remaining). "
|
||||
"Choose a later arrival time or remove this town."
|
||||
)
|
||||
|
||||
try:
|
||||
conn.execute('''
|
||||
INSERT INTO attack_plan_participants
|
||||
(plan_id, player_id, world_id, origin_town_id, origin_town_name,
|
||||
units, attack_type, transport_needed, transport_count,
|
||||
travel_time_secs, send_time, return_time, is_feasible, error_msg)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(plan_id, origin_town_id) DO UPDATE SET
|
||||
player_id = excluded.player_id,
|
||||
units = excluded.units,
|
||||
attack_type = excluded.attack_type,
|
||||
transport_needed= excluded.transport_needed,
|
||||
transport_count = excluded.transport_count,
|
||||
travel_time_secs= excluded.travel_time_secs,
|
||||
send_time = excluded.send_time,
|
||||
return_time = excluded.return_time,
|
||||
is_feasible = excluded.is_feasible,
|
||||
error_msg = excluded.error_msg,
|
||||
updated_at = datetime('now')
|
||||
''', (
|
||||
plan_id, player_id, world_id,
|
||||
origin_town_id, origin_town_name,
|
||||
json.dumps(units),
|
||||
calc['attack_type'],
|
||||
1 if calc['transport_needed'] else 0,
|
||||
calc['transport_count'],
|
||||
calc['travel_time_secs'],
|
||||
send_time, return_time,
|
||||
1 if calc['is_feasible'] else 0,
|
||||
calc['error_msg']
|
||||
))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
conn.close()
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'is_feasible': calc['is_feasible'],
|
||||
'error_msg': calc['error_msg'],
|
||||
'attack_type': calc['attack_type'],
|
||||
'transport_count': calc['transport_count'],
|
||||
'travel_time_secs': calc['travel_time_secs'],
|
||||
'send_time': send_time,
|
||||
'return_time': return_time,
|
||||
})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DELETE /api/<world_id>/attack_plans/<plan_id>/participants/<town_id>
|
||||
# Remove a participant (coordinator/admin only, plan must be draft).
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route(
|
||||
'/api/<world_id>/attack_plans/<int:plan_id>/participants/<town_id>',
|
||||
methods=['DELETE']
|
||||
)
|
||||
def remove_participant(world_id, plan_id, town_id):
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
requester_id = str(data.get('requester_player_id', '')).strip()
|
||||
|
||||
if not _has_feature(clan['id'], requester_id, 'attack_planner_admin'):
|
||||
return jsonify({'error': 'No permission'}), 403
|
||||
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
'DELETE FROM attack_plan_participants WHERE plan_id = ? AND origin_town_id = ?',
|
||||
(plan_id, town_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/<world_id>/attack_plans/<plan_id>/participants/<town_id>/cancel
|
||||
# Player self-cancels their own town's participation (if > 2min before send).
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route(
|
||||
'/api/<world_id>/attack_plans/<int:plan_id>/participants/<town_id>/cancel',
|
||||
methods=['POST']
|
||||
)
|
||||
def cancel_participant(world_id, plan_id, town_id):
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
player_id = str(data.get('player_id', '')).strip()
|
||||
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
'''SELECT app.*, ap.status as plan_status
|
||||
FROM attack_plan_participants app
|
||||
JOIN attack_plans ap ON ap.id = app.plan_id
|
||||
WHERE app.plan_id = ? AND app.origin_town_id = ? AND app.player_id = ?''',
|
||||
(plan_id, town_id, player_id)
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Participant not found'}), 404
|
||||
|
||||
now = int(time.time())
|
||||
send_time = row['send_time'] or 0
|
||||
if send_time > 0 and (send_time - now) < 120:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Too close to send time — cannot cancel'}), 400
|
||||
|
||||
conn.execute(
|
||||
"UPDATE attack_plan_participants SET status='cancelled', updated_at=datetime('now') "
|
||||
"WHERE plan_id = ? AND origin_town_id = ?",
|
||||
(plan_id, town_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/<world_id>/attack_plans/<plan_id>/participants/<town_id>/status
|
||||
# Tampermonkey bot reports its status (armed, sent, missed).
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route(
|
||||
'/api/<world_id>/attack_plans/<int:plan_id>/participants/<town_id>/status',
|
||||
methods=['POST']
|
||||
)
|
||||
def report_participant_status(world_id, plan_id, town_id):
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
new_status = data.get('status', '').strip()
|
||||
if new_status not in ('armed', 'sent', 'missed'):
|
||||
return jsonify({'error': 'Invalid status'}), 400
|
||||
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE attack_plan_participants SET status=?, updated_at=datetime('now') "
|
||||
"WHERE plan_id = ? AND origin_town_id = ?",
|
||||
(new_status, plan_id, town_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# If all active participants are sent/missed, mark plan completed
|
||||
pending = conn.execute(
|
||||
"SELECT COUNT(*) as n FROM attack_plan_participants "
|
||||
"WHERE plan_id = ? AND status NOT IN ('sent','missed','cancelled')",
|
||||
(plan_id,)
|
||||
).fetchone()['n']
|
||||
if pending == 0:
|
||||
conn.execute(
|
||||
"UPDATE attack_plans SET status='completed', updated_at=datetime('now') WHERE id=?",
|
||||
(plan_id,)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/<world_id>/attack_plans/<plan_id>/arm
|
||||
# Lock the plan and activate it. Validates all participants feasible.
|
||||
# Requires attack_planner_admin.
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/<world_id>/attack_plans/<int:plan_id>/arm', methods=['POST'])
|
||||
def arm_plan(world_id, plan_id):
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
player_id = str(data.get('player_id', '')).strip()
|
||||
|
||||
if not _has_feature(clan['id'], player_id, 'attack_planner_admin'):
|
||||
return jsonify({'error': 'No permission'}), 403
|
||||
|
||||
conn = get_db()
|
||||
plan = conn.execute(
|
||||
"SELECT * FROM attack_plans WHERE id = ? AND world_id = ? AND status = 'draft'",
|
||||
(plan_id, world_id)
|
||||
).fetchone()
|
||||
if not plan:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Plan not found or not in draft'}), 404
|
||||
|
||||
# Check all participants are feasible and have valid send_times
|
||||
infeasible = conn.execute(
|
||||
"SELECT COUNT(*) as n FROM attack_plan_participants "
|
||||
"WHERE plan_id = ? AND is_feasible = 0 AND status != 'cancelled'",
|
||||
(plan_id,)
|
||||
).fetchone()['n']
|
||||
if infeasible > 0:
|
||||
conn.close()
|
||||
return jsonify({'error': f'{infeasible} participant(s) are not feasible — fix or remove them first'}), 400
|
||||
|
||||
now = int(time.time())
|
||||
# Check earliest send_time is >= 2 min away
|
||||
earliest = conn.execute(
|
||||
"SELECT MIN(send_time) as earliest FROM attack_plan_participants "
|
||||
"WHERE plan_id = ? AND status != 'cancelled'",
|
||||
(plan_id,)
|
||||
).fetchone()['earliest']
|
||||
if earliest and (earliest - now) < 120:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Earliest send time is less than 2 minutes away'}), 400
|
||||
|
||||
conn.execute(
|
||||
"UPDATE attack_plans SET status='active', updated_at=datetime('now') WHERE id=?",
|
||||
(plan_id,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/<world_id>/attack_plans/<plan_id>/cancel
|
||||
# Cancel the entire plan.
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/<world_id>/attack_plans/<int:plan_id>/cancel', methods=['POST'])
|
||||
def cancel_plan(world_id, plan_id):
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
player_id = str(data.get('player_id', '')).strip()
|
||||
|
||||
if not _has_feature(clan['id'], player_id, 'attack_planner_admin'):
|
||||
return jsonify({'error': 'No permission'}), 403
|
||||
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE attack_plans SET status='cancelled', updated_at=datetime('now') "
|
||||
"WHERE id = ? AND world_id = ?",
|
||||
(plan_id, world_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/<world_id>/attack_plans/active
|
||||
# Tampermonkey polls this to get active plans for its towns.
|
||||
# Returns plans where this player has active participants.
|
||||
# ------------------------------------------------------------------
|
||||
@attack_planner.route('/api/<world_id>/attack_plans/active', methods=['GET'])
|
||||
def get_active_plans(world_id):
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
player_id = request.args.get('player_id', '').strip()
|
||||
if not player_id:
|
||||
return jsonify({'error': 'player_id required'}), 400
|
||||
|
||||
conn = get_db()
|
||||
rows = conn.execute('''
|
||||
SELECT ap.id as plan_id, ap.plan_name,
|
||||
ap.target_town_name, ap.target_x, ap.target_y,
|
||||
ap.target_arrival_time, ap.status as plan_status,
|
||||
app.id as participant_id,
|
||||
app.origin_town_id, app.origin_town_name,
|
||||
app.units, app.attack_type,
|
||||
app.transport_count,
|
||||
app.send_time, app.return_time,
|
||||
app.status as participant_status
|
||||
FROM attack_plans ap
|
||||
JOIN attack_plan_participants app ON app.plan_id = ap.id
|
||||
WHERE ap.world_id = ?
|
||||
AND ap.status = 'active'
|
||||
AND app.player_id = ?
|
||||
AND app.status IN ('pending', 'armed')
|
||||
ORDER BY app.send_time ASC
|
||||
''', (world_id, player_id)).fetchall()
|
||||
conn.close()
|
||||
|
||||
result = []
|
||||
for r in rows:
|
||||
row = dict(r)
|
||||
try:
|
||||
row['units'] = json.loads(row['units'])
|
||||
except Exception:
|
||||
row['units'] = {}
|
||||
result.append(row)
|
||||
|
||||
return jsonify(result)
|
||||
57
app.py
57
app.py
@@ -1,33 +1,76 @@
|
||||
from flask import Flask, request, jsonify
|
||||
from flask import Flask, request, jsonify, redirect, url_for
|
||||
from flask_cors import CORS
|
||||
from db import init_db
|
||||
from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
|
||||
from db import init_db, get_db
|
||||
from routes.api import api
|
||||
from routes.dashboard import dashboard
|
||||
from routes.auth import auth
|
||||
from routes.tracker import tracker
|
||||
import logging
|
||||
|
||||
# Initialise DB schema (e.g. creating newly added tables) when the app starts
|
||||
logging.basicConfig(
|
||||
level=logging.WARNING,
|
||||
format='%(asctime)s [%(name)s] %(levelname)s: %(message)s'
|
||||
)
|
||||
|
||||
# Initialise DB schema when the app starts
|
||||
init_db()
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = 'grc-super-secret-key-change-in-production'
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Flask-Login setup
|
||||
# ----------------------------------------------------------------
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Παρακαλώ συνδεθείτε για να συνεχίσετε.'
|
||||
|
||||
class User(UserMixin):
|
||||
def __init__(self, id, username, clan_id):
|
||||
self.id = id
|
||||
self.username = username
|
||||
self.clan_id = clan_id
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
conn = get_db()
|
||||
row = conn.execute('SELECT id, username, clan_id FROM users WHERE id = ?', (user_id,)).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
return User(row['id'], row['username'], row['clan_id'])
|
||||
return None
|
||||
|
||||
# Make current_user available in all templates
|
||||
@app.context_processor
|
||||
def inject_user():
|
||||
return dict(current_user=current_user)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# CORS
|
||||
# ----------------------------------------------------------------
|
||||
CORS(app, resources={r"/*": {"origins": "*"}}, supports_credentials=False)
|
||||
|
||||
# Belt-and-suspenders: inject CORS headers on every response,
|
||||
# including error responses that flask-cors might miss.
|
||||
@app.after_request
|
||||
def add_cors_headers(response):
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, DELETE, OPTIONS'
|
||||
response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
|
||||
response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-Clan-Key'
|
||||
return response
|
||||
|
||||
# Explicitly handle OPTIONS preflight for all routes
|
||||
@app.route('/', defaults={'path': ''}, methods=['OPTIONS'])
|
||||
@app.route('/<path:path>', methods=['OPTIONS'])
|
||||
def handle_options(path):
|
||||
return jsonify({}), 200
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Blueprints
|
||||
# ----------------------------------------------------------------
|
||||
app.register_blueprint(api)
|
||||
app.register_blueprint(dashboard)
|
||||
app.register_blueprint(auth)
|
||||
app.register_blueprint(tracker)
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("✅ Grepolis Remote — DB initialised")
|
||||
|
||||
221
blueprint_engine.py
Normal file
221
blueprint_engine.py
Normal file
@@ -0,0 +1,221 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STANDARD_BLUEPRINT — ordered list of phases.
|
||||
# Each phase is a dict of { building_name: required_level }.
|
||||
# The engine works through phases in order, queueing one building at a time.
|
||||
# ---------------------------------------------------------------------------
|
||||
STANDARD_BLUEPRINT = [
|
||||
{"barracks": 1, "farm": 3, "lumber": 2, "stoner": 2, "ironer": 2, "storage": 2, "main": 2, "temple": 1},
|
||||
{"barracks": 1, "farm": 3, "lumber": 3, "stoner": 3, "ironer": 3, "storage": 6, "main": 8},
|
||||
{"farm": 8, "lumber": 8, "ironer": 8, "stoner": 8, "market": 5, "temple": 5, "barracks": 5},
|
||||
{"academy": 13},
|
||||
{"storage": 12, "farm": 12},
|
||||
{"main": 25},
|
||||
{"storage": 21, "farm": 15},
|
||||
{"lumber": 15, "stoner": 10, "ironer": 12},
|
||||
{"docks": 10},
|
||||
{"academy": 30},
|
||||
{"farm": 20, "storage": 25},
|
||||
{"market": 15, "trade_office": 1, "hide": 10},
|
||||
{"market": 30, "farm": 35, "thermal": 1, "academy": 36},
|
||||
{"farm": 45, "storage": 35, "lumber": 40, "ironer": 40, "stoner": 40},
|
||||
{"temple": 30}
|
||||
]
|
||||
|
||||
RESEARCH_LIST = [
|
||||
"booty", "pottery", "architecture", "building_crane",
|
||||
"shipwright", "plow", "mathematics", "combat_experience",
|
||||
"strong_wine", "take_over", "colonize_ship"
|
||||
]
|
||||
|
||||
RESEARCH_LEVELS = {
|
||||
"booty": 7,
|
||||
"pottery": 7,
|
||||
"architecture": 10,
|
||||
"building_crane": 13,
|
||||
"shipwright": 13,
|
||||
"plow": 22,
|
||||
"mathematics": 25,
|
||||
"combat_experience": 34,
|
||||
"strong_wine": 34,
|
||||
"take_over": 28,
|
||||
"colonize_ship": 13
|
||||
}
|
||||
|
||||
MAX_LOOKAHEAD_PHASES = 2 # How many phases ahead to look if current phase is fully blocked
|
||||
|
||||
|
||||
def evaluate_blueprints(conn):
|
||||
blueprints = conn.execute('SELECT town_id, blueprint_name FROM town_blueprints WHERE is_active = 1').fetchall()
|
||||
log.warning(f"[blueprint] Active blueprints: {len(blueprints)}")
|
||||
if not blueprints:
|
||||
return
|
||||
|
||||
for row in blueprints:
|
||||
town_id = str(row['town_id'])
|
||||
log.warning(f"[blueprint] Evaluating town_id={town_id}")
|
||||
|
||||
town_row = conn.execute(
|
||||
'SELECT data, player_id, town_name, world_id FROM town_state WHERE town_id = ?', (town_id,)
|
||||
).fetchone()
|
||||
|
||||
if not town_row:
|
||||
log.warning(f"[blueprint] No town_state row for town_id={town_id} — skipping")
|
||||
continue
|
||||
|
||||
player_id = town_row['player_id']
|
||||
town_name_db = town_row['town_name']
|
||||
town_world_id = town_row['world_id']
|
||||
log.warning(f"[blueprint] Town: {town_name_db}, player_id={player_id}, world_id={town_world_id!r}")
|
||||
|
||||
try:
|
||||
town = json.loads(town_row['data'])
|
||||
except Exception as e:
|
||||
log.warning(f"[blueprint] Failed to parse town JSON: {e}")
|
||||
continue
|
||||
|
||||
build_queue = town.get('buildingOrder', [])
|
||||
buildings = town.get('buildings', {})
|
||||
build_data = town.get('buildData', {})
|
||||
|
||||
log.warning(f"[blueprint] buildingOrder length: {len(build_queue)}")
|
||||
|
||||
# ── Guard: don't queue if there's already a pending/executing command ──────
|
||||
# Ghost detection: if a 'pending' command has sat untouched for >5 min,
|
||||
# it's stale — delete it so we can re-evaluate fresh.
|
||||
five_min_ago = (datetime.utcnow() - timedelta(minutes=5)).isoformat()
|
||||
db_pending = conn.execute(
|
||||
"SELECT id, type, status, updated_at FROM commands "
|
||||
"WHERE town_id = ? AND type IN ('build', 'research') AND status IN ('pending', 'executing')",
|
||||
(town_id,)
|
||||
).fetchall()
|
||||
|
||||
if db_pending:
|
||||
ghost_ids = [
|
||||
r['id'] for r in db_pending
|
||||
if r['status'] == 'pending' and (r['updated_at'] is None or r['updated_at'] < five_min_ago)
|
||||
]
|
||||
if ghost_ids:
|
||||
log.warning(f"[blueprint] Deleting {len(ghost_ids)} ghost commands {ghost_ids}")
|
||||
conn.execute(
|
||||
f"DELETE FROM commands WHERE id IN ({','.join('?' for _ in ghost_ids)})",
|
||||
ghost_ids
|
||||
)
|
||||
conn.commit()
|
||||
db_pending = conn.execute(
|
||||
"SELECT id, type, status, updated_at FROM commands "
|
||||
"WHERE town_id = ? AND type IN ('build', 'research') AND status IN ('pending', 'executing')",
|
||||
(town_id,)
|
||||
).fetchall()
|
||||
|
||||
if db_pending:
|
||||
details = [(r['id'], r['type'], r['status']) for r in db_pending]
|
||||
log.warning(f"[blueprint] Already has {len(db_pending)} queued commands — skipping. {details}")
|
||||
continue
|
||||
|
||||
# ── Calculate future levels: current buildings + anything in the game build queue ──
|
||||
future_levels = {k: v for k, v in buildings.items()}
|
||||
for q_item in build_queue:
|
||||
b_type = q_item.get('building_type') or q_item.get('name')
|
||||
if b_type:
|
||||
future_levels[b_type] = future_levels.get(b_type, 0) + 1
|
||||
|
||||
log.warning(f"[blueprint] future_levels: {future_levels}")
|
||||
|
||||
# ── Simple phase search ────────────────────────────────────────────────────
|
||||
# Strategy:
|
||||
# 1. Find the first phase that has at least one building below its target.
|
||||
# 2. Queue the first incomplete building from that phase — no resource checks,
|
||||
# no dependency checks, exactly like adding it manually.
|
||||
# 3. If every incomplete building in that phase is flagged has_max_level=True
|
||||
# (meaning the game truly refuses to build it), look up to MAX_LOOKAHEAD_PHASES
|
||||
# phases ahead for something to queue instead.
|
||||
target_building = None
|
||||
lookahead_used = 0
|
||||
first_incomplete_phase_idx = None
|
||||
|
||||
for phase_idx, phase in enumerate(STANDARD_BLUEPRINT):
|
||||
# Collect buildings that still need work in this phase
|
||||
incomplete = [b for b, req in phase.items() if future_levels.get(b, 0) < req]
|
||||
|
||||
if not incomplete:
|
||||
continue # Phase is fully complete, move on
|
||||
|
||||
# Track the very first incomplete phase we encounter
|
||||
if first_incomplete_phase_idx is None:
|
||||
first_incomplete_phase_idx = phase_idx
|
||||
|
||||
log.warning(f"[blueprint] Phase {phase_idx} incomplete: {incomplete}")
|
||||
|
||||
# Separate into: genuinely blocked (has_max=True) vs. queueable
|
||||
blocked = [b for b in incomplete if build_data.get(b, {}).get('has_max_level', False)]
|
||||
queueable = [b for b in incomplete if b not in blocked]
|
||||
|
||||
if queueable:
|
||||
# We are in the current phase (or a valid lookahead phase) — queue it!
|
||||
target_building = queueable[0]
|
||||
log.warning(f"[blueprint] -> SELECTED '{target_building}' from phase {phase_idx}"
|
||||
+ (f" (lookahead +{lookahead_used})" if lookahead_used else ""))
|
||||
break
|
||||
|
||||
# Everything incomplete in this phase is has_max_level blocked.
|
||||
# Allow limited lookahead.
|
||||
if phase_idx == first_incomplete_phase_idx or lookahead_used < MAX_LOOKAHEAD_PHASES:
|
||||
if phase_idx != first_incomplete_phase_idx:
|
||||
lookahead_used += 1
|
||||
log.warning(f"[blueprint] Phase {phase_idx}: all incomplete buildings are has_max — "
|
||||
f"looking ahead (lookahead_used={lookahead_used})")
|
||||
continue
|
||||
|
||||
# Ran out of lookahead budget — stop
|
||||
log.warning(f"[blueprint] Lookahead exhausted after {lookahead_used} extra phases — giving up")
|
||||
break
|
||||
|
||||
# ── Academy Research (fallback when no building target) ───────────────────
|
||||
target_research = None
|
||||
if not target_building:
|
||||
academy_level = future_levels.get('academy', 0)
|
||||
researched = town.get('researches', {})
|
||||
for r_name in RESEARCH_LIST:
|
||||
if not researched.get(r_name):
|
||||
req_level = RESEARCH_LEVELS.get(r_name, 99)
|
||||
if academy_level >= req_level:
|
||||
target_research = r_name
|
||||
log.warning(f"[blueprint] -> Research target: {r_name}")
|
||||
break
|
||||
|
||||
log.warning(f"[blueprint] Final: target_building={target_building}, target_research={target_research}")
|
||||
|
||||
# ── Insert command ─────────────────────────────────────────────────────────
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
if target_building:
|
||||
pos_row = conn.execute(
|
||||
"SELECT MAX(position) as max_pos FROM commands"
|
||||
" WHERE player_id = ? AND town_id = ? AND type = 'build'"
|
||||
" AND status IN ('pending', 'executing')",
|
||||
(str(player_id), str(town_id))
|
||||
).fetchone()
|
||||
position = (pos_row['max_pos'] or 0) + 1
|
||||
payload_str = json.dumps({"building_id": target_building})
|
||||
conn.execute('''
|
||||
INSERT INTO commands (town_id, town_name, type, payload, status, position, created_at, updated_at, player_id)
|
||||
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?)
|
||||
''', (str(town_id), town_name_db, 'build', payload_str, position, now, now, str(player_id)))
|
||||
log.warning(f"[blueprint] Inserted build command: {target_building} for {town_name_db}")
|
||||
|
||||
elif target_research:
|
||||
payload_str = json.dumps({"research_id": target_research})
|
||||
conn.execute('''
|
||||
INSERT INTO commands (town_id, town_name, type, payload, status, created_at, updated_at, player_id)
|
||||
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?)
|
||||
''', (str(town_id), town_name_db, 'research', payload_str, now, now, str(player_id)))
|
||||
log.warning(f"[blueprint] Inserted research command: {target_research} for {town_name_db}")
|
||||
|
||||
else:
|
||||
log.warning(f"[blueprint] Nothing to do for {town_name_db}")
|
||||
44
bot_modules/00_config.js
Normal file
44
bot_modules/00_config.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// ================================================================
|
||||
// 00_config.js — Shared constants and utility helpers
|
||||
// Runs first; everything here is available to all other modules.
|
||||
// ================================================================
|
||||
|
||||
const uw = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
|
||||
const BASE_URL = 'https://grepo.haunter-pets.top';
|
||||
|
||||
// Read the clan key injected by the Loader before eval()
|
||||
const CLAN_KEY = window.__GRC_CLAN_KEY || '';
|
||||
|
||||
// Returns a random integer between min and max (inclusive)
|
||||
function randInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
// Schedules fn to run after a random ms delay, then reschedules itself.
|
||||
// Optional initialDelayMs sets the delay for the very first run only.
|
||||
function jitterLoop(fn, minMs, maxMs, initialDelayMs) {
|
||||
function schedule(delay) {
|
||||
setTimeout(async () => {
|
||||
await fn();
|
||||
schedule(randInt(minMs, maxMs));
|
||||
}, delay);
|
||||
}
|
||||
schedule(initialDelayMs !== undefined ? initialDelayMs : randInt(minMs, maxMs));
|
||||
}
|
||||
|
||||
function log(msg) {
|
||||
console.log(`[GRC] ${msg}`);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// Wrapper around fetch() that automatically injects the X-Clan-Key header
|
||||
// on every request. Use this instead of fetch() everywhere in the bot.
|
||||
function apiFetch(url, options) {
|
||||
options = options || {};
|
||||
options.headers = options.headers || {};
|
||||
options.headers['X-Clan-Key'] = CLAN_KEY;
|
||||
return fetch(url, options);
|
||||
}
|
||||
35
bot_modules/01_ui.js
Normal file
35
bot_modules/01_ui.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// ================================================================
|
||||
// 01_ui.js — Toolbar button + pause toggle
|
||||
// Depends on: uw, log (00_config.js)
|
||||
// ================================================================
|
||||
|
||||
const btnHtml = `
|
||||
<div class="divider"></div>
|
||||
<div class="activity" id="grc_btn"
|
||||
style="filter: brightness(70%) sepia(100%) hue-rotate(200deg) saturate(1000%) contrast(0.8);">
|
||||
<p id="grc_label" style="position:relative;top:-8px;font-weight:bold;z-index:6;">Remote</p>
|
||||
</div>`;
|
||||
|
||||
let paused = false;
|
||||
|
||||
function togglePause() {
|
||||
paused = !paused;
|
||||
const label = document.getElementById('grc_label');
|
||||
const btn = document.getElementById('grc_btn');
|
||||
if (paused) {
|
||||
label.textContent = 'Paused';
|
||||
btn.style.filter = 'brightness(70%) sepia(100%) hue-rotate(-50deg) saturate(1000%) contrast(0.8)';
|
||||
} else {
|
||||
label.textContent = 'Remote';
|
||||
btn.style.filter = 'brightness(294%) sepia(100%) hue-rotate(200deg) saturate(1000%) contrast(0.8)';
|
||||
}
|
||||
log(`Remote is now ${paused ? 'PAUSED' : 'ACTIVE'}`);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!document.getElementById('grc_btn')) {
|
||||
uw.$('.tb_activities, .toolbar_activities').find('.middle').append(btnHtml);
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
uw.$(document).on('click', '#grc_btn', togglePause);
|
||||
320
bot_modules/02_state.js
Normal file
320
bot_modules/02_state.js
Normal file
@@ -0,0 +1,320 @@
|
||||
// ================================================================
|
||||
// 02_state.js — Gather & push town state to the relay server
|
||||
// Depends on: uw, BASE_URL, log, paused (00_config.js / 01_ui.js)
|
||||
// ================================================================
|
||||
|
||||
function gatherState() {
|
||||
const towns = uw.ITowns?.towns || {};
|
||||
const player = uw.Game?.player_name || '';
|
||||
const player_id = uw.Game?.player_id ?? null;
|
||||
const alliance_id = uw.Game?.alliance_id ?? null;
|
||||
let alliance_name = null;
|
||||
try {
|
||||
const pm = uw.MM.getModels().Player[player_id];
|
||||
if (pm && pm.attributes) alliance_name = pm.attributes.alliance_name;
|
||||
} catch (e) { log(`Failed to extract alliance_name: ${e}`); }
|
||||
|
||||
const total_points = uw.Game?.player_points ?? 0;
|
||||
const world = uw.Game?.world_id || '';
|
||||
|
||||
// Battle points: att+def = total earned, used = spent, available = att+def-used
|
||||
// Source: ModernBot.user.js line 1635: let available = killpoints.att + killpoints.def - killpoints.used;
|
||||
let battle_points = { att: 0, def: 0, used: 0, available: 0 };
|
||||
try {
|
||||
const pk = uw.MM?.getModelByNameAndPlayerId?.('PlayerKillpoints')?.attributes;
|
||||
if (pk) {
|
||||
battle_points.att = pk.att || 0;
|
||||
battle_points.def = pk.def || 0;
|
||||
battle_points.used = pk.used || 0;
|
||||
battle_points.available = battle_points.att + battle_points.def - battle_points.used;
|
||||
}
|
||||
} catch (e) { log(`Failed to extract battle_points: ${e}`); }
|
||||
|
||||
const townList = Object.values(towns).map(town => {
|
||||
const res = town.resources();
|
||||
const buildings = town.buildings()?.attributes ?? {};
|
||||
|
||||
const unitsObj = {};
|
||||
try {
|
||||
const units = town.units();
|
||||
if (units) {
|
||||
Object.keys(units).forEach(k => {
|
||||
unitsObj[k] = typeof units[k] === 'number'
|
||||
? units[k]
|
||||
: (units[k]?.getAmount?.() ?? 0);
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
let buildQueue = [];
|
||||
try {
|
||||
const bo = town.buildingOrders?.();
|
||||
if (bo?.models) buildQueue = bo.models.map(m => m.attributes);
|
||||
} catch (e) {}
|
||||
|
||||
let unitQueue = [];
|
||||
try {
|
||||
const uo = town.getUnitOrdersCollection?.();
|
||||
if (uo?.models) unitQueue = uo.models.map(m => m.attributes);
|
||||
} catch (e) {}
|
||||
|
||||
let buildDataMap = {};
|
||||
try {
|
||||
const buildDataRaw = uw.MM?.getModels?.()?.BuildingBuildData?.[town.id]?.attributes?.building_data || {};
|
||||
for (const k in buildDataRaw) {
|
||||
buildDataMap[k] = {
|
||||
buildable: buildDataRaw[k].buildable,
|
||||
dependencies: buildDataRaw[k].dependencies_fulfilled !== false,
|
||||
wood: buildDataRaw[k].resources_for?.wood || 0,
|
||||
stone: buildDataRaw[k].resources_for?.stone || 0,
|
||||
iron: buildDataRaw[k].resources_for?.iron || 0,
|
||||
pop: buildDataRaw[k].population_for || 0,
|
||||
build_time: buildDataRaw[k].building_time || '',
|
||||
can_upgrade: !!buildDataRaw[k].can_upgrade,
|
||||
enough_resources: !!buildDataRaw[k].enough_resources,
|
||||
missing_dependencies: buildDataRaw[k].missing_dependencies || [],
|
||||
has_max_level: !!buildDataRaw[k].has_max_level
|
||||
};
|
||||
}
|
||||
} catch (e) { log(`Failed to gather build data: ${e}`); }
|
||||
|
||||
// ---- Storage capacity -----------------------------------------------
|
||||
let storageCapacity = 0;
|
||||
try {
|
||||
storageCapacity = town.getStorageCapacity?.() || 0;
|
||||
if (!storageCapacity) {
|
||||
const storageLevel = buildings.storage ?? 0;
|
||||
const gd = uw.GameData?.buildingData?.storage;
|
||||
storageCapacity = gd?.max_storage?.[storageLevel] || gd?.storage?.[storageLevel] || 0;
|
||||
}
|
||||
if (!storageCapacity) {
|
||||
storageCapacity = res.capacity || res.storage_capacity || res.storage || 0;
|
||||
}
|
||||
} catch (e) { log(`storage capacity lookup failed: ${e}`); }
|
||||
|
||||
// ---- Market / Trade capacity -----------------------------------------
|
||||
let marketCapacity = 0;
|
||||
try {
|
||||
const marketLevel = buildings.market ?? 0;
|
||||
const gd = uw.GameData?.buildingData?.market;
|
||||
marketCapacity = gd?.capacity_per_level?.[marketLevel] || 0;
|
||||
if (buildings.trade_office && buildings.trade_office > 0) {
|
||||
marketCapacity += (uw.GameData?.buildingData?.trade_office?.capacity_extra_per_level || 500) * marketLevel;
|
||||
}
|
||||
} catch (e) { log(`market capacity lookup failed: ${e}`); }
|
||||
|
||||
// ---- Coordinates & sea zone -----------------------------------------
|
||||
let x = null, y = null, sea = null;
|
||||
try {
|
||||
x = town.getIslandCoordinateX?.() ?? null;
|
||||
y = town.getIslandCoordinateY?.() ?? null;
|
||||
if (typeof x === 'number' && typeof y === 'number') {
|
||||
sea = Math.floor(x / 100) * 10 + Math.floor(y / 100);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// ---- Researches -----------------------------------------------------
|
||||
let researches = {};
|
||||
try {
|
||||
const r = town.researches?.();
|
||||
if (r) researches = r.attributes ?? (typeof r === 'object' ? r : {});
|
||||
} catch (e) { log(`[Debug] town.researches() failed: ${e}`); }
|
||||
|
||||
// ---- Unit Data (Costs & Dependencies) -------------------------------
|
||||
let unitDataMap = {};
|
||||
try {
|
||||
const gdUnits = uw.GameData?.units || {};
|
||||
for (const u in gdUnits) {
|
||||
if (u === 'militia') continue;
|
||||
const reqBuildings = gdUnits[u].building_dependencies || {};
|
||||
const reqResearch = gdUnits[u].research_dependencies || [];
|
||||
let missing_deps = {};
|
||||
for (const reqB in reqBuildings) {
|
||||
if ((buildings[reqB] || 0) < reqBuildings[reqB]) {
|
||||
missing_deps[reqB] = { name: reqB, needed_level: reqBuildings[reqB] };
|
||||
}
|
||||
}
|
||||
for (const reqR of reqResearch) {
|
||||
if (!researches[reqR]) {
|
||||
missing_deps[reqR] = { name: reqR, needed_level: 'Έρευνα' };
|
||||
}
|
||||
}
|
||||
const cost = gdUnits[u].resources || {};
|
||||
const w = cost.wood || 0, s = cost.stone || 0, i = cost.iron || 0;
|
||||
let enough = !(res.wood < w || res.stone < s || res.iron < i);
|
||||
unitDataMap[u] = {
|
||||
wood: w, stone: s, iron: i,
|
||||
pop: gdUnits[u].population || 0,
|
||||
favor: gdUnits[u].favor || 0,
|
||||
build_time: gdUnits[u].build_time || 0,
|
||||
enough_resources: enough,
|
||||
missing_dependencies: missing_deps
|
||||
};
|
||||
}
|
||||
} catch (e) { log(`Failed to gather unit data: ${e}`); }
|
||||
|
||||
// ---- Farm town data -----------------------------------------------
|
||||
let farms = [];
|
||||
try {
|
||||
const farmCollection = uw.MM.getOnlyCollectionByName('FarmTown');
|
||||
const relCollection = uw.MM.getOnlyCollectionByName('FarmTownPlayerRelation');
|
||||
if (farmCollection && relCollection) {
|
||||
const ix = town.getIslandCoordinateX?.();
|
||||
const iy = town.getIslandCoordinateY?.();
|
||||
farmCollection.models.forEach(farm => {
|
||||
if (farm.attributes.island_x !== ix || farm.attributes.island_y !== iy) return;
|
||||
relCollection.models.forEach(rel => {
|
||||
if (rel.attributes.farm_town_id === farm.attributes.id &&
|
||||
rel.attributes.relation_status >= 1) {
|
||||
farms.push({
|
||||
farm_town_id: farm.attributes.id,
|
||||
farm_name: farm.attributes.name || '',
|
||||
relation_id: rel.id,
|
||||
relation_status: rel.attributes.relation_status,
|
||||
expansion_stage: rel.attributes.expansion_stage || 0,
|
||||
expansion_at: rel.attributes.expansion_at || 0,
|
||||
lootable_at: rel.attributes.lootable_at || 0
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// ---- Extra town flags -----------------------------------------------
|
||||
let has_premium = false;
|
||||
try {
|
||||
has_premium = uw.GameDataPremium?.isAdvisorActivated?.('curator') || false;
|
||||
} catch (e) {}
|
||||
|
||||
// ---- Favor extraction -----------------------------------------------
|
||||
let favor = 0;
|
||||
let godName = null;
|
||||
try {
|
||||
godName = town.god?.() ?? null;
|
||||
if (godName && uw.ITowns?.player_gods?.attributes) {
|
||||
favor = uw.ITowns.player_gods.attributes[godName + '_favor'] || 0;
|
||||
} else if (uw.$ && uw.Game?.townId === town.id) {
|
||||
// UI fallback for the active town
|
||||
const uiText = uw.$('.favor_amount').text();
|
||||
if (uiText) favor = parseInt(uiText.replace(/[^\d]/g, ''), 10) || 0;
|
||||
}
|
||||
} catch (e) { log(`Failed to extract favor: ${e}`); }
|
||||
|
||||
return {
|
||||
town_id: town.id,
|
||||
town_name: town.name,
|
||||
x, y, sea,
|
||||
wood: res.wood,
|
||||
stone: res.stone,
|
||||
iron: res.iron,
|
||||
storage: storageCapacity,
|
||||
market_capacity: marketCapacity,
|
||||
population: res.population,
|
||||
favor: favor,
|
||||
points: town.getPoints?.() ?? 0,
|
||||
god: godName,
|
||||
buildings,
|
||||
units: unitsObj,
|
||||
buildingOrder: buildQueue,
|
||||
unitOrder: unitQueue,
|
||||
buildData: buildDataMap,
|
||||
unitData: unitDataMap,
|
||||
researches,
|
||||
has_premium,
|
||||
bonuses: {},
|
||||
wonder_points: 0,
|
||||
alliance_name,
|
||||
farms,
|
||||
};
|
||||
});
|
||||
|
||||
// ---- World speed & unit speed table -----
|
||||
let world_speed = 1;
|
||||
let unit_speeds = {};
|
||||
try {
|
||||
world_speed = uw.Game?.world_speed || 1;
|
||||
const gdUnits = uw.GameData?.units || {};
|
||||
for (const [uid, ud] of Object.entries(gdUnits)) {
|
||||
if (ud.speed !== undefined) {
|
||||
unit_speeds[uid] = {
|
||||
speed: ud.speed || 0,
|
||||
population: ud.population || 1,
|
||||
is_naval: !!(ud.naval || ud.is_naval || false),
|
||||
capacity: ud.capacity || 0, // transport ship cargo
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) { log(`unit speed gather failed: ${e}`); }
|
||||
|
||||
|
||||
// Active celebrations — tracks running parties/processions per town
|
||||
// finished_at is a unix timestamp (seconds). 0 means not running.
|
||||
const celebrations = [];
|
||||
try {
|
||||
const celebModels = uw.MM.getModels()?.Celebration;
|
||||
if (celebModels) {
|
||||
for (const cel of Object.values(celebModels)) {
|
||||
const a = cel.attributes;
|
||||
if (!a || !a.town_id) continue;
|
||||
celebrations.push({
|
||||
town_id: String(a.town_id),
|
||||
celebration_type: a.celebration_type, // 'party' | 'triumph'
|
||||
finished_at: a.finished_at ?? 0
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) { log(`celebrations gather failed: ${e}`); }
|
||||
|
||||
return { player, player_id, alliance_id, total_points, battle_points, world_id: world,
|
||||
world_speed, unit_speeds, towns: townList, celebrations };
|
||||
|
||||
}
|
||||
|
||||
|
||||
function pushState() {
|
||||
if (paused) return;
|
||||
try {
|
||||
const payload = gatherState();
|
||||
apiFetch(`${BASE_URL}/api/state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(() => log(`State pushed — ${payload.towns.length} towns`))
|
||||
.catch(e => log(`State push failed: ${e}`));
|
||||
} catch (e) {
|
||||
log(`gatherState error: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- AJAX Interceptor for Zero-Delay Push ----------------------------
|
||||
let pushTimeout = null;
|
||||
function debouncedPushState() {
|
||||
if (paused) return;
|
||||
if (pushTimeout) clearTimeout(pushTimeout);
|
||||
pushTimeout = setTimeout(() => {
|
||||
log('⚡ State change detected (AJAX). Syncing to Remote...');
|
||||
pushState();
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
if (uw.$) {
|
||||
uw.$(document).ajaxComplete(function (e, xhr, opt) {
|
||||
if (!opt || !opt.url) return;
|
||||
if (opt.url.includes(BASE_URL)) return;
|
||||
if (opt.url.includes('map_tiles')) return;
|
||||
if (opt.url.includes('action=') || opt.url.includes('switch_town')) {
|
||||
debouncedPushState();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Report command result back to relay -----------------------------
|
||||
function reportResult(cmdId, status, message) {
|
||||
apiFetch(`${BASE_URL}/api/commands/${cmdId}/result`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status, message })
|
||||
}).catch(e => log(`reportResult failed: ${e}`));
|
||||
}
|
||||
46
bot_modules/03_captcha.js
Normal file
46
bot_modules/03_captcha.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// ================================================================
|
||||
// 03_captcha.js — hCaptcha detection & server alert
|
||||
// Depends on: uw, BASE_URL, log, paused (00_config.js / 01_ui.js)
|
||||
// ================================================================
|
||||
|
||||
let captchaActive = false;
|
||||
|
||||
function reportCaptcha(detected) {
|
||||
const player_id = uw.Game?.player_id;
|
||||
const world_id = uw.Game?.world_id || '';
|
||||
if (!player_id) return;
|
||||
apiFetch(`${BASE_URL}/api/captcha/alert?player_id=${player_id}&world_id=${world_id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ detected })
|
||||
}).catch(e => log(`captcha report failed: ${e}`));
|
||||
}
|
||||
|
||||
function detectCaptcha() {
|
||||
setInterval(() => {
|
||||
const win = document.getElementById('hcaptcha_window');
|
||||
let isVisible = false;
|
||||
|
||||
if (win) {
|
||||
const style = window.getComputedStyle(win);
|
||||
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
||||
isVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isVisible && !captchaActive) {
|
||||
captchaActive = true;
|
||||
paused = true;
|
||||
const label = document.getElementById('grc_label');
|
||||
const btn = document.getElementById('grc_btn');
|
||||
if (label) label.textContent = '⚠ CAPTCHA';
|
||||
if (btn) btn.style.filter = 'brightness(70%) sepia(100%) hue-rotate(300deg) saturate(1000%) contrast(0.8)';
|
||||
log('⚠ CAPTCHA detected — bot paused, alerting server');
|
||||
reportCaptcha(true);
|
||||
} else if (!isVisible && captchaActive) {
|
||||
captchaActive = false;
|
||||
log('✅ Captcha resolved — alert cleared (bot remains paused)');
|
||||
reportCaptcha(false);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
215
bot_modules/04a_execute_farm.js
Normal file
215
bot_modules/04a_execute_farm.js
Normal file
@@ -0,0 +1,215 @@
|
||||
// ================================================================
|
||||
// 04a_execute_farm.js — Farm command executors
|
||||
// Depends on: uw, log, sleep, randInt, paused, pushState
|
||||
// ================================================================
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Execute: Farm Upgrade / Unlock
|
||||
// ----------------------------------------------------------------
|
||||
async function executeFarmUpgrade(cmd) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
let farmModels, relModels;
|
||||
try {
|
||||
farmModels = uw.MM.getOnlyCollectionByName('FarmTown')?.models;
|
||||
relModels = uw.MM.getOnlyCollectionByName('FarmTownPlayerRelation')?.models;
|
||||
} catch (e) {
|
||||
return { ok: false, msg: `Cannot access farm collections: ${e.message}` };
|
||||
}
|
||||
if (!farmModels || !relModels) {
|
||||
return { ok: false, msg: 'Farm collections not loaded yet' };
|
||||
}
|
||||
|
||||
// Build polis list (one town per island)
|
||||
const islandsSeen = new Set();
|
||||
const polisList = [];
|
||||
try {
|
||||
for (const town of uw.MM.getCollections().Town[0].models) {
|
||||
const { on_small_island, island_id, id } = town.attributes;
|
||||
if (on_small_island) continue;
|
||||
if (!islandsSeen.has(island_id)) {
|
||||
islandsSeen.add(island_id);
|
||||
polisList.push(id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, msg: `Cannot build town list: ${e.message}` };
|
||||
}
|
||||
|
||||
let upgraded = 0, unlocked = 0, skipped = 0, errors = 0;
|
||||
|
||||
for (const town_id of polisList) {
|
||||
const town = uw.ITowns?.towns?.[town_id];
|
||||
if (!town) continue;
|
||||
const ix = town.getIslandCoordinateX();
|
||||
const iy = town.getIslandCoordinateY();
|
||||
if (ix == null || iy == null) continue;
|
||||
|
||||
for (const farm of farmModels) {
|
||||
if (farm.attributes.island_x !== ix || farm.attributes.island_y !== iy) continue;
|
||||
|
||||
for (const rel of relModels) {
|
||||
if (rel.attributes.farm_town_id !== farm.attributes.id) continue;
|
||||
|
||||
const status = rel.attributes.relation_status;
|
||||
const level = rel.attributes.expansion_stage || 0;
|
||||
const expAt = rel.attributes.expansion_at || 0;
|
||||
|
||||
if (expAt > now) { skipped++; continue; }
|
||||
if (status === 1 && level >= 5) { skipped++; continue; }
|
||||
if (status < 0) { skipped++; continue; }
|
||||
|
||||
const isLocked = status === 0;
|
||||
const action = isLocked ? 'unlock' : 'upgrade';
|
||||
|
||||
const requestedAction = cmd.payload?.action_type;
|
||||
if (requestedAction && requestedAction !== action) { skipped++; continue; }
|
||||
|
||||
if (paused) return { ok: false, msg: 'Aborted due to pause/captcha' };
|
||||
|
||||
log(`Farm ${action}: farm_id=${farm.attributes.id} level=${level} town=${town_id}`);
|
||||
try {
|
||||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||||
model_url: `FarmTownPlayerRelation/${rel.id}`,
|
||||
action_name: action,
|
||||
arguments: { farm_town_id: farm.attributes.id },
|
||||
town_id
|
||||
});
|
||||
isLocked ? unlocked++ : upgraded++;
|
||||
} catch (e) { errors++; }
|
||||
|
||||
await sleep(randInt(4000, 10000));
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pushState();
|
||||
return { ok: true, msg: `Farm upgrade done: ${unlocked} unlocked, ${upgraded} upgraded, ${skipped} skipped, ${errors} errors` };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Execute: Farm Loot
|
||||
// ----------------------------------------------------------------
|
||||
async function executeFarmLoot(cmd) {
|
||||
const { loot_option } = cmd.payload || {};
|
||||
const option = parseInt(loot_option) || 1;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
let farmModels, relModels;
|
||||
try {
|
||||
farmModels = uw.MM.getOnlyCollectionByName('FarmTown')?.models;
|
||||
relModels = uw.MM.getOnlyCollectionByName('FarmTownPlayerRelation')?.models;
|
||||
} catch (e) {
|
||||
return { ok: false, msg: `Cannot access farm collections: ${e.message}` };
|
||||
}
|
||||
if (!farmModels || !relModels) {
|
||||
return { ok: false, msg: 'Farm collections not loaded yet — open island view first' };
|
||||
}
|
||||
|
||||
// Build island groups using MM (all towns, not just visible)
|
||||
const islandTownsMap = {};
|
||||
try {
|
||||
const allTowns = uw.MM.getCollections().Town[0].models;
|
||||
for (const town of allTowns) {
|
||||
const { on_small_island, island_id, id } = town.attributes;
|
||||
if (on_small_island) continue;
|
||||
if (!islandTownsMap[island_id]) islandTownsMap[island_id] = [];
|
||||
islandTownsMap[island_id].push(id);
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, msg: `Cannot build town list: ${e.message}` };
|
||||
}
|
||||
|
||||
const islandList = Object.keys(islandTownsMap);
|
||||
log(`Farm: processing ${islandList.length} islands with option=${option}`);
|
||||
|
||||
let claimed = 0, skipped = 0, errors = 0;
|
||||
|
||||
for (let i = 0; i < islandList.length; i++) {
|
||||
const island_id = islandList[i];
|
||||
const townIds = islandTownsMap[island_id];
|
||||
|
||||
let selected_town_id = null;
|
||||
let lowest_total_res = Infinity;
|
||||
|
||||
for (const t_id of townIds) {
|
||||
const t = uw.ITowns?.towns?.[t_id];
|
||||
if (!t) continue;
|
||||
|
||||
let storageCapacity = t.getStorageCapacity?.() || 0;
|
||||
if (!storageCapacity) {
|
||||
const buildings = t.buildings?.()?.attributes || {};
|
||||
const storageLevel = buildings.storage ?? 0;
|
||||
const gd = uw.GameData?.buildingData?.storage;
|
||||
storageCapacity = gd?.max_storage?.[storageLevel] || gd?.storage?.[storageLevel] || 0;
|
||||
}
|
||||
|
||||
const res = t.resources?.() || {};
|
||||
const w = res.wood || 0, s = res.stone || 0, ir = res.iron || 0;
|
||||
|
||||
// Skip completely full towns
|
||||
if (storageCapacity > 0 && w >= storageCapacity && s >= storageCapacity && ir >= storageCapacity) continue;
|
||||
|
||||
const total_res = w + s + ir;
|
||||
if (total_res < lowest_total_res) {
|
||||
lowest_total_res = total_res;
|
||||
selected_town_id = t_id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected_town_id) {
|
||||
log(`Farm: Skipping island ${island_id} (All towns are 100% full)`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const town_id = selected_town_id;
|
||||
const town = uw.ITowns?.towns?.[town_id];
|
||||
if (!town) { skipped++; continue; }
|
||||
|
||||
const ix = town.getIslandCoordinateX();
|
||||
const iy = town.getIslandCoordinateY();
|
||||
if (ix == null || iy == null) { skipped++; continue; }
|
||||
|
||||
const readyFarms = [];
|
||||
for (const farm of farmModels) {
|
||||
if (farm.attributes.island_x !== ix || farm.attributes.island_y !== iy) continue;
|
||||
for (const rel of relModels) {
|
||||
if (
|
||||
rel.attributes.farm_town_id === farm.attributes.id &&
|
||||
rel.attributes.relation_status === 1 &&
|
||||
(!rel.attributes.lootable_at || now >= rel.attributes.lootable_at)
|
||||
) {
|
||||
readyFarms.push({
|
||||
town_id,
|
||||
farm_town_id: rel.attributes.farm_town_id,
|
||||
relation_id: rel.id
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (readyFarms.length === 0) { skipped++; continue; }
|
||||
log(`Farm: ${readyFarms.length} ready on island of town ${town_id}`);
|
||||
|
||||
for (const farm of readyFarms) {
|
||||
if (paused) return { ok: false, msg: 'Aborted due to pause/captcha' };
|
||||
try {
|
||||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||||
model_url: `FarmTownPlayerRelation/${farm.relation_id}`,
|
||||
action_name: 'claim',
|
||||
arguments: { farm_town_id: farm.farm_town_id, type: 'resources', option },
|
||||
town_id: farm.town_id
|
||||
});
|
||||
claimed++;
|
||||
} catch (e) { errors++; }
|
||||
|
||||
await sleep(randInt(500, 1500));
|
||||
}
|
||||
|
||||
try { uw.WMap.removeFarmTownLootCooldownIconAndRefreshLootTimers(); } catch (e) {}
|
||||
}
|
||||
|
||||
return { ok: true, msg: `Farm done: ${claimed} claimed, ${skipped} islands skipped, ${errors} errors` };
|
||||
}
|
||||
139
bot_modules/04b_execute_admin.js
Normal file
139
bot_modules/04b_execute_admin.js
Normal file
@@ -0,0 +1,139 @@
|
||||
// ================================================================
|
||||
// 04b_execute_admin.js — Admin command executors
|
||||
// Depends on: uw, log, sleep, randInt, paused
|
||||
// ================================================================
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Execute: Build
|
||||
// ----------------------------------------------------------------
|
||||
async function executeBuild(cmd) {
|
||||
const { town_id, payload } = cmd;
|
||||
const { building_id } = payload;
|
||||
|
||||
const town = uw.ITowns?.getTown?.(town_id) || uw.ITowns?.towns?.[town_id];
|
||||
if (!town) return { ok: false, msg: `Town ${town_id} not found in ITowns` };
|
||||
|
||||
const queueLen = town.buildingOrders?.()?.length ?? 0;
|
||||
const hasCurator = uw.GameDataPremium?.isAdvisorActivated?.('curator');
|
||||
const maxQueue = hasCurator ? 7 : 2;
|
||||
if (queueLen >= maxQueue) {
|
||||
return { ok: false, requeue: true, msg: `Build queue full (${queueLen}/${maxQueue})` };
|
||||
}
|
||||
|
||||
try {
|
||||
const buildData = uw.MM.getModels?.()?.BuildingBuildData?.[town_id]
|
||||
?.attributes?.building_data?.[building_id];
|
||||
if (buildData) {
|
||||
const res = town.resources();
|
||||
const { resources_for, population_for } = buildData;
|
||||
if (town.getAvailablePopulation?.() < population_for) {
|
||||
return { ok: false, requeue: true, msg: `Not enough population for ${building_id}` };
|
||||
}
|
||||
if (res.wood < resources_for.wood || res.stone < resources_for.stone || res.iron < resources_for.iron) {
|
||||
return { ok: false, requeue: true, msg: `Not enough resources for ${building_id}` };
|
||||
}
|
||||
}
|
||||
} catch (e) { log(`Resource check skipped: ${e}`); }
|
||||
|
||||
const reactionMs = randInt(800, 2500);
|
||||
log(`Waiting ${reactionMs}ms before firing buildUp (reaction time)...`);
|
||||
await sleep(reactionMs);
|
||||
|
||||
if (paused) return { ok: false, msg: 'Aborted due to pause/captcha' };
|
||||
|
||||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||||
model_url: 'BuildingOrder',
|
||||
action_name: 'buildUp',
|
||||
arguments: { building_id },
|
||||
town_id
|
||||
});
|
||||
|
||||
await sleep(500);
|
||||
return { ok: true, msg: `buildUp ${building_id} queued` };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Execute: Recruit
|
||||
// ----------------------------------------------------------------
|
||||
async function executeRecruit(cmd) {
|
||||
const { town_id, payload } = cmd;
|
||||
const { unit_id, amount } = payload;
|
||||
|
||||
const town = uw.ITowns?.getTown?.(town_id) || uw.ITowns?.towns?.[town_id];
|
||||
if (!town) return { ok: false, msg: `Town ${town_id} not found` };
|
||||
|
||||
const navalUnits = [
|
||||
'big_transporter', 'small_transporter', 'bireme',
|
||||
'attack_ship', 'trireme', 'colonize_ship', 'sea_monster'
|
||||
];
|
||||
const endpoint = navalUnits.includes(unit_id) ? 'building_docks' : 'building_barracks';
|
||||
|
||||
const reactionMs = randInt(800, 2500);
|
||||
log(`Waiting ${reactionMs}ms before firing recruit (reaction time)...`);
|
||||
await sleep(reactionMs);
|
||||
|
||||
if (paused) return { ok: false, msg: 'Aborted due to pause/captcha' };
|
||||
|
||||
uw.gpAjax.ajaxPost(endpoint, 'build', {
|
||||
unit_id,
|
||||
amount: parseInt(amount) || 1,
|
||||
town_id
|
||||
});
|
||||
|
||||
await sleep(500);
|
||||
return { ok: true, msg: `Recruit ${amount}x ${unit_id} submitted` };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Execute: Market Offer
|
||||
// ----------------------------------------------------------------
|
||||
async function executeMarketOffer(cmd) {
|
||||
const { town_id, payload } = cmd;
|
||||
const { offer, offer_type, demand, demand_type, max_delivery_time, visibility } = payload;
|
||||
|
||||
const town = uw.ITowns?.getTown?.(town_id) || uw.ITowns?.towns?.[town_id];
|
||||
if (!town) return { ok: false, msg: `Town ${town_id} not found` };
|
||||
|
||||
const reactionMs = randInt(800, 2500);
|
||||
log(`Waiting ${reactionMs}ms before firing market offer (reaction time)...`);
|
||||
await sleep(reactionMs);
|
||||
|
||||
if (paused) return { ok: false, msg: 'Aborted due to pause/captcha' };
|
||||
|
||||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||||
model_url: 'CreateOffers/' + town_id,
|
||||
action_name: 'createOffer',
|
||||
captcha: null,
|
||||
arguments: { offer, offer_type, demand, demand_type, max_delivery_time, visibility }
|
||||
});
|
||||
|
||||
await sleep(500);
|
||||
return { ok: true, msg: `Market offer posted: ${offer} ${offer_type} => ${demand} ${demand_type}` };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Execute: Research (Academy)
|
||||
// ----------------------------------------------------------------
|
||||
async function executeResearch(cmd) {
|
||||
const { town_id, payload } = cmd;
|
||||
const { research_id } = payload;
|
||||
|
||||
const town = uw.ITowns?.getTown?.(town_id) || uw.ITowns?.towns?.[town_id];
|
||||
if (!town) return { ok: false, msg: `Town ${town_id} not found` };
|
||||
|
||||
const reactionMs = randInt(800, 2500);
|
||||
log(`Waiting ${reactionMs}ms before firing research (reaction time)...`);
|
||||
await sleep(reactionMs);
|
||||
|
||||
if (paused) return { ok: false, msg: 'Aborted due to pause/captcha' };
|
||||
|
||||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||||
model_url: 'ResearchOrder',
|
||||
action_name: 'research',
|
||||
arguments: { id: research_id },
|
||||
town_id
|
||||
});
|
||||
|
||||
await sleep(500);
|
||||
return { ok: true, msg: `Research ${research_id} queued` };
|
||||
}
|
||||
307
bot_modules/04c_execute_bootcamp_trade.js
Normal file
307
bot_modules/04c_execute_bootcamp_trade.js
Normal file
@@ -0,0 +1,307 @@
|
||||
// ================================================================
|
||||
// 04c_execute_bootcamp_trade.js
|
||||
// Auto-Bootcamp & Auto-Rural-Trade loops
|
||||
//
|
||||
// Both are driven by jitterLoop (registered in 05_main.js boot()).
|
||||
// Settings arrive via cmdData.bot_settings in the poll response —
|
||||
// no extra network call is needed.
|
||||
//
|
||||
// Timers (human-like, randomised):
|
||||
// Bootcamp : 12–22 min (camp cooldown is ~12–15 min)
|
||||
// RuralTrade: 25–45 min (trading is low-urgency)
|
||||
// ================================================================
|
||||
|
||||
// Shared cache — set by pollAndExecute every 8-18 s
|
||||
let lastKnownBotSettings = {};
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// botLog — sends a log entry to the server
|
||||
// ----------------------------------------------------------------
|
||||
async function botLog(player_id, world_id, feature, message) {
|
||||
log(`[${feature}] ${message}`);
|
||||
try {
|
||||
await apiFetch(`${BASE_URL}/api/bot-logs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ player_id, world_id, feature, message })
|
||||
});
|
||||
} catch (e) { /* non-critical */ }
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// AUTO BOOTCAMP
|
||||
// ================================================================
|
||||
async function autoBootcampLoop() {
|
||||
if (paused) return;
|
||||
|
||||
const settings = lastKnownBotSettings;
|
||||
if (!settings.bootcamp_enabled) return;
|
||||
|
||||
const player_id = uw.Game?.player_id;
|
||||
const world_id = uw.Game?.world_id;
|
||||
if (!player_id || !world_id) return;
|
||||
|
||||
let model;
|
||||
try {
|
||||
model = uw.MM.getModelByNameAndPlayerId('PlayerAttackSpot');
|
||||
} catch (e) { return; }
|
||||
|
||||
// Model not loaded yet (player hasn't opened the camp UI this session)
|
||||
if (!model || typeof model.getLevel?.() === 'undefined') {
|
||||
log('[bootcamp] PlayerAttackSpot model not ready — skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 1. Claim reward if available ──────────────────────────────
|
||||
try {
|
||||
const hasReward = model.hasReward?.();
|
||||
if (hasReward) {
|
||||
const reward = model.getReward?.();
|
||||
if (reward) {
|
||||
const isInstant = reward.power_id?.includes('instant');
|
||||
const isFavor = reward.power_id?.includes('favor');
|
||||
const stashable = reward.stashable;
|
||||
|
||||
const useReward = () => {
|
||||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||||
model_url: `PlayerAttackSpot/${player_id}`,
|
||||
action_name: 'useReward',
|
||||
arguments: {}
|
||||
});
|
||||
botLog(player_id, world_id, 'bootcamp', `Reward used: ${reward.power_id}`);
|
||||
};
|
||||
|
||||
if (isInstant && !isFavor) {
|
||||
useReward();
|
||||
} else if (stashable) {
|
||||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||||
model_url: `PlayerAttackSpot/${player_id}`,
|
||||
action_name: 'stashReward',
|
||||
arguments: {}
|
||||
}, 0, {
|
||||
success: () => {
|
||||
botLog(player_id, world_id, 'bootcamp', `Reward stashed: ${reward.power_id}`);
|
||||
},
|
||||
error: useReward
|
||||
});
|
||||
} else {
|
||||
useReward();
|
||||
}
|
||||
await sleep(randInt(3000, 7000));
|
||||
return; // Wait for next cycle to attack
|
||||
}
|
||||
}
|
||||
} catch (e) { /* reward check failed — continue to attack check */ }
|
||||
|
||||
// ── 2. Attack if no cooldown ───────────────────────────────────
|
||||
try {
|
||||
// If getCooldownDuration is unavailable, skip safely
|
||||
if (typeof model.getCooldownDuration !== 'function') {
|
||||
log('[bootcamp] getCooldownDuration unavailable — skipping');
|
||||
return;
|
||||
}
|
||||
const cooldown = model.getCooldownDuration();
|
||||
if (cooldown > 0) {
|
||||
const minRemaining = Math.round(cooldown / 60);
|
||||
await botLog(player_id, world_id, 'bootcamp', `Camp on cooldown — ${minRemaining} min remaining`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check no existing attack movement to/from camp
|
||||
const movements = uw.MM.getModels()?.MovementsUnits;
|
||||
if (movements) {
|
||||
for (const mv of Object.values(movements)) {
|
||||
if (mv.attributes.destination_is_attack_spot || mv.attributes.origin_is_attack_spot) {
|
||||
await botLog(player_id, world_id, 'bootcamp', 'Attack already in flight — skipping');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect units from current town
|
||||
const currentTownId = uw.Game?.townId;
|
||||
if (!currentTownId) return;
|
||||
|
||||
const town = uw.ITowns?.towns?.[currentTownId];
|
||||
if (!town) return;
|
||||
|
||||
const units = { ...town.units?.() };
|
||||
delete units.militia;
|
||||
|
||||
// Remove naval
|
||||
for (const unit in units) {
|
||||
if (uw.GameData?.units?.[unit]?.is_naval) delete units[unit];
|
||||
}
|
||||
|
||||
// Remove defensive if use_def is off
|
||||
if (!settings.bootcamp_use_def) {
|
||||
delete units.sword;
|
||||
delete units.archer;
|
||||
}
|
||||
|
||||
// Remove zero-count
|
||||
for (const unit in units) {
|
||||
if (!units[unit] || units[unit] <= 0) delete units[unit];
|
||||
}
|
||||
|
||||
if (Object.keys(units).length === 0) {
|
||||
await botLog(player_id, world_id, 'bootcamp', 'No available units — skipping attack. (Χωρίς αμυντικά)');
|
||||
return;
|
||||
}
|
||||
|
||||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||||
model_url: `PlayerAttackSpot/${player_id}`,
|
||||
action_name: 'attack',
|
||||
arguments: units
|
||||
});
|
||||
|
||||
const unitSummary = Object.entries(units).map(([u, n]) => `${n}x${u}`).join(', ');
|
||||
await botLog(player_id, world_id, 'bootcamp', `Στέλνω ${JSON.stringify(units)} στο camp...`);
|
||||
|
||||
} catch (e) {
|
||||
await botLog(player_id, world_id, 'bootcamp', `Error during attack: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ================================================================
|
||||
// AUTO RURAL TRADE
|
||||
// Triggers only when a town's pending build command is stuck due to
|
||||
// insufficient resources. Trades for the specific missing resource.
|
||||
//
|
||||
// Ratio map: 1→0.25, 2→0.5, 3→0.75, 4→1.0, 5→1.25
|
||||
// ================================================================
|
||||
const RATIO_MAP = { 1: 0.25, 2: 0.50, 3: 0.75, 4: 1.00, 5: 1.25 };
|
||||
|
||||
async function autoRuralTradeLoop() {
|
||||
if (paused) return;
|
||||
|
||||
const settings = lastKnownBotSettings;
|
||||
if (!settings.rural_trade_enabled) return;
|
||||
|
||||
const player_id = uw.Game?.player_id;
|
||||
const world_id = uw.Game?.world_id;
|
||||
if (!player_id || !world_id) return;
|
||||
|
||||
const minRatio = RATIO_MAP[settings.rural_trade_ratio] ?? 0.75;
|
||||
|
||||
let farmModels, relModels;
|
||||
try {
|
||||
farmModels = uw.MM.getOnlyCollectionByName('FarmTown')?.models;
|
||||
relModels = uw.MM.getOnlyCollectionByName('FarmTownPlayerRelation')?.models;
|
||||
} catch (e) { return; }
|
||||
|
||||
if (!farmModels || !relModels) return;
|
||||
|
||||
// ── Find towns with stuck build commands ───────────────────────
|
||||
// We look at each town's in-game build queue. If the queue slot
|
||||
// is empty but we have a pending remote command, the build is
|
||||
// waiting — check which resource it needs.
|
||||
const towns = uw.ITowns?.towns;
|
||||
if (!towns) return;
|
||||
|
||||
let tradesTotal = 0;
|
||||
|
||||
for (const [town_id_str, town] of Object.entries(towns)) {
|
||||
if (paused) return;
|
||||
|
||||
// Get next pending build for this town from BuildingBuildData
|
||||
let missingResource = null;
|
||||
try {
|
||||
const buildData = uw.MM.getModels()?.BuildingBuildData?.[town_id_str];
|
||||
if (!buildData) continue;
|
||||
|
||||
const orders = town.buildingOrders?.()?.models ?? [];
|
||||
// Only check if the in-game queue has room (build could be submitted)
|
||||
const queueCap = uw.GameDataPremium?.isAdvisorActivated('curator') ? 7 : 2;
|
||||
if (orders.length >= queueCap) continue; // Queue full — not stuck on resources
|
||||
|
||||
// Check all building types to find one we might be trying to build
|
||||
// Heuristic: look for any building where we have less resources than needed
|
||||
const allBuildingData = buildData.attributes?.building_data ?? {};
|
||||
const res = town.resources?.() ?? {};
|
||||
|
||||
for (const [building_id, bdata] of Object.entries(allBuildingData)) {
|
||||
if (!bdata.resources_for) continue;
|
||||
const cost = bdata.resources_for;
|
||||
const w = cost.wood || 0;
|
||||
const s = cost.stone || 0;
|
||||
const ir = cost.iron || 0;
|
||||
|
||||
// Skip if we can already afford it
|
||||
if (res.wood >= w && res.stone >= s && res.iron >= ir) continue;
|
||||
|
||||
// Find which resource is most lacking (relative to cost)
|
||||
const shortfalls = [];
|
||||
if (w > 0) shortfalls.push({ res: 'wood', ratio: res.wood / w });
|
||||
if (s > 0) shortfalls.push({ res: 'stone', ratio: res.stone / s });
|
||||
if (ir > 0) shortfalls.push({ res: 'iron', ratio: res.iron / ir });
|
||||
|
||||
if (shortfalls.length === 0) continue;
|
||||
shortfalls.sort((a, b) => a.ratio - b.ratio);
|
||||
missingResource = shortfalls[0].res;
|
||||
break;
|
||||
}
|
||||
} catch (e) { continue; }
|
||||
|
||||
if (!missingResource) continue;
|
||||
|
||||
// ── Find farm villages on this island offering missingResource ──
|
||||
const town_obj = town;
|
||||
const ix = town_obj.getIslandCoordinateX?.();
|
||||
const iy = town_obj.getIslandCoordinateY?.();
|
||||
if (ix == null || iy == null) continue;
|
||||
|
||||
let tradeMade = false;
|
||||
for (const farm of farmModels) {
|
||||
if (farm.attributes.island_x !== ix || farm.attributes.island_y !== iy) continue;
|
||||
if (farm.attributes.resource_offer !== missingResource) continue;
|
||||
|
||||
for (const rel of relModels) {
|
||||
if (rel.attributes.farm_town_id !== farm.attributes.id) continue;
|
||||
if (rel.attributes.relation_status !== 1) continue; // must be allied
|
||||
|
||||
// Check ratio meets minimum
|
||||
const tradeRatio = rel.attributes.current_trade_ratio ?? 0;
|
||||
if (tradeRatio < minRatio) continue;
|
||||
|
||||
const tradeCapacity = town_obj.getAvailableTradeCapacity?.() ?? 0;
|
||||
if (tradeCapacity < 100) continue;
|
||||
|
||||
const amount = Math.min(tradeCapacity, 3000);
|
||||
|
||||
if (paused) return;
|
||||
|
||||
try {
|
||||
uw.gpAjax.ajaxPost('frontend_bridge', 'execute', {
|
||||
model_url: `FarmTownPlayerRelation/${rel.id}`,
|
||||
action_name: 'trade',
|
||||
arguments: { farm_town_id: farm.attributes.id, amount },
|
||||
town_id: parseInt(town_id_str)
|
||||
});
|
||||
await botLog(player_id, world_id, 'rural_trade',
|
||||
`Traded ${amount} ${missingResource} ← ${farm.attributes.name} via ${town_obj.getName?.() ?? town_id_str}`);
|
||||
tradesTotal++;
|
||||
tradeMade = true;
|
||||
} catch (e) {
|
||||
await botLog(player_id, world_id, 'rural_trade', `Trade error: ${e}`);
|
||||
}
|
||||
|
||||
await sleep(randInt(800, 1800));
|
||||
}
|
||||
|
||||
if (tradeMade) break; // One trade per town per cycle is enough
|
||||
}
|
||||
|
||||
if (!tradeMade && missingResource) {
|
||||
await botLog(player_id, world_id, 'rural_trade',
|
||||
`${town_obj.getName?.() ?? town_id_str} needs ${missingResource} but no suitable village found`);
|
||||
}
|
||||
|
||||
await sleep(randInt(500, 1200));
|
||||
}
|
||||
|
||||
if (tradesTotal === 0) {
|
||||
log('[rural_trade] No stuck builds or no tradeable villages — nothing to do');
|
||||
}
|
||||
}
|
||||
93
bot_modules/04d_execute_culture.js
Normal file
93
bot_modules/04d_execute_culture.js
Normal file
@@ -0,0 +1,93 @@
|
||||
// ================================================================
|
||||
// 04d_execute_culture.js — Execute Αγορά celebration commands
|
||||
//
|
||||
// Handles commands of type 'culture' from the culture_queue.
|
||||
// Completely separate from the commands table — builds/recruits
|
||||
// are never blocked by a stuck culture command.
|
||||
//
|
||||
// Reports results to /api/culture/result/<id> (dedicated endpoint).
|
||||
// ================================================================
|
||||
|
||||
async function reportCultureResult(queueId, status, msg) {
|
||||
try {
|
||||
await apiFetch(`${BASE_URL}/api/culture/result/${queueId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status, message: msg })
|
||||
});
|
||||
} catch (e) {
|
||||
log(`[αγορά] ⚠️ Failed to report result for queue #${queueId}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCultureCommand(cmd) {
|
||||
if (!cmd || !cmd.id || !cmd.payload) return { ok: false, msg: 'No payload' };
|
||||
|
||||
// town_id lives on the command root; payload has celebration_type
|
||||
const town_id = cmd.town_id || cmd.payload.town_id;
|
||||
const celebration_type = cmd.payload.celebration_type;
|
||||
const source = cmd.payload.source || 'manual';
|
||||
|
||||
if (!celebration_type || !town_id) {
|
||||
await reportCultureResult(cmd.id, 'failed', 'Invalid culture payload: missing celebration_type or town_id');
|
||||
return { ok: false, msg: 'Invalid payload' };
|
||||
}
|
||||
|
||||
if (!['party', 'triumph'].includes(celebration_type)) {
|
||||
await reportCultureResult(cmd.id, 'failed', `Unknown celebration_type: ${celebration_type}`);
|
||||
return { ok: false, msg: 'Unknown type' };
|
||||
}
|
||||
|
||||
const label = celebration_type === 'party' ? 'Γιορτή πόλης' : 'Παρέλαση θριάμβου';
|
||||
log(`[αγορά] Εκτέλεση ${label} για πόλη ${town_id} (${source})`);
|
||||
|
||||
// Validate town exists in game memory
|
||||
const town = uw.ITowns?.towns?.[town_id];
|
||||
if (!town) {
|
||||
const msg = `Η πόλη ${town_id} δεν βρέθηκε στη μνήμη του παιχνιδιού.`;
|
||||
await reportCultureResult(cmd.id, 'failed', msg);
|
||||
return { ok: false, msg };
|
||||
}
|
||||
|
||||
// Double-check: is there already a celebration of this type running?
|
||||
try {
|
||||
const celebModels = uw.MM.getModels()?.Celebration;
|
||||
if (celebModels) {
|
||||
const nowTs = Math.floor(Date.now() / 1000);
|
||||
for (const cel of Object.values(celebModels)) {
|
||||
const a = cel.attributes;
|
||||
if (String(a.town_id) === String(town_id)
|
||||
&& a.celebration_type === celebration_type
|
||||
&& (a.finished_at ?? 0) > nowTs) {
|
||||
const msg = `${label} ήδη ενεργή στην πόλη ${town_id}.`;
|
||||
await reportCultureResult(cmd.id, 'failed', msg);
|
||||
return { ok: false, msg };
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { /* model not loaded — proceed; server already validated */ }
|
||||
|
||||
// Human-like reaction delay — same as all other executors
|
||||
const reactionMs = randInt(800, 2500);
|
||||
log(`[αγορά] Waiting ${reactionMs}ms (reaction time)...`);
|
||||
await sleep(reactionMs);
|
||||
|
||||
if (paused) {
|
||||
await reportCultureResult(cmd.id, 'failed', 'Aborted due to pause/captcha');
|
||||
return { ok: false, msg: 'Paused' };
|
||||
}
|
||||
|
||||
// Fire-and-forget — exact same 3-arg pattern used everywhere in this codebase.
|
||||
// AutoFarm confirms: page=building_place, action=start_celebration,
|
||||
// params={ celebration_type, town_id }
|
||||
uw.gpAjax.ajaxPost('building_place', 'start_celebration', {
|
||||
town_id: parseInt(town_id, 10),
|
||||
celebration_type: celebration_type
|
||||
});
|
||||
|
||||
await sleep(500);
|
||||
const successMsg = `${label} εστάλη επιτυχώς (${source}).`;
|
||||
log(`[αγορά] ✅ ${successMsg} (πόλη ${town_id})`);
|
||||
await reportCultureResult(cmd.id, 'done', successMsg);
|
||||
return { ok: true, msg: successMsg };
|
||||
}
|
||||
256
bot_modules/05_main.js
Normal file
256
bot_modules/05_main.js
Normal file
@@ -0,0 +1,256 @@
|
||||
// ================================================================
|
||||
// 05_main.js — Poll loop, command dispatch, boot
|
||||
// Depends on: everything above
|
||||
// ================================================================
|
||||
|
||||
// Shared farm state — prevents auto-farm and explicit farm_loot commands
|
||||
// from running concurrently. Also caches last-known farm settings so the
|
||||
// auto-farm loop doesn't need its own API call.
|
||||
let farmLootRunning = false;
|
||||
let lastKnownFarmSettings = {};
|
||||
|
||||
// Loot option → cooldown ms (matches game's farm timer options)
|
||||
const LOOT_TIMINGS = { 1: 300000, 2: 1200000, 3: 5400000, 4: 14400000 };
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// scheduleNextFarm — fires autoFarmLoop once, then reschedules
|
||||
// Delay = loot_option cooldown + random 30-120s human jitter.
|
||||
// This mirrors ModernBot's pattern: run exactly when farms are ready.
|
||||
// ----------------------------------------------------------------
|
||||
function scheduleNextFarm(isFirstRun = false) {
|
||||
let totalMs = 15000; // 15 seconds for the first run to catch already-ready farms
|
||||
if (!isFirstRun) {
|
||||
const option = lastKnownFarmSettings.loot_option || 1;
|
||||
const baseMs = LOOT_TIMINGS[option] || 300000;
|
||||
const jitterMs = randInt(30000, 120000); // +30 to +120 s
|
||||
totalMs = baseMs + jitterMs;
|
||||
}
|
||||
log(`⏰ Next auto-farm in ${(totalMs / 60000).toFixed(1)} min`);
|
||||
setTimeout(async () => {
|
||||
await autoFarmLoop();
|
||||
scheduleNextFarm(false);
|
||||
}, totalMs);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// pollAndExecute — runs every 8–18 s (main command loop)
|
||||
// Handles builds, recruits, market, research, explicit farm commands.
|
||||
// Auto-farm has its own separate loop below.
|
||||
// ----------------------------------------------------------------
|
||||
async function pollAndExecute() {
|
||||
if (paused) return;
|
||||
const player_id = uw.Game?.player_id;
|
||||
const world_id = uw.Game?.world_id;
|
||||
if (!player_id) return;
|
||||
|
||||
let cmdData;
|
||||
try {
|
||||
const res = await apiFetch(`${BASE_URL}/api/commands/pending?player_id=${player_id}&world_id=${world_id}`);
|
||||
cmdData = await res.json();
|
||||
} catch (e) {
|
||||
log(`Poll failed: ${e}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache farm + bot settings so autonomous loops can read them without extra calls
|
||||
lastKnownFarmSettings = cmdData.farm_settings || {};
|
||||
lastKnownBotSettings = cmdData.bot_settings || {};
|
||||
|
||||
// Handle manual bootcamp attack trigger
|
||||
if (lastKnownBotSettings.attack_now) {
|
||||
log('Manual bootcamp attack requested! Firing immediately...');
|
||||
// Fire asynchronously so it doesn't block the rest of pollAndExecute
|
||||
setTimeout(autoBootcampLoop, 0);
|
||||
}
|
||||
|
||||
// Feature flags — default to all on if server doesn't send them (backward compatible)
|
||||
const features = cmdData.enabled_features || ['farm', 'admin'];
|
||||
const farmOn = features.includes('farm');
|
||||
const adminOn = features.includes('admin');
|
||||
|
||||
// Build: one command per town (server returns an array)
|
||||
const buildCmds = adminOn ? (cmdData.builds || []) : [];
|
||||
const recruitCmd = adminOn ? cmdData.recruit : null;
|
||||
const marketCmd = adminOn ? cmdData.market : null;
|
||||
const researchCmd = adminOn ? cmdData.research : null;
|
||||
const farmCmd = farmOn ? cmdData.farm : null;
|
||||
const farmUpgradeCmd = farmOn ? cmdData.farm_upgrade : null;
|
||||
const cultureCmd = cmdData.culture || null;
|
||||
|
||||
if (cmdData.sync_requested) {
|
||||
log('Sync requested by server — pushing state immediately');
|
||||
pushState();
|
||||
}
|
||||
|
||||
const execute = async (cmd) => {
|
||||
if (!cmd) return;
|
||||
log(`Executing command #${cmd.id} — type:${cmd.type} town:${cmd.town_id}`);
|
||||
if (paused) {
|
||||
log(`[Paused] Ignoring command #${cmd.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
if (cmd.type === 'build') result = await executeBuild(cmd);
|
||||
else if (cmd.type === 'recruit') result = await executeRecruit(cmd);
|
||||
else if (cmd.type === 'market_offer') result = await executeMarketOffer(cmd);
|
||||
else if (cmd.type === 'research') result = await executeResearch(cmd);
|
||||
else if (cmd.type === 'farm_upgrade') result = await executeFarmUpgrade(cmd);
|
||||
else if (cmd.type === 'culture') {
|
||||
// executeCultureCommand reports to /api/culture/result directly
|
||||
// — do NOT call the shared reportResult() afterwards
|
||||
await executeCultureCommand(cmd);
|
||||
return;
|
||||
}
|
||||
else if (cmd.type === 'farm_loot') {
|
||||
// Guard: if auto-farm is mid-run, requeue rather than overlap
|
||||
if (farmLootRunning) {
|
||||
result = { ok: false, requeue: true, msg: 'Auto-farm in progress — requeueing' };
|
||||
} else {
|
||||
farmLootRunning = true;
|
||||
try { result = await executeFarmLoot(cmd); }
|
||||
finally { farmLootRunning = false; }
|
||||
}
|
||||
}
|
||||
else result = { ok: false, msg: `Unknown type: ${cmd.type}` };
|
||||
} catch (e) {
|
||||
result = { ok: false, msg: `Exception: ${e}` };
|
||||
}
|
||||
|
||||
const finalStatus = result.requeue ? 'pending' : (result.ok ? 'done' : 'failed');
|
||||
log(`Command #${cmd.id}: ${finalStatus === 'done' ? '✅' : finalStatus === 'pending' ? '⏳' : '❌'} ${result.msg}`);
|
||||
reportResult(cmd.id, finalStatus, result.msg);
|
||||
};
|
||||
|
||||
// Execute one build command per town (simultaneous queue draining across all villages)
|
||||
for (let i = 0; i < buildCmds.length; i++) {
|
||||
await execute(buildCmds[i]);
|
||||
if (i < buildCmds.length - 1) {
|
||||
// Random inter-town gap — avoids looking like a macro
|
||||
const gap = randInt(1500, 3000);
|
||||
log(`Build: town done. Waiting ${gap}ms before next town...`);
|
||||
await sleep(gap);
|
||||
}
|
||||
}
|
||||
await execute(recruitCmd);
|
||||
await execute(marketCmd);
|
||||
await execute(researchCmd);
|
||||
await execute(farmCmd);
|
||||
await execute(farmUpgradeCmd);
|
||||
await execute(cultureCmd);
|
||||
}
|
||||
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// autoFarmLoop — runs every 60–120 s (independent of main poll)
|
||||
// Checks warehouse capacity and loots ready farms automatically.
|
||||
// Completely decoupled from pollAndExecute so builds/recruits
|
||||
// are never blocked by the long inter-island farm delays.
|
||||
// ----------------------------------------------------------------
|
||||
async function autoFarmLoop() {
|
||||
if (paused) return;
|
||||
|
||||
const farmSettings = lastKnownFarmSettings;
|
||||
if (!farmSettings.enabled) return;
|
||||
|
||||
// Don't overlap with an explicit farm_loot command running in main loop
|
||||
if (farmLootRunning) {
|
||||
log('Auto-farm: explicit farm_loot in progress — skipping this cycle');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any farms are actually ready before doing anything heavy
|
||||
const nowTs = Math.floor(Date.now() / 1000);
|
||||
let readyFarms = [];
|
||||
try {
|
||||
const coll = uw.MM.getOnlyCollectionByName('FarmTownPlayerRelation');
|
||||
readyFarms = coll?.models?.filter(r =>
|
||||
r.attributes.relation_status === 1 &&
|
||||
(r.attributes.lootable_at || 0) <= nowTs
|
||||
) || [];
|
||||
} catch (e) { return; }
|
||||
|
||||
if (readyFarms.length === 0) return;
|
||||
log(`⚡ Auto-farm: ${readyFarms.length} ready farms found`);
|
||||
|
||||
// Check if ALL warehouses are already full (>95%) — no point looting
|
||||
const towns = Object.values(uw.ITowns?.towns || {});
|
||||
let allFull = true;
|
||||
for (const town of towns) {
|
||||
const res = town.resources?.() || {};
|
||||
let storage = town.getStorageCapacity?.() || 0;
|
||||
if (!storage) {
|
||||
const buildings = town.buildings?.()?.attributes || {};
|
||||
const storageLevel = buildings.storage ?? 0;
|
||||
const gd = uw.GameData?.buildingData?.storage;
|
||||
storage = gd?.max_storage?.[storageLevel] || gd?.storage?.[storageLevel] || 0;
|
||||
}
|
||||
if (!storage) storage = res.capacity || res.storage_capacity || res.storage || 0;
|
||||
if (!storage) continue;
|
||||
const maxRes = Math.max(res.wood || 0, res.stone || 0, res.iron || 0);
|
||||
if (maxRes / storage < 0.95) { allFull = false; break; }
|
||||
}
|
||||
|
||||
const player_id = uw.Game?.player_id;
|
||||
if (allFull) {
|
||||
log('⚠️ Auto-farm: ALL warehouses are full (>95%) — skipping loot this cycle');
|
||||
try {
|
||||
const world_id = uw.Game?.world_id || '';
|
||||
await apiFetch(`${BASE_URL}/api/farm_status?player_id=${player_id}&world_id=${world_id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ warehouse_full: true })
|
||||
});
|
||||
} catch (e) {}
|
||||
return;
|
||||
}
|
||||
|
||||
// All clear — run the loot
|
||||
farmLootRunning = true;
|
||||
try {
|
||||
await executeFarmLoot({ payload: { loot_option: farmSettings.loot_option } });
|
||||
// Report success so dashboard shows last_farmed_at
|
||||
try {
|
||||
const world_id = uw.Game?.world_id || '';
|
||||
await apiFetch(`${BASE_URL}/api/farm_status?player_id=${player_id}&world_id=${world_id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ warehouse_full: false })
|
||||
});
|
||||
} catch (e) {}
|
||||
pushState();
|
||||
} finally {
|
||||
farmLootRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Boot — works whether page is already loaded or not.
|
||||
// When eval()'d dynamically the 'load' event has already fired,
|
||||
// so we check readyState and boot immediately in that case.
|
||||
// ----------------------------------------------------------------
|
||||
function boot() {
|
||||
log('Grepolis Remote Control v4.2.0 (remote) loaded');
|
||||
detectCaptcha();
|
||||
setTimeout(pushState, 5000);
|
||||
jitterLoop(pushState, 60000, 120000); // state sync every 1–2 min
|
||||
jitterLoop(pollAndExecute, 8000, 18000, 2000); // command poll every 8–18 s, but start in 2s
|
||||
scheduleNextFarm(true); // auto-farm timer-based (loot_option + 30–120s)
|
||||
jitterLoop(autoBootcampLoop, 720000, 1320000); // bootcamp every 12–22 min
|
||||
jitterLoop(autoRuralTradeLoop, 1500000, 2700000); // rural trade every 25–45 min
|
||||
if (typeof window._grcInitTracker === 'function') {
|
||||
window._grcInitTracker(); // live tracker event-driven
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
// Page already loaded (normal case when eval()'d dynamically).
|
||||
// Use setTimeout so the rest of the concatenated modules (like 06 and 07)
|
||||
// have a chance to evaluate and expose their init functions before boot runs.
|
||||
setTimeout(boot, 0);
|
||||
} else {
|
||||
// Fallback: wait for load event (shouldn't happen but safe to keep)
|
||||
window.addEventListener('load', boot);
|
||||
}
|
||||
172
bot_modules/06_tracker.js
Normal file
172
bot_modules/06_tracker.js
Normal file
@@ -0,0 +1,172 @@
|
||||
// ================================================================
|
||||
// 06_tracker.js — Live Tracker: movement & attack monitoring
|
||||
// Depends on: 00_config.js (BASE_URL, apiFetch, log)
|
||||
//
|
||||
// Strategy (Option B — event-driven + one initial load):
|
||||
// 1. On boot: read current movements from game memory, push to backend
|
||||
// 2. On GameEvents.attack.incoming or GameEvents.command.change:
|
||||
// re-read movements, push to backend (backend notifies SSE clients)
|
||||
//
|
||||
// Data source: CommandsMenuBubble (Grepolis internal Backbone model)
|
||||
// - Already used by the Sound Alarm script — proven safe
|
||||
// - Zero extra server requests to Grepolis
|
||||
// - Contains ALL movement types: incoming attacks, own attacks, support
|
||||
//
|
||||
// All pushes go to POST /api/<world_id>/movements with X-Clan-Key.
|
||||
// Backend is fully isolated per player_id + world_id.
|
||||
// ================================================================
|
||||
|
||||
(function() {
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Internal state — prevent overlapping pushes
|
||||
// ----------------------------------------------------------------
|
||||
let _trackerPushPending = false;
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _extractMovements — reads CommandsMenuBubble from game memory
|
||||
// Returns a clean array of movement objects safe to send to backend.
|
||||
//
|
||||
// Source: Sound Alarm script already validated this model works.
|
||||
// We read .commands which is a list of all active troop movements.
|
||||
// ----------------------------------------------------------------
|
||||
function _extractMovements() {
|
||||
try {
|
||||
const player_id = uw.Game?.player_id;
|
||||
if (!player_id) return [];
|
||||
|
||||
// CommandsMenuBubble holds all movement commands for the player
|
||||
const cmb = uw.MM.checkAndPublishRawModel('CommandsMenuBubble', { id: player_id });
|
||||
if (!cmb) return [];
|
||||
|
||||
const commands = cmb.get('commands') || [];
|
||||
const movements = [];
|
||||
|
||||
for (const cmd of commands) {
|
||||
const attrs = cmd.attributes || cmd;
|
||||
if (!attrs) continue;
|
||||
|
||||
// Normalise command type to a readable key
|
||||
const cmdType = _normaliseType(attrs.type || attrs.command_type || '');
|
||||
|
||||
movements.push({
|
||||
id: String(attrs.id || attrs.command_id || ''),
|
||||
type: cmdType,
|
||||
origin_town: attrs.origin_town_name || attrs.origin?.town_name || null,
|
||||
origin_player: attrs.origin_player_name|| attrs.origin?.player_name|| null,
|
||||
target_town: attrs.target_town_name || attrs.target?.town_name || null,
|
||||
target_player: attrs.target_player_name|| attrs.target?.player_name|| null,
|
||||
// arrival_at is a Unix timestamp (seconds)
|
||||
arrival_at: attrs.arrival_at || attrs.arrival || null,
|
||||
});
|
||||
}
|
||||
|
||||
return movements.filter(m => m.id); // drop any without an ID
|
||||
|
||||
} catch (e) {
|
||||
log(`[tracker] Extract error: ${e}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _normaliseType — maps game's internal type strings to clean keys
|
||||
// ----------------------------------------------------------------
|
||||
function _normaliseType(raw) {
|
||||
const t = String(raw).toLowerCase();
|
||||
if (t.includes('attack') && t.includes('sea')) return 'attack_sea';
|
||||
if (t.includes('attack') && t.includes('land')) return 'attack_land';
|
||||
if (t.includes('attack')) return 'attack_land';
|
||||
if (t.includes('support')) return 'support';
|
||||
if (t.includes('farm') || t.includes('loot')) return 'farming';
|
||||
if (t.includes('spy') || t.includes('espion')) return 'espionage';
|
||||
if (t.includes('settle') || t.includes('colon'))return 'colonization';
|
||||
return t || 'unknown';
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// _pushMovements — reads memory, sends to backend
|
||||
// Debounced: if a push is already in-flight, skip.
|
||||
// ----------------------------------------------------------------
|
||||
async function _pushMovements() {
|
||||
if (_trackerPushPending) return;
|
||||
_trackerPushPending = true;
|
||||
|
||||
try {
|
||||
const player_id = uw.Game?.player_id;
|
||||
const world_id = uw.Game?.world_id;
|
||||
if (!player_id || !world_id) return;
|
||||
|
||||
const movements = _extractMovements();
|
||||
log(`[tracker] Pushing ${movements.length} movement(s) for ${world_id}`);
|
||||
|
||||
await apiFetch(`${BASE_URL}/api/${world_id}/movements`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ player_id, world_id, movements })
|
||||
});
|
||||
} catch (e) {
|
||||
log(`[tracker] Push failed: ${e}`);
|
||||
} finally {
|
||||
_trackerPushPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// initTracker — called from boot() after game is ready
|
||||
//
|
||||
// 1. Immediate push (Option B initial load)
|
||||
// 2. Bind GameEvents for passive real-time updates
|
||||
// ----------------------------------------------------------------
|
||||
function initTracker() {
|
||||
// Wait a moment for the game models to fully initialise
|
||||
setTimeout(async () => {
|
||||
// --- Initial load push ---
|
||||
await _pushMovements();
|
||||
|
||||
// --- Bind to GameEvents (passive, zero server cost) ---
|
||||
try {
|
||||
// New incoming attack detected (This event works according to logs)
|
||||
const attackEvent = uw.GameEvents?.attack?.incoming || 'attack:incoming';
|
||||
uw.$.Observer(attackEvent).subscribe(
|
||||
'GRC_TRACKER_ATTACK',
|
||||
function(e, data) {
|
||||
// Small delay so game model updates before we read it
|
||||
setTimeout(_pushMovements, 500);
|
||||
}
|
||||
);
|
||||
log('[tracker] ✅ Subscribed to attack.incoming event');
|
||||
} catch (e) {
|
||||
log(`[tracker] Could not subscribe to attack.incoming: ${e}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Any command state changed (sent, landed, recalled, etc.)
|
||||
// Fallback to string if the constant doesn't exist in this version
|
||||
const cmdEvent = (uw.GameEvents?.command && uw.GameEvents.command.change)
|
||||
? uw.GameEvents.command.change
|
||||
: 'CommandsMenuBubble:change';
|
||||
|
||||
uw.$.Observer(cmdEvent).subscribe(
|
||||
'GRC_TRACKER_CMD',
|
||||
function(e, data) {
|
||||
setTimeout(_pushMovements, 500);
|
||||
}
|
||||
);
|
||||
log('[tracker] ✅ Subscribed to command changes');
|
||||
} catch (e) {
|
||||
log(`[tracker] Could not subscribe to command changes: ${e}`);
|
||||
}
|
||||
|
||||
// --- Failsafe: push every 15 seconds regardless of events ---
|
||||
setInterval(_pushMovements, 15000);
|
||||
|
||||
}, 6000); // 6s after boot — ensures CommandsMenuBubble model is loaded
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Expose initTracker so 05_main.js boot() can call it
|
||||
// ----------------------------------------------------------------
|
||||
window._grcInitTracker = initTracker;
|
||||
|
||||
})();
|
||||
288
db.py
288
db.py
@@ -1,5 +1,6 @@
|
||||
import sqlite3
|
||||
import os
|
||||
import secrets
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), 'grepo.db')
|
||||
|
||||
@@ -24,6 +25,7 @@ def init_db():
|
||||
payload TEXT NOT NULL, -- JSON string
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | executing | done | failed
|
||||
result_msg TEXT,
|
||||
position INTEGER, -- manual sort order for build queue (lower = first)
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
@@ -66,7 +68,189 @@ def init_db():
|
||||
)
|
||||
''')
|
||||
|
||||
# Bot settings — per-player config for bootcamp & rural-trade auto-loops
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS bot_settings (
|
||||
player_id TEXT PRIMARY KEY,
|
||||
bootcamp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
bootcamp_use_def INTEGER NOT NULL DEFAULT 0,
|
||||
rural_trade_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
rural_trade_ratio INTEGER NOT NULL DEFAULT 3, -- 1=0.25 2=0.5 3=0.75 4=1.0 5=1.25
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
''')
|
||||
|
||||
# Bot logs — ring buffer of last 50 entries per player per feature
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS bot_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_id TEXT NOT NULL,
|
||||
world_id TEXT NOT NULL DEFAULT '',
|
||||
feature TEXT NOT NULL, -- 'bootcamp' | 'rural_trade'
|
||||
message TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
''')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_bot_logs_player_feature ON bot_logs(player_id, feature)')
|
||||
|
||||
# Celebrations — active celebration state per town, pushed by the bot
|
||||
# One row per town_id + celebration_type. Upserted on every state push.
|
||||
# finished_at = 0 means no active celebration of that type.
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS celebrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_id TEXT NOT NULL,
|
||||
world_id TEXT NOT NULL,
|
||||
town_id TEXT NOT NULL,
|
||||
town_name TEXT,
|
||||
celebration_type TEXT NOT NULL, -- 'party' | 'triumph'
|
||||
finished_at INTEGER NOT NULL DEFAULT 0, -- unix timestamp (0 = not running)
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(player_id, world_id, town_id, celebration_type)
|
||||
)
|
||||
''')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_celebrations_player_world ON celebrations(player_id, world_id)')
|
||||
|
||||
# Culture log — immutable audit log of every Αγορά command fired from the dashboard
|
||||
# Records what was fired, what it cost, and whether the bot confirmed success.
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS culture_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_id TEXT NOT NULL,
|
||||
world_id TEXT NOT NULL,
|
||||
town_id TEXT NOT NULL,
|
||||
town_name TEXT,
|
||||
celebration_type TEXT NOT NULL, -- 'party' | 'triumph'
|
||||
cost_wood INTEGER NOT NULL DEFAULT 0,
|
||||
cost_stone INTEGER NOT NULL DEFAULT 0,
|
||||
cost_iron INTEGER NOT NULL DEFAULT 0,
|
||||
cost_battle_pts INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | success | failed
|
||||
source TEXT NOT NULL DEFAULT 'manual', -- 'manual' | 'auto'
|
||||
result_msg TEXT,
|
||||
fired_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
confirmed_at TEXT
|
||||
)
|
||||
''')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_culture_log_player_world ON culture_log(player_id, world_id)')
|
||||
|
||||
# Culture queue — dedicated queue for celebration commands (separate from commands table).
|
||||
# Max 1 pending/executing per player+world+celebration_type enforced at app level.
|
||||
# source: 'manual' (dashboard button) | 'auto' (server-side auto-fire from state push)
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS culture_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_id TEXT NOT NULL,
|
||||
world_id TEXT NOT NULL,
|
||||
town_id TEXT NOT NULL,
|
||||
town_name TEXT NOT NULL DEFAULT '',
|
||||
celebration_type TEXT NOT NULL, -- 'party' | 'triumph'
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | executing | done | failed
|
||||
source TEXT NOT NULL DEFAULT 'manual', -- 'manual' | 'auto'
|
||||
result_msg TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
executed_at TEXT
|
||||
)
|
||||
''')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_culture_queue_player_world ON culture_queue(player_id, world_id, status)')
|
||||
|
||||
# Culture settings — per-town auto-mode toggle (party / triumph).
|
||||
# One row per player+world+town. Updated from Αγορά dashboard toggles.
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS culture_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_id TEXT NOT NULL,
|
||||
world_id TEXT NOT NULL,
|
||||
town_id TEXT NOT NULL,
|
||||
auto_party INTEGER NOT NULL DEFAULT 0,
|
||||
auto_triumph INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(player_id, world_id, town_id)
|
||||
)
|
||||
''')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_culture_settings_player_world ON culture_settings(player_id, world_id)')
|
||||
|
||||
|
||||
# Blueprints - assigns a blueprint to a specific town
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS town_blueprints (
|
||||
town_id TEXT PRIMARY KEY,
|
||||
blueprint_name TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
''')
|
||||
|
||||
# Troop movements — pushed by Tampermonkey from game events
|
||||
# Fully isolated per player_id + world_id.
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS movements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_id TEXT NOT NULL,
|
||||
world_id TEXT NOT NULL,
|
||||
command_id TEXT NOT NULL,
|
||||
cmd_type TEXT NOT NULL,
|
||||
origin_town TEXT,
|
||||
origin_player TEXT,
|
||||
target_town TEXT,
|
||||
target_player TEXT,
|
||||
arrival_at INTEGER,
|
||||
raw_data TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(player_id, world_id, command_id)
|
||||
)
|
||||
''')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_movements_player_world ON movements(player_id, world_id)')
|
||||
|
||||
# Attack Plans — coordinated timed strikes across multiple players/towns
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS attack_plans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
world_id TEXT NOT NULL,
|
||||
plan_name TEXT NOT NULL,
|
||||
created_by_player_id TEXT NOT NULL,
|
||||
target_town_id TEXT,
|
||||
target_town_name TEXT,
|
||||
target_x REAL,
|
||||
target_y REAL,
|
||||
target_arrival_time INTEGER NOT NULL, -- unix epoch (UTC)
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
-- draft | active | completed | cancelled
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
''')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_attack_plans_world ON attack_plans(world_id, status)')
|
||||
|
||||
# Attack Plan Participants — one row per attacking town per plan
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS attack_plan_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plan_id INTEGER NOT NULL REFERENCES attack_plans(id) ON DELETE CASCADE,
|
||||
player_id TEXT NOT NULL,
|
||||
world_id TEXT NOT NULL,
|
||||
origin_town_id TEXT NOT NULL,
|
||||
origin_town_name TEXT,
|
||||
units TEXT NOT NULL DEFAULT '{}', -- JSON
|
||||
attack_type TEXT, -- 'attack_land' | 'attack_sea'
|
||||
transport_needed INTEGER NOT NULL DEFAULT 0,
|
||||
transport_count INTEGER NOT NULL DEFAULT 0,
|
||||
travel_time_secs INTEGER,
|
||||
send_time INTEGER, -- unix epoch (UTC), calculated
|
||||
return_time INTEGER, -- unix epoch (UTC), calculated
|
||||
is_feasible INTEGER NOT NULL DEFAULT 1,
|
||||
error_msg TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
-- pending | armed | sent | missed | cancelled
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(plan_id, origin_town_id)
|
||||
)
|
||||
''')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_app_plan ON attack_plan_participants(plan_id)')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_app_player ON attack_plan_participants(player_id, world_id)')
|
||||
|
||||
# Migration: add new columns if upgrading an existing database
|
||||
|
||||
for _col in [
|
||||
'ALTER TABLE town_state ADD COLUMN player_id TEXT',
|
||||
'ALTER TABLE town_state ADD COLUMN alliance_id TEXT',
|
||||
@@ -74,12 +258,116 @@ def init_db():
|
||||
'ALTER TABLE town_state ADD COLUMN y REAL',
|
||||
'ALTER TABLE town_state ADD COLUMN sea INTEGER',
|
||||
'ALTER TABLE commands ADD COLUMN player_id TEXT',
|
||||
'ALTER TABLE commands ADD COLUMN position INTEGER',
|
||||
'ALTER TABLE farm_settings ADD COLUMN bandit_camp_enabled INTEGER NOT NULL DEFAULT 0',
|
||||
"ALTER TABLE clan_members ADD COLUMN features TEXT NOT NULL DEFAULT 'farm,admin'",
|
||||
'ALTER TABLE clan_members ADD COLUMN world_id TEXT',
|
||||
'ALTER TABLE users ADD COLUMN clan_id INTEGER REFERENCES clans(id)',
|
||||
# bot_logs gained world_id for per-world isolation
|
||||
"ALTER TABLE bot_logs ADD COLUMN world_id TEXT NOT NULL DEFAULT ''",
|
||||
]:
|
||||
try:
|
||||
c.execute(_col)
|
||||
except Exception:
|
||||
pass # column already exists
|
||||
|
||||
# Back-fill position for existing rows that have NULL position
|
||||
try:
|
||||
c.execute('UPDATE commands SET position = id WHERE position IS NULL')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Users — website admin accounts
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
clan_id INTEGER REFERENCES clans(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
''')
|
||||
|
||||
# Clans — groups owned by a user, identified by a unique clan_key
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS clans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id INTEGER NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
clan_key TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
''')
|
||||
|
||||
# Clan members — links Grepolis player_ids to a clan.
|
||||
# UNIQUE on (clan_id, player_id, world_id) so the same player
|
||||
# appearing in multiple worlds creates separate rows.
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS clan_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
clan_id INTEGER NOT NULL REFERENCES clans(id),
|
||||
player_id TEXT NOT NULL,
|
||||
player_name TEXT,
|
||||
world_id TEXT NOT NULL DEFAULT '',
|
||||
features TEXT NOT NULL DEFAULT 'farm,admin',
|
||||
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(clan_id, player_id, world_id)
|
||||
)
|
||||
''')
|
||||
|
||||
# Migration: if clan_members still has the old UNIQUE(clan_id, player_id) constraint
|
||||
# (without world_id), recreate the table with the correct 3-column constraint.
|
||||
try:
|
||||
tbl_sql = c.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='clan_members'"
|
||||
).fetchone()
|
||||
if tbl_sql and 'player_id, world_id' not in (tbl_sql['sql'] or ''):
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS _clan_members_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
clan_id INTEGER NOT NULL REFERENCES clans(id),
|
||||
player_id TEXT NOT NULL,
|
||||
player_name TEXT,
|
||||
world_id TEXT NOT NULL DEFAULT '',
|
||||
features TEXT NOT NULL DEFAULT 'farm,admin',
|
||||
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(clan_id, player_id, world_id)
|
||||
)
|
||||
''')
|
||||
c.execute('''
|
||||
INSERT OR IGNORE INTO _clan_members_new
|
||||
(id, clan_id, player_id, player_name, world_id, features, joined_at)
|
||||
SELECT id, clan_id, player_id, player_name,
|
||||
COALESCE(world_id, ''), features, joined_at
|
||||
FROM clan_members
|
||||
''')
|
||||
c.execute('DROP TABLE clan_members')
|
||||
c.execute('ALTER TABLE _clan_members_new RENAME TO clan_members')
|
||||
except Exception as _e:
|
||||
print(f'clan_members migration skipped: {_e}')
|
||||
|
||||
# Migration: Auto-assign existing users to their clan_id if they are the owner
|
||||
try:
|
||||
c.execute('''
|
||||
UPDATE users
|
||||
SET clan_id = (SELECT id FROM clans WHERE owner_id = users.id)
|
||||
WHERE clan_id IS NULL AND EXISTS (SELECT 1 FROM clans WHERE owner_id = users.id)
|
||||
''')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: add source column to culture_log (added in v4.3)
|
||||
# Safe to run repeatedly — silently skipped if column already exists.
|
||||
try:
|
||||
c.execute("ALTER TABLE culture_log ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'")
|
||||
except Exception:
|
||||
pass # column already exists
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
def generate_clan_key():
|
||||
"""Generate a short, unique, human-readable clan key."""
|
||||
return secrets.token_urlsafe(8).upper()[:10]
|
||||
|
||||
173
future_ideas.md
Normal file
173
future_ideas.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Future Ideas & Optimizations
|
||||
|
||||
This document tracks potential architectural improvements and features inspired by other Grepolis alliance coordination scripts (like GrepoData and Noct).
|
||||
|
||||
## 1. Timestamp-Based Polling Optimization (`since` parameter)
|
||||
**Inspired by:** `noct-api.grepo-soft.workers.dev`
|
||||
|
||||
**Current State:**
|
||||
The Tampermonkey client polls the server every 8-18 seconds and receives the full state/command payload every time, even if nothing has changed.
|
||||
|
||||
**Proposed Implementation:**
|
||||
- Add a `since` timestamp parameter to the client's poll requests.
|
||||
- The server checks if any commands or state updates have occurred *after* the `since` timestamp.
|
||||
- **If no new data:** The server returns an empty `HTTP 204 No Content` response.
|
||||
- **If new data:** The server returns `HTTP 200 OK` with only the data that changed.
|
||||
|
||||
**Benefits:**
|
||||
- Drastically reduces server bandwidth and CPU load.
|
||||
- Minimizes the size of network requests on the client side, making the script stealthier and less resource-intensive in the browser.
|
||||
|
||||
**Concrete Code Example (How Noct does it):**
|
||||
```javascript
|
||||
// 1. Client-side polling logic
|
||||
let lastFetchTime = Date.now();
|
||||
|
||||
async function pollCommands() {
|
||||
const url = `https://noct-api.grepo-soft.workers.dev/api/alliance/commands` +
|
||||
`?alliance=p0PmzsZMo4xZ2o29uvqggy5d` +
|
||||
`&world=gr118` +
|
||||
`&clientId=848938473` +
|
||||
`&since=${lastFetchTime}`; // Ask only for things after this timestamp
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
// 2. Server returns HTTP 204 (No Content) if nothing new happened
|
||||
if (response.status === 204) {
|
||||
return; // Empty payload, exit early
|
||||
}
|
||||
|
||||
// 3. Server returns HTTP 200 (OK) only if there are NEW commands
|
||||
if (response.status === 200) {
|
||||
const data = await response.json();
|
||||
|
||||
// Process new commands...
|
||||
executeCommands(data.commands);
|
||||
|
||||
// Update the timestamp so the next poll only asks for things after this moment
|
||||
lastFetchTime = Date.now();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# Backend (Python/Flask Equivalent)
|
||||
@app.route('/api/alliance/commands')
|
||||
def get_commands():
|
||||
# Get the timestamp from the URL query
|
||||
since_ts = int(request.args.get('since', 0))
|
||||
|
||||
# Query DB for commands created AFTER the 'since' timestamp
|
||||
new_commands = db.execute(
|
||||
"SELECT * FROM commands WHERE created_at_ts > ?", (since_ts,)
|
||||
).fetchall()
|
||||
|
||||
if not new_commands:
|
||||
# Return empty body with 204 No Content
|
||||
return '', 204
|
||||
|
||||
return jsonify({"commands": new_commands}), 200
|
||||
```
|
||||
|
||||
## 2. WebSocket Architecture for Real-Time Synchronization
|
||||
**Inspired by:** `grepodata.com` (ReactPHP WebSocket server)
|
||||
|
||||
**Current State:**
|
||||
Command delivery relies on HTTP polling. If an attack plan requires a launch in 30 seconds, but the client is on an 18-second polling interval, there is a high risk of missing the execution window or executing late.
|
||||
|
||||
**Proposed Implementation:**
|
||||
- Integrate `Flask-SocketIO` (or a standalone async WebSocket server) into the backend.
|
||||
- The client script establishes a persistent `wss://` connection upon loading the game.
|
||||
- The client authenticates using its `clan_key` and subscribes to its alliance "topic/room".
|
||||
- When an admin arms an attack plan or a player updates their town state, the server instantly *pushes* the payload to all connected alliance members.
|
||||
|
||||
**Benefits:**
|
||||
- **Zero Polling Latency:** Commands arrive in ~100ms instead of 8-18 seconds.
|
||||
- **Perfect Attack Timing:** Ensures clients receive armed plans immediately, maximizing the margin for precise execution.
|
||||
- **Instant UI Updates:** The dashboard can update in real-time as members come online or troop counts change.
|
||||
|
||||
## 3. Server-Sent Events (SSE) Lag/Refresh Bug Fix
|
||||
**Issue:**
|
||||
When refreshing or hitting the "back" button on the Live Tracker (`tracker.html`), the page occasionally hangs, lags heavily, or completely fails to load.
|
||||
|
||||
**Root Cause:**
|
||||
The Live Tracker uses SSE (`EventSource`) to receive real-time movement updates. Modern browsers strictly limit simultaneous HTTP/1.1 connections to the same server (usually 6 maximum). When the user navigates away or refreshes, the browser drops the frontend page, but the Python/Flask backend (`tracker.py`) does not immediately detect the broken pipe and keeps the socket open, waiting to send data.
|
||||
If the user hits refresh multiple times, these "ghost" connections stack up. Upon reaching 6 ghost connections, the browser refuses to load any further requests until the old connections naturally time out (which can take 30+ seconds).
|
||||
|
||||
**Proposed Fix:**
|
||||
1. **Client-side (`tracker.html`)**: Ensure the browser explicitly tells the server the connection is closing exactly as the page unloads.
|
||||
```javascript
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (typeof es !== 'undefined' && es !== null) {
|
||||
es.close();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
2. **Server-side (`tracker.py`)**: Ensure the generator handles client disconnects gracefully and immediately cleans up the subscriber queue without waiting for a timeout.
|
||||
```python
|
||||
# Make sure the generator yields spaces/heartbeats actively so the OS
|
||||
# throws an IOError/GeneratorExit the moment the client drops.
|
||||
```
|
||||
|
||||
## 4. Auto-Culture / Auto-Celebrations
|
||||
**Inspired by:** `3rdparty/AutoFarm - AutoUpgrade villages lvl 6 - AutoCelebrations-NoOrpheus.user.js`
|
||||
|
||||
**Current State:**
|
||||
Players manually check their towns for sufficient resources (Wood, Stone, Silver), Gold, or Battle Points, and manually trigger City Festivals, Olympic Games, Victory Processions, or Theater Plays.
|
||||
|
||||
**Proposed Implementation:**
|
||||
- Add a new bot module (e.g., `Auto-Culture`) containing a background loop that periodically checks the resource counts, gold, and battle points for each active town.
|
||||
- When conditions are met (e.g., 15000 Wood, 18000 Stone, 15000 Silver for a City Festival, or 300 Battle Points for a Victory Procession), send the appropriate API request (action: `start_celebration` or `start_all_celebrations`) to queue the celebration.
|
||||
- Provide dashboard controls so users can selectively enable or disable specific celebration types (keeping Olympic Games off by default to avoid accidental gold spending).
|
||||
|
||||
**Benefits:**
|
||||
- Automates the generation of Culture Points across all towns without user intervention.
|
||||
- Ensures resources aren't maxed out and wasted while the player is away.
|
||||
|
||||
## 5. Auto-WW Donator / Resource Balancer
|
||||
**Inspired by:** `3rdparty/WW Trade Helper.user.js`
|
||||
|
||||
**Current State:**
|
||||
Players manually open the World Wonder window and use UI helpers to calculate how much Wood, Stone, and Silver they can send based on their town's trade capacity and available resources. They then manually click the "Send Resources" button.
|
||||
|
||||
**Proposed Implementation:**
|
||||
- Integrate the calculation logic from `WW Trade Helper` which effectively balances resource distribution (e.g., Even distribution, No Wood, No Stone, No Silver).
|
||||
- Create a bot module that periodically checks cities for excess resources and available trade capacity.
|
||||
- Automatically construct and send the `ajaxPost` payload to the World Wonder endpoint (`model_url: 'WorldWonder'`, `action_name: 'sendResources'`, etc.) without requiring the World Wonder window to be open or requiring manual button clicks.
|
||||
|
||||
**Benefits:**
|
||||
- Maximizes alliance contributions to World Wonders around the clock.
|
||||
- Perfectly balances resources sent so the player doesn't accidentally empty their town of a critical resource.
|
||||
|
||||
## 6. On-Demand Market Capacity & Trade Order Monitor
|
||||
**Inspired by:** `3rdparty/ModernBot.user.js` (`getAllTrades`) and `3rdparty/DIO-TOOLS-David1327 stamasPacket.user.js` (transport capacity logic)
|
||||
|
||||
**Background & Why Previous Attempts Failed:**
|
||||
Earlier attempts to read market capacity via `town.getAvailableTradeCapacity()` and active orders via `uw.MM.getCollections().Trade` consistently returned empty/zero values. The root cause: both APIs depend on Grepolis having pre-loaded the town's trade models in memory, which only happens if the user has recently opened that specific town's market window. For towns the user hasn't visited this session, the models are simply absent.
|
||||
|
||||
**Proposed Implementation (On-Demand, Single AJAX Call):**
|
||||
- Add a **[Sync Market 🔄]** button in the dashboard town details panel.
|
||||
- On click, the dashboard sends a `POST /api/market-sync-request` to Flask.
|
||||
- The bot detects the pending request during its next poll cycle and executes a single AJAX call:
|
||||
```javascript
|
||||
uw.gpAjax.ajaxGet('town_overviews', 'trade_overview', {}, true, e => {
|
||||
// e.towns_merchants → available & total merchant capacity per town
|
||||
// e.movements → all active incoming & outgoing shipments
|
||||
});
|
||||
```
|
||||
- The `true` flag fetches data for **all towns simultaneously** — one call, full empire snapshot.
|
||||
- The bot pushes the result to Flask via `POST /api/market-state`.
|
||||
- The dashboard polls and renders:
|
||||
- **Capacity** inline in the town detail panel (e.g., `Merchants: 8 / 15`)
|
||||
- **Shipments** as a small list: `→ Αθήνα: 2,500 🪵 (12m 30s)` / `← Σπάρτη: 1,800 ⛏️ (3m)`
|
||||
|
||||
**UI Layout (TBD):**
|
||||
- Capacity shown inline with resources (compact).
|
||||
- Shipments shown in a collapsible section or modal — to be decided with user.
|
||||
|
||||
**Benefits:**
|
||||
- Zero automatic polling — completely stealthy, fires only when the user explicitly requests it.
|
||||
- Bypasses the model-dependency problem entirely by going straight to the Grepolis server.
|
||||
- A single AJAX call populates market data for all towns at once, making it highly efficient.
|
||||
- Follows the existing Live Sync button pattern — no new architectural concepts needed.
|
||||
@@ -1,3 +1,4 @@
|
||||
flask
|
||||
flask-cors
|
||||
flask-login
|
||||
gunicorn
|
||||
666
routes/api.py
666
routes/api.py
@@ -1,11 +1,50 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from db import get_db
|
||||
import json
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
from flask import make_response
|
||||
from blueprint_engine import evaluate_blueprints
|
||||
import logging
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
api = Blueprint('api', __name__)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helper — look up clan by the X-Clan-Key header.
|
||||
# Returns the clan row dict, or None if key is missing / invalid.
|
||||
# ------------------------------------------------------------------
|
||||
def _get_clan_from_request():
|
||||
key = request.headers.get('X-Clan-Key', '').strip()
|
||||
if not key:
|
||||
return None
|
||||
conn = get_db()
|
||||
clan = conn.execute('SELECT * FROM clans WHERE clan_key = ?', (key,)).fetchone()
|
||||
conn.close()
|
||||
return clan
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helper — auto-register a player_id under a clan on first push.
|
||||
# ------------------------------------------------------------------
|
||||
def _auto_register_member(clan_id, player_id, player_name, world_id=''):
|
||||
world_id = world_id or ''
|
||||
conn = get_db()
|
||||
conn.execute('''
|
||||
INSERT OR IGNORE INTO clan_members (clan_id, player_id, player_name, world_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (clan_id, str(player_id), player_name or '', world_id))
|
||||
# Update name on every push (it can change); world_id is part of the key so no overwrite risk
|
||||
conn.execute('''
|
||||
UPDATE clan_members SET player_name = ?
|
||||
WHERE clan_id = ? AND player_id = ? AND world_id = ?
|
||||
''', (player_name or '', clan_id, str(player_id), world_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/state
|
||||
# Tampermonkey pushes a full town snapshot every poll cycle.
|
||||
@@ -16,11 +55,17 @@ def receive_state():
|
||||
if not data:
|
||||
return jsonify({'error': 'no data'}), 400
|
||||
|
||||
towns = data.get('towns', [])
|
||||
player = data.get('player', '')
|
||||
player_id = data.get('player_id', '')
|
||||
alliance_id = str(data.get('alliance_id', '') or '')
|
||||
world_id = data.get('world_id', '')
|
||||
towns = data.get('towns', [])
|
||||
player = data.get('player', '')
|
||||
player_id = data.get('player_id', '')
|
||||
alliance_id = str(data.get('alliance_id', '') or '')
|
||||
world_id = data.get('world_id', '')
|
||||
battle_points = data.get('battle_points', {})
|
||||
|
||||
# Auto-register this player to the clan that matches the key (if any)
|
||||
clan = _get_clan_from_request()
|
||||
if clan:
|
||||
_auto_register_member(clan['id'], player_id, player, world_id)
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
@@ -28,6 +73,7 @@ def receive_state():
|
||||
x = town.get('x')
|
||||
y = town.get('y')
|
||||
sea = town.get('sea')
|
||||
town['battle_points'] = battle_points
|
||||
c.execute('''
|
||||
INSERT INTO town_state
|
||||
(town_id, town_name, player, player_id, alliance_id, world_id, x, y, sea, data, updated_at)
|
||||
@@ -55,26 +101,183 @@ def receive_state():
|
||||
datetime.utcnow().isoformat()
|
||||
))
|
||||
conn.commit()
|
||||
|
||||
# Store world speed + unit data
|
||||
world_speed = data.get('world_speed')
|
||||
unit_speeds = data.get('unit_speeds')
|
||||
if world_id and world_speed is not None and unit_speeds:
|
||||
world_data = json.dumps({'world_speed': world_speed, 'unit_speeds': unit_speeds})
|
||||
c.execute('''
|
||||
INSERT INTO kv_store (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||
''', (f'world_data_{world_id}', world_data, datetime.utcnow().isoformat()))
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
evaluate_blueprints(conn)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
print("Error evaluating blueprints:", e)
|
||||
|
||||
# Upsert active celebrations (party / triumph cooldowns per town)
|
||||
celebrations = data.get('celebrations', [])
|
||||
if celebrations and player_id and world_id:
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
for cel in celebrations:
|
||||
town_id_cel = str(cel.get('town_id', ''))
|
||||
cel_type = cel.get('celebration_type', '')
|
||||
finished_at = int(cel.get('finished_at', 0))
|
||||
if not town_id_cel or not cel_type:
|
||||
continue
|
||||
# Resolve town_name from what we just upserted
|
||||
t_name_row = c.execute(
|
||||
'SELECT town_name FROM town_state WHERE town_id = ?', (town_id_cel,)
|
||||
).fetchone()
|
||||
t_name = t_name_row['town_name'] if t_name_row else ''
|
||||
c.execute('''\
|
||||
INSERT INTO celebrations
|
||||
(player_id, world_id, town_id, town_name, celebration_type, finished_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(player_id, world_id, town_id, celebration_type) DO UPDATE SET
|
||||
town_name = excluded.town_name,
|
||||
finished_at = excluded.finished_at,
|
||||
updated_at = excluded.updated_at
|
||||
''', (str(player_id), world_id, town_id_cel, t_name, cel_type, finished_at, now_iso))
|
||||
conn.commit()
|
||||
|
||||
# ── Auto-culture: check per-town settings and queue if eligible ──
|
||||
# Runs on every state push — no extra game polling needed.
|
||||
_auto_culture_check(c, str(player_id), world_id, towns, battle_points)
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'towns_updated': len(towns)})
|
||||
|
||||
|
||||
# Cost constants (must match dashboard.py PARTY_COST / TRIUMPH_COST)
|
||||
_PARTY_COST = {'wood': 15000, 'stone': 18000, 'iron': 15000}
|
||||
_TRIUMPH_BP = 300
|
||||
|
||||
def _auto_culture_check(c, player_id, world_id, towns, battle_points):
|
||||
"""Called after every state push.
|
||||
For each town that has auto_party or auto_triumph enabled:
|
||||
- skip if a pending/executing entry already exists in culture_queue
|
||||
- skip if celebration cooldown hasn't expired yet
|
||||
- skip if resources/battle-points are insufficient
|
||||
- otherwise insert a new 'auto' entry into culture_queue
|
||||
"""
|
||||
if not player_id or not world_id:
|
||||
return
|
||||
|
||||
now_ts = int(datetime.utcnow().timestamp())
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
bp_available = battle_points.get('available', 0) if isinstance(battle_points, dict) else 0
|
||||
|
||||
# Load auto settings for this player/world
|
||||
auto_rows = c.execute(
|
||||
'SELECT town_id, auto_party, auto_triumph FROM culture_settings WHERE player_id = ? AND world_id = ?',
|
||||
(player_id, world_id)
|
||||
).fetchall()
|
||||
if not auto_rows:
|
||||
return
|
||||
|
||||
# Build a quick lookup of town data from the state payload
|
||||
town_map = {str(t.get('town_id', '')): t for t in towns}
|
||||
|
||||
for auto_row in auto_rows:
|
||||
tid = str(auto_row['town_id'])
|
||||
td = town_map.get(tid)
|
||||
if not td:
|
||||
continue # town not in this state push (shouldn't happen but guard anyway)
|
||||
town_name = td.get('town_name', '')
|
||||
|
||||
for cel_type, enabled in [('party', auto_row['auto_party']), ('triumph', auto_row['auto_triumph'])]:
|
||||
if not enabled:
|
||||
continue
|
||||
|
||||
# 1. Already queued?
|
||||
existing = c.execute(
|
||||
"SELECT id FROM culture_queue WHERE player_id=? AND world_id=? AND celebration_type=? AND status IN ('pending','executing')",
|
||||
(player_id, world_id, cel_type)
|
||||
).fetchone()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
# 2. Cooldown still active?
|
||||
cel_cd = c.execute(
|
||||
'SELECT finished_at FROM celebrations WHERE player_id=? AND world_id=? AND town_id=? AND celebration_type=?',
|
||||
(player_id, world_id, tid, cel_type)
|
||||
).fetchone()
|
||||
if cel_cd and int(cel_cd['finished_at'] or 0) > now_ts:
|
||||
continue # still cooling down
|
||||
|
||||
# 3. Resources check
|
||||
if cel_type == 'party':
|
||||
if (td.get('wood', 0) < _PARTY_COST['wood'] or
|
||||
td.get('stone', 0) < _PARTY_COST['stone'] or
|
||||
td.get('iron', 0) < _PARTY_COST['iron']):
|
||||
continue # not enough yet — will retry on next state push
|
||||
else: # triumph
|
||||
if bp_available < _TRIUMPH_BP:
|
||||
continue
|
||||
|
||||
# All clear — queue it
|
||||
c.execute('''
|
||||
INSERT INTO culture_queue
|
||||
(player_id, world_id, town_id, town_name, celebration_type, status, source, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', 'auto', ?)
|
||||
''', (player_id, world_id, tid, town_name, cel_type, now_iso))
|
||||
# Also log it
|
||||
cost_w = _PARTY_COST['wood'] if cel_type == 'party' else 0
|
||||
cost_s = _PARTY_COST['stone'] if cel_type == 'party' else 0
|
||||
cost_i = _PARTY_COST['iron'] if cel_type == 'party' else 0
|
||||
cost_b = _TRIUMPH_BP if cel_type == 'triumph' else 0
|
||||
c.execute('''
|
||||
INSERT INTO culture_log
|
||||
(player_id, world_id, town_id, town_name, celebration_type,
|
||||
cost_wood, cost_stone, cost_iron, cost_battle_pts, status, source, fired_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 'auto', ?)
|
||||
''', (player_id, world_id, tid, town_name, cel_type,
|
||||
cost_w, cost_s, cost_i, cost_b, now_iso))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/commands/pending
|
||||
# Tampermonkey polls this to get the next command to execute.
|
||||
# Returns one 'build' AND one 'recruit' command independently,
|
||||
# so both queues are served in parallel without blocking each other.
|
||||
# ------------------------------------------------------------------
|
||||
def _fetch_pending_of_type(c, cmd_type, player_id):
|
||||
row = c.execute('''
|
||||
SELECT * FROM commands
|
||||
WHERE status = 'pending' AND type = ? AND player_id = ?
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
''', (cmd_type, player_id)).fetchone()
|
||||
def _fetch_pending_of_type(c, cmd_type, player_id, world_id):
|
||||
"""Fetch a single oldest pending command of a given type (recruit, market, etc.)."""
|
||||
|
||||
# We use LEFT JOIN because global commands (like farm_loot) use a pseudo town_id like "0_gr121"
|
||||
# which does not exist in town_state.
|
||||
global_town_id = f"0_{world_id}" if world_id else "0"
|
||||
|
||||
if world_id:
|
||||
row = c.execute('''
|
||||
SELECT c.* FROM commands c
|
||||
LEFT JOIN town_state ts ON c.town_id = ts.town_id
|
||||
WHERE c.status = 'pending' AND c.type = ? AND c.player_id = ?
|
||||
AND (ts.world_id = ? OR c.town_id = ?)
|
||||
ORDER BY c.updated_at ASC, c.id ASC
|
||||
LIMIT 1
|
||||
''', (cmd_type, player_id, world_id, global_town_id)).fetchone()
|
||||
else:
|
||||
row = c.execute('''
|
||||
SELECT * FROM commands
|
||||
WHERE status = 'pending' AND type = ? AND player_id = ?
|
||||
ORDER BY updated_at ASC, id ASC
|
||||
LIMIT 1
|
||||
''', (cmd_type, player_id)).fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
c.execute('''
|
||||
c.execute('''\
|
||||
UPDATE commands
|
||||
SET status = 'executing', updated_at = ?
|
||||
WHERE id = ?
|
||||
@@ -86,44 +289,221 @@ def _fetch_pending_of_type(c, cmd_type, player_id):
|
||||
'payload': json.loads(row['payload'])
|
||||
}
|
||||
|
||||
|
||||
def _fetch_pending_culture(c, player_id, world_id):
|
||||
"""Fetch one pending culture command from the dedicated culture_queue table.
|
||||
Completely separate from the commands table — no interference with builds/recruits.
|
||||
Also times out stuck 'executing' rows to 'failed' after 5 minutes.
|
||||
"""
|
||||
now = datetime.utcnow().isoformat()
|
||||
five_min_ago = (datetime.utcnow() - timedelta(minutes=5)).isoformat()
|
||||
|
||||
# Expire stuck executing entries (fail, don't requeue — auto will re-fire on next state push)
|
||||
c.execute('''
|
||||
UPDATE culture_queue
|
||||
SET status = 'failed', result_msg = 'Timeout (5 min)'
|
||||
WHERE status = 'executing' AND executed_at < ? AND player_id = ?
|
||||
''', (five_min_ago, player_id))
|
||||
|
||||
# Fetch one pending row
|
||||
if world_id:
|
||||
row = c.execute('''
|
||||
SELECT * FROM culture_queue
|
||||
WHERE status = 'pending' AND player_id = ? AND world_id = ?
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
''', (player_id, world_id)).fetchone()
|
||||
else:
|
||||
row = c.execute('''
|
||||
SELECT * FROM culture_queue
|
||||
WHERE status = 'pending' AND player_id = ?
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
''', (player_id,)).fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
c.execute('''
|
||||
UPDATE culture_queue SET status = 'executing', executed_at = ? WHERE id = ?
|
||||
''', (now, row['id']))
|
||||
|
||||
return {
|
||||
'id': row['id'],
|
||||
'town_id': row['town_id'],
|
||||
'type': 'culture',
|
||||
'payload': {
|
||||
'town_id': row['town_id'],
|
||||
'celebration_type': row['celebration_type'],
|
||||
'source': row['source']
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _fetch_pending_builds_all_towns(c, player_id, world_id):
|
||||
|
||||
"""
|
||||
Fetch ONE pending 'build' command per distinct town_id.
|
||||
This allows all towns to build in parallel within a single poll cycle.
|
||||
Within each town the oldest-updated command is picked first, so requeued
|
||||
commands (updated_at = now) naturally sort behind fresh ones.
|
||||
|
||||
Towns that already have a command in 'executing' state are skipped —
|
||||
this prevents a second build from being dispatched before the first one
|
||||
has reported its result (which was causing commands to pile up in EXECUTING).
|
||||
"""
|
||||
# Towns that currently have a build already in-flight — don't touch those.
|
||||
if world_id:
|
||||
executing_rows = c.execute('''
|
||||
SELECT DISTINCT c.town_id FROM commands c
|
||||
JOIN town_state ts ON c.town_id = ts.town_id
|
||||
WHERE c.status = 'executing' AND c.type = 'build' AND c.player_id = ? AND ts.world_id = ?
|
||||
''', (player_id, world_id)).fetchall()
|
||||
else:
|
||||
executing_rows = c.execute('''
|
||||
SELECT DISTINCT town_id FROM commands
|
||||
WHERE status = 'executing' AND type = 'build' AND player_id = ?
|
||||
''', (player_id,)).fetchall()
|
||||
busy_towns = {r['town_id'] for r in executing_rows}
|
||||
|
||||
# Get every town that has at least one pending build, ordered by
|
||||
# which town has been waiting longest (MIN updated_at across its commands).
|
||||
if world_id:
|
||||
town_rows = c.execute('''
|
||||
SELECT c.town_id
|
||||
FROM commands c
|
||||
JOIN town_state ts ON c.town_id = ts.town_id
|
||||
WHERE c.status = 'pending' AND c.type = 'build' AND c.player_id = ? AND ts.world_id = ?
|
||||
GROUP BY c.town_id
|
||||
ORDER BY MIN(c.updated_at) ASC
|
||||
''', (player_id, world_id)).fetchall()
|
||||
else:
|
||||
town_rows = c.execute('''
|
||||
SELECT town_id
|
||||
FROM commands
|
||||
WHERE status = 'pending' AND type = 'build' AND player_id = ?
|
||||
GROUP BY town_id
|
||||
ORDER BY MIN(updated_at) ASC
|
||||
''', (player_id,)).fetchall()
|
||||
_log.warning(f"[poll] build towns found: {[r['town_id'] for r in town_rows]}, busy: {busy_towns}")
|
||||
|
||||
results = []
|
||||
now = datetime.utcnow().isoformat()
|
||||
for town_row in town_rows:
|
||||
town_id = town_row['town_id']
|
||||
|
||||
# Skip this town if a build is already executing for it
|
||||
if town_id in busy_towns:
|
||||
continue
|
||||
|
||||
row = c.execute('''
|
||||
SELECT * FROM commands
|
||||
WHERE status = 'pending' AND type = 'build'
|
||||
AND player_id = ? AND town_id = ?
|
||||
ORDER BY position ASC, id ASC
|
||||
LIMIT 1
|
||||
''', (player_id, town_id)).fetchone()
|
||||
if not row:
|
||||
continue
|
||||
c.execute('''
|
||||
UPDATE commands SET status = 'executing', updated_at = ?
|
||||
WHERE id = ?
|
||||
''', (now, row['id']))
|
||||
results.append({
|
||||
'id': row['id'],
|
||||
'town_id': row['town_id'],
|
||||
'type': row['type'],
|
||||
'payload': json.loads(row['payload'])
|
||||
})
|
||||
return results
|
||||
|
||||
@api.route('/api/commands/pending', methods=['GET'])
|
||||
def get_pending_command():
|
||||
player_id = request.args.get('player_id')
|
||||
world_id = request.args.get('world_id')
|
||||
_log.warning(f"[poll] player_id={player_id!r} world_id={world_id!r}")
|
||||
if not player_id:
|
||||
return jsonify({'error': 'no player_id provided'}), 400
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
build_cmd = _fetch_pending_of_type(c, 'build', player_id)
|
||||
recruit_cmd = _fetch_pending_of_type(c, 'recruit', player_id)
|
||||
market_cmd = _fetch_pending_of_type(c, 'market_offer', player_id)
|
||||
farm_cmd = _fetch_pending_of_type(c, 'farm_loot', player_id)
|
||||
farm_upgrade_cmd = _fetch_pending_of_type(c, 'farm_upgrade', player_id)
|
||||
sync_req = _check_and_reset_sync(c, player_id)
|
||||
# Free up stuck 'executing' commands (e.g. if the game page was refreshed mid-execution)
|
||||
two_minutes_ago = (datetime.utcnow() - timedelta(minutes=2)).isoformat()
|
||||
c.execute('''
|
||||
UPDATE commands
|
||||
SET status = 'pending', result_msg = 'Requeued (timeout)'
|
||||
WHERE status = 'executing' AND updated_at < ? AND player_id = ?
|
||||
''', (two_minutes_ago, player_id))
|
||||
|
||||
# Also return current farm settings so TM knows loot_option
|
||||
build_cmds = _fetch_pending_builds_all_towns(c, player_id, world_id) # one per town
|
||||
recruit_cmd = _fetch_pending_of_type(c, 'recruit', player_id, world_id)
|
||||
market_cmd = _fetch_pending_of_type(c, 'market_offer', player_id, world_id)
|
||||
farm_cmd = _fetch_pending_of_type(c, 'farm_loot', player_id, world_id)
|
||||
farm_upgrade_cmd = _fetch_pending_of_type(c, 'farm_upgrade', player_id, world_id)
|
||||
research_cmd = _fetch_pending_of_type(c, 'research', player_id, world_id)
|
||||
culture_cmd = _fetch_pending_culture(c, player_id, world_id) # reads culture_queue
|
||||
sync_req = _check_and_reset_sync(c, player_id)
|
||||
|
||||
# Determine player_key for world-specific settings if world_id is provided
|
||||
player_key = f"{player_id}_{world_id}" if world_id else player_id
|
||||
|
||||
# Farm settings
|
||||
farm_row = c.execute(
|
||||
'SELECT enabled, loot_option FROM farm_settings WHERE player_id = ?', (player_id,)
|
||||
'SELECT enabled, loot_option FROM farm_settings WHERE player_id = ?', (player_key,)
|
||||
).fetchone()
|
||||
farm_settings = {
|
||||
'enabled': bool(farm_row['enabled']) if farm_row else False,
|
||||
'loot_option': farm_row['loot_option'] if farm_row else 1
|
||||
'enabled': bool(farm_row['enabled']) if farm_row else False,
|
||||
'loot_option': farm_row['loot_option'] if farm_row else 1
|
||||
}
|
||||
|
||||
# Bot settings (bootcamp + rural trade)
|
||||
bot_row = c.execute(
|
||||
'SELECT * FROM bot_settings WHERE player_id = ?', (player_key,)
|
||||
).fetchone()
|
||||
bot_settings = {
|
||||
'bootcamp_enabled': bool(bot_row['bootcamp_enabled']) if bot_row else False,
|
||||
'bootcamp_use_def': bool(bot_row['bootcamp_use_def']) if bot_row else False,
|
||||
'rural_trade_enabled': bool(bot_row['rural_trade_enabled']) if bot_row else False,
|
||||
'rural_trade_ratio': bot_row['rural_trade_ratio'] if bot_row else 3,
|
||||
}
|
||||
|
||||
# One-shot manual attack flag
|
||||
attack_now_key = f'bootcamp_attack_now_{player_key}'
|
||||
flag_row = c.execute('SELECT value FROM kv_store WHERE key = ?', (attack_now_key,)).fetchone()
|
||||
if flag_row and flag_row['value'] == '1':
|
||||
bot_settings['attack_now'] = True
|
||||
c.execute("UPDATE kv_store SET value = '0' WHERE key = ?", (attack_now_key,))
|
||||
else:
|
||||
bot_settings['attack_now'] = False
|
||||
|
||||
# Feature flags — look up this player's authorized features from their clan
|
||||
member_row = c.execute(
|
||||
'SELECT features FROM clan_members WHERE player_id = ?', (str(player_id),)
|
||||
).fetchone()
|
||||
if member_row and member_row['features']:
|
||||
enabled_features = [f.strip() for f in member_row['features'].split(',') if f.strip()]
|
||||
else:
|
||||
enabled_features = ['farm', 'admin'] # default: all on (backward-compatible)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'build': build_cmd,
|
||||
'recruit': recruit_cmd,
|
||||
'market': market_cmd,
|
||||
'farm': farm_cmd,
|
||||
'farm_upgrade': farm_upgrade_cmd,
|
||||
'farm_settings': farm_settings,
|
||||
'sync_requested': sync_req
|
||||
'builds': build_cmds, # list: one build command per town
|
||||
'recruit': recruit_cmd,
|
||||
'market': market_cmd,
|
||||
'research': research_cmd,
|
||||
'farm': farm_cmd,
|
||||
'farm_upgrade': farm_upgrade_cmd,
|
||||
'culture': culture_cmd,
|
||||
'farm_settings': farm_settings,
|
||||
'bot_settings': bot_settings,
|
||||
'enabled_features': enabled_features,
|
||||
'sync_requested': sync_req
|
||||
})
|
||||
|
||||
|
||||
def _check_and_reset_sync(c, player_id):
|
||||
key = f'sync_request_{player_id}'
|
||||
row = c.execute("SELECT value FROM kv_store WHERE key = ?", (key,)).fetchone()
|
||||
@@ -160,20 +540,52 @@ def sync_request():
|
||||
@api.route('/api/commands/<int:cmd_id>/result', methods=['POST'])
|
||||
def command_result(cmd_id):
|
||||
data = request.get_json(silent=True) or {}
|
||||
status = data.get('status', 'done') # 'done' | 'failed'
|
||||
status = data.get('status', 'done') # 'done' | 'failed' | 'pending' (requeue)
|
||||
msg = data.get('message', '')
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
conn = get_db()
|
||||
# Look up type + player_id for post-update hooks
|
||||
cmd = conn.execute(
|
||||
'SELECT type, player_id FROM commands WHERE id = ?', (cmd_id,)
|
||||
).fetchone()
|
||||
|
||||
conn.execute('''
|
||||
UPDATE commands
|
||||
SET status = ?, result_msg = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
''', (status, msg, datetime.utcnow().isoformat(), cmd_id))
|
||||
''', (status, msg, now, cmd_id))
|
||||
|
||||
# When an explicit farm_loot command succeeds, record the timestamp
|
||||
if cmd and cmd['type'] == 'farm_loot' and status == 'done' and cmd['player_id']:
|
||||
town_id = str(cmd['town_id'])
|
||||
world_id = town_id.split('_')[1] if '_' in town_id else None
|
||||
lf_key = f'last_farmed_{cmd["player_id"]}_{world_id}' if world_id else f'last_farmed_{cmd["player_id"]}'
|
||||
conn.execute('''
|
||||
INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
||||
''', (lf_key, now, now))
|
||||
|
||||
# When a culture command finishes, update the matching culture_log row
|
||||
if cmd and cmd['type'] == 'culture' and cmd['player_id']:
|
||||
log_status = 'success' if status == 'done' else ('failed' if status == 'failed' else 'pending')
|
||||
conn.execute('''\
|
||||
UPDATE culture_log
|
||||
SET status = ?, result_msg = ?, confirmed_at = ?
|
||||
WHERE player_id = ? AND status = 'pending'
|
||||
AND id = (
|
||||
SELECT id FROM culture_log
|
||||
WHERE player_id = ? AND status = 'pending'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
)
|
||||
''', (log_status, msg, now, str(cmd['player_id']), str(cmd['player_id'])))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/captcha/alert
|
||||
# Tampermonkey reports when #hcaptcha_window appears/disappears.
|
||||
@@ -182,12 +594,14 @@ def command_result(cmd_id):
|
||||
@api.route('/api/captcha/alert', methods=['POST'])
|
||||
def captcha_alert():
|
||||
player_id = request.args.get('player_id')
|
||||
world_id = request.args.get('world_id', '').strip()
|
||||
if not player_id:
|
||||
return jsonify({'error': 'no player_id provided'}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
detected = bool(data.get('detected', False))
|
||||
kv_key = f'captcha_active_{player_id}'
|
||||
# Key is world-specific so captcha in world A doesn't affect world B
|
||||
kv_key = f'captcha_active_{player_id}_{world_id}' if world_id else f'captcha_active_{player_id}'
|
||||
|
||||
conn = get_db()
|
||||
conn.execute('''
|
||||
@@ -200,3 +614,187 @@ def captcha_alert():
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/market_data
|
||||
# Tampermonkey uploads the market scan data.
|
||||
# ------------------------------------------------------------------
|
||||
@api.route('/api/market_data', methods=['POST'])
|
||||
def upload_market_data():
|
||||
player_id = request.args.get('player_id')
|
||||
if not player_id:
|
||||
return jsonify({'error': 'no player_id provided'}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
kv_key = f'market_data_{player_id}'
|
||||
|
||||
conn = get_db()
|
||||
conn.execute('''
|
||||
INSERT INTO kv_store (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at
|
||||
''', (kv_key, json.dumps(data), datetime.utcnow().isoformat()))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST/GET /api/farm_status — TM reports warehouse_full; dashboard reads it
|
||||
# ------------------------------------------------------------------
|
||||
@api.route('/api/farm_status', methods=['POST', 'GET'])
|
||||
def farm_status():
|
||||
player_id = request.args.get('player_id')
|
||||
world_id = request.args.get('world_id')
|
||||
if not player_id:
|
||||
return jsonify({'error': 'no player_id'}), 400
|
||||
|
||||
player_key = f"{player_id}_{world_id}" if world_id else player_id
|
||||
kv_key = f'farm_status_{player_key}'
|
||||
conn = get_db()
|
||||
if request.method == 'POST':
|
||||
data = request.get_json(silent=True) or {}
|
||||
now = datetime.utcnow().isoformat()
|
||||
conn.execute('''
|
||||
INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
||||
''', (kv_key, json.dumps(data), now))
|
||||
# Auto-farm reports warehouse_full=false when it successfully looted something
|
||||
if not data.get('warehouse_full', True):
|
||||
conn.execute('''
|
||||
INSERT INTO kv_store (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
||||
''', (f'last_farmed_{player_key}', now, now))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
else:
|
||||
row = conn.execute('SELECT value FROM kv_store WHERE key=?', (kv_key,)).fetchone()
|
||||
conn.close()
|
||||
return jsonify(json.loads(row['value']) if row else {'warehouse_full': False})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/bot-logs
|
||||
# TM bot reports log entries for bootcamp / rural_trade loops.
|
||||
# ------------------------------------------------------------------
|
||||
@api.route('/api/bot-logs', methods=['POST'])
|
||||
def api_bot_logs():
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return make_response('Unauthorized', 403)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
player_id = str(data.get('player_id', ''))
|
||||
world_id = str(data.get('world_id', ''))
|
||||
feature = data.get('feature', '')
|
||||
message = data.get('message', '')
|
||||
|
||||
if not player_id or not feature or not message:
|
||||
return jsonify({'error': 'missing fields'}), 400
|
||||
|
||||
player_key = f"{player_id}_{world_id}" if world_id else player_id
|
||||
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
'INSERT INTO bot_logs (player_id, feature, message) VALUES (?, ?, ?)',
|
||||
(player_key, feature, message)
|
||||
)
|
||||
# Keep only latest 50 per player/feature
|
||||
conn.execute('''
|
||||
DELETE FROM bot_logs
|
||||
WHERE player_id = ? AND feature = ?
|
||||
AND id NOT IN (
|
||||
SELECT id FROM bot_logs
|
||||
WHERE player_id = ? AND feature = ?
|
||||
ORDER BY id DESC LIMIT 50
|
||||
)
|
||||
''', (player_key, feature, player_key, feature))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/culture/result/<id>
|
||||
# Bot reports success/failure of a culture command.
|
||||
# Completely separate from /api/commands/<id>/result — zero
|
||||
# interference with builds, recruits, or any other command type.
|
||||
# ------------------------------------------------------------------
|
||||
@api.route('/api/culture/result/<int:queue_id>', methods=['POST'])
|
||||
def culture_result(queue_id):
|
||||
data = request.get_json(silent=True) or {}
|
||||
status_in = data.get('status', 'done') # 'done' | 'failed'
|
||||
msg = data.get('message', '')
|
||||
now = datetime.utcnow().isoformat()
|
||||
final_status = 'done' if status_in == 'done' else 'failed'
|
||||
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
'SELECT * FROM culture_queue WHERE id = ?', (queue_id,)
|
||||
).fetchone()
|
||||
|
||||
if row:
|
||||
conn.execute('''
|
||||
UPDATE culture_queue
|
||||
SET status = ?, result_msg = ?, executed_at = ?
|
||||
WHERE id = ?
|
||||
''', (final_status, msg, now, queue_id))
|
||||
|
||||
# Sync the most recent matching pending culture_log entry
|
||||
log_row = conn.execute('''
|
||||
SELECT id FROM culture_log
|
||||
WHERE player_id = ? AND world_id = ? AND town_id = ?
|
||||
AND celebration_type = ? AND status = 'pending'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
''', (row['player_id'], row['world_id'], row['town_id'], row['celebration_type'])).fetchone()
|
||||
if log_row:
|
||||
log_status = 'success' if final_status == 'done' else 'failed'
|
||||
conn.execute('''
|
||||
UPDATE culture_log
|
||||
SET status = ?, result_msg = ?, confirmed_at = ?
|
||||
WHERE id = ?
|
||||
''', (log_status, msg, now, log_row['id']))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
|
||||
# Serves the modular bot code concatenated into a single response
|
||||
# ------------------------------------------------------------------
|
||||
@api.route('/api/bot', methods=['GET'])
|
||||
def serve_bot():
|
||||
# Require a valid clan key — reject unknown clients
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return make_response('Unauthorized: invalid or missing clan key', 403)
|
||||
|
||||
bot_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'bot_modules')
|
||||
if not os.path.exists(bot_dir):
|
||||
return make_response("Bot modules directory not found", 404)
|
||||
|
||||
|
||||
modules = sorted([f for f in os.listdir(bot_dir) if f.endswith('.js')])
|
||||
|
||||
combined_code = []
|
||||
combined_code.append("(function() {")
|
||||
combined_code.append(" 'use strict';\n")
|
||||
|
||||
for module in modules:
|
||||
with open(os.path.join(bot_dir, module), 'r', encoding='utf-8') as f:
|
||||
combined_code.append(f" // --- BEGIN {module} ---")
|
||||
combined_code.append(f.read())
|
||||
combined_code.append(f" // --- END {module} ---\n")
|
||||
|
||||
combined_code.append("})();")
|
||||
|
||||
response = make_response("\n".join(combined_code))
|
||||
response.headers['Content-Type'] = 'application/javascript'
|
||||
# Prevent caching so updates are instant
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
return response
|
||||
|
||||
|
||||
323
routes/auth.py
Normal file
323
routes/auth.py
Normal file
@@ -0,0 +1,323 @@
|
||||
from flask import Blueprint, render_template, request, jsonify, redirect, url_for, flash
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from db import get_db, generate_clan_key
|
||||
from datetime import datetime, timezone
|
||||
|
||||
auth = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helper — resolve the User class from app (avoid circular import)
|
||||
# ------------------------------------------------------------------
|
||||
def _make_user(row):
|
||||
from app import User
|
||||
return User(row['id'], row['username'], row['clan_id'])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET/POST /auth/login
|
||||
# ------------------------------------------------------------------
|
||||
@auth.route('/auth/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
error = None
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
'SELECT id, username, password_hash, clan_id FROM users WHERE username = ?', (username,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
if row and check_password_hash(row['password_hash'], password):
|
||||
user = _make_user(row)
|
||||
login_user(user, remember=True)
|
||||
return redirect(url_for('dashboard.index'))
|
||||
else:
|
||||
error = 'Λάθος όνομα χρήστη ή κωδικός.'
|
||||
|
||||
return render_template('login.html', error=error)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET/POST /auth/register
|
||||
# ------------------------------------------------------------------
|
||||
@auth.route('/auth/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
error = None
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
confirm = request.form.get('confirm', '')
|
||||
|
||||
if not username or not password:
|
||||
error = 'Συμπλήρωσε όνομα χρήστη και κωδικό.'
|
||||
elif password != confirm:
|
||||
error = 'Οι κωδικοί δεν ταιριάζουν.'
|
||||
elif len(password) < 6:
|
||||
error = 'Ο κωδικός πρέπει να έχει τουλάχιστον 6 χαρακτήρες.'
|
||||
else:
|
||||
conn = get_db()
|
||||
existing = conn.execute(
|
||||
'SELECT id FROM users WHERE username = ?', (username,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
error = 'Το όνομα χρήστη χρησιμοποιείται ήδη.'
|
||||
conn.close()
|
||||
else:
|
||||
pw_hash = generate_password_hash(password)
|
||||
conn.execute(
|
||||
'INSERT INTO users (username, password_hash) VALUES (?, ?)',
|
||||
(username, pw_hash)
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
'SELECT id, username, password_hash, clan_id FROM users WHERE username = ?', (username,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
user = _make_user(row)
|
||||
login_user(user, remember=True)
|
||||
return redirect(url_for('auth.options'))
|
||||
|
||||
return render_template('register.html', error=error)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /auth/logout
|
||||
# ------------------------------------------------------------------
|
||||
@auth.route('/auth/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET/POST /auth/options — Clan management page
|
||||
# ------------------------------------------------------------------
|
||||
@auth.route('/auth/options', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def options():
|
||||
conn = get_db()
|
||||
|
||||
# Load clan based on current user's clan_id
|
||||
clan = None
|
||||
if current_user.clan_id:
|
||||
clan = conn.execute(
|
||||
'SELECT * FROM clans WHERE id = ?', (current_user.clan_id,)
|
||||
).fetchone()
|
||||
|
||||
# Fetch website admins (users belonging to this clan other than current user)
|
||||
admins = []
|
||||
if clan and clan['owner_id'] == current_user.id:
|
||||
admins = conn.execute(
|
||||
'SELECT id, username, created_at FROM users WHERE clan_id = ? AND id != ? ORDER BY created_at ASC',
|
||||
(clan['id'], current_user.id)
|
||||
).fetchall()
|
||||
|
||||
members = []
|
||||
if clan:
|
||||
rows = conn.execute(
|
||||
'''SELECT cm.id, cm.player_id, cm.player_name, cm.world_id, cm.joined_at, cm.features,
|
||||
MAX(ts.updated_at) as updated_at
|
||||
FROM clan_members cm
|
||||
LEFT JOIN town_state ts ON ts.player_id = cm.player_id
|
||||
AND ts.world_id = cm.world_id
|
||||
WHERE cm.clan_id = ?
|
||||
GROUP BY cm.player_id, cm.world_id
|
||||
ORDER BY cm.player_name ASC, cm.world_id ASC''',
|
||||
(clan['id'],)
|
||||
).fetchall()
|
||||
|
||||
now = datetime.utcnow()
|
||||
for row in rows:
|
||||
is_online = False
|
||||
if row['updated_at']:
|
||||
try:
|
||||
last_seen = datetime.fromisoformat(row['updated_at'])
|
||||
if (now - last_seen).total_seconds() <= 150:
|
||||
is_online = True
|
||||
except Exception:
|
||||
pass
|
||||
members.append({
|
||||
'id': row['id'],
|
||||
'player_id': row['player_id'],
|
||||
'player_name': row['player_name'] or 'Άγνωστος',
|
||||
'world_id': row['world_id'] or '–',
|
||||
'joined_at': row['joined_at'][:10] if row['joined_at'] else '',
|
||||
'is_online': is_online,
|
||||
'feat_farm': 'farm' in (row['features'] or 'farm,admin'),
|
||||
'feat_admin': 'admin' in (row['features'] or 'farm,admin'),
|
||||
})
|
||||
|
||||
conn.close()
|
||||
return render_template('options.html', clan=clan, members=members, admins=admins)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /auth/clan/create
|
||||
# ------------------------------------------------------------------
|
||||
@auth.route('/auth/clan/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_clan():
|
||||
clan_name = request.form.get('clan_name', '').strip()
|
||||
if not clan_name:
|
||||
return redirect(url_for('auth.options'))
|
||||
|
||||
conn = get_db()
|
||||
existing = conn.execute(
|
||||
'SELECT id FROM clans WHERE owner_id = ?', (current_user.id,)
|
||||
).fetchone()
|
||||
|
||||
if not existing:
|
||||
key = generate_clan_key()
|
||||
cursor = conn.execute(
|
||||
'INSERT INTO clans (owner_id, name, clan_key) VALUES (?, ?, ?)',
|
||||
(current_user.id, clan_name, key)
|
||||
)
|
||||
clan_id = cursor.lastrowid
|
||||
conn.execute('UPDATE users SET clan_id = ? WHERE id = ?', (clan_id, current_user.id))
|
||||
conn.commit()
|
||||
|
||||
# Update the current_user object dynamically to reflect the new clan_id without re-login
|
||||
current_user.clan_id = clan_id
|
||||
|
||||
conn.close()
|
||||
return redirect(url_for('auth.options'))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /auth/clan/regenerate-key
|
||||
# ------------------------------------------------------------------
|
||||
@auth.route('/auth/clan/regenerate-key', methods=['POST'])
|
||||
@login_required
|
||||
def regenerate_key():
|
||||
new_key = generate_clan_key()
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
'UPDATE clans SET clan_key = ? WHERE owner_id = ?',
|
||||
(new_key, current_user.id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return redirect(url_for('auth.options'))
|
||||
|
||||
|
||||
@auth.route('/auth/clan/remove-member/<player_id>/<path:world_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_member(player_id, world_id):
|
||||
conn = get_db()
|
||||
clan = conn.execute(
|
||||
'SELECT id FROM clans WHERE owner_id = ?', (current_user.id,)
|
||||
).fetchone()
|
||||
if clan:
|
||||
conn.execute(
|
||||
'DELETE FROM clan_members WHERE clan_id = ? AND player_id = ? AND world_id = ?',
|
||||
(clan['id'], player_id, world_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return redirect(url_for('auth.options'))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /auth/clan/update-features/<player_id>
|
||||
# ------------------------------------------------------------------
|
||||
@auth.route('/auth/clan/update-features/<player_id>/<path:world_id>', methods=['POST'])
|
||||
@login_required
|
||||
def update_member_features(player_id, world_id):
|
||||
farm = 'farm' if request.form.get('farm') else None
|
||||
admin = 'admin' if request.form.get('admin') else None
|
||||
|
||||
features = ','.join(f for f in [farm, admin] if f) or ''
|
||||
|
||||
conn = get_db()
|
||||
clan = conn.execute(
|
||||
'SELECT id FROM clans WHERE owner_id = ?', (current_user.id,)
|
||||
).fetchone()
|
||||
if clan:
|
||||
conn.execute(
|
||||
'UPDATE clan_members SET features = ? WHERE clan_id = ? AND player_id = ? AND world_id = ?',
|
||||
(features, clan['id'], player_id, world_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return redirect(url_for('auth.options'))
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /auth/clan/add-admin
|
||||
# ------------------------------------------------------------------
|
||||
@auth.route('/auth/clan/add-admin', methods=['POST'])
|
||||
@login_required
|
||||
def add_admin():
|
||||
username = request.form.get('admin_username', '').strip()
|
||||
if not username:
|
||||
return redirect(url_for('auth.options'))
|
||||
|
||||
conn = get_db()
|
||||
clan = conn.execute(
|
||||
'SELECT id FROM clans WHERE owner_id = ?', (current_user.id,)
|
||||
).fetchone()
|
||||
|
||||
if clan:
|
||||
# Check if user exists
|
||||
user = conn.execute('SELECT id, clan_id FROM users WHERE username = ?', (username,)).fetchone()
|
||||
if user:
|
||||
# If user already belongs to a clan, we could show an error, but let's just overwrite for now
|
||||
# or maybe only if clan_id is NULL
|
||||
conn.execute('UPDATE users SET clan_id = ? WHERE id = ?', (clan['id'], user['id']))
|
||||
conn.commit()
|
||||
flash(f"Ο χρήστης {username} προστέθηκε ως διαχειριστής.", "success")
|
||||
else:
|
||||
flash(f"Ο χρήστης {username} δεν βρέθηκε.", "error")
|
||||
|
||||
conn.close()
|
||||
return redirect(url_for('auth.options'))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /auth/clan/remove-admin/<admin_id>
|
||||
# ------------------------------------------------------------------
|
||||
@auth.route('/auth/clan/remove-admin/<int:admin_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_admin(admin_id):
|
||||
conn = get_db()
|
||||
clan = conn.execute(
|
||||
'SELECT id FROM clans WHERE owner_id = ?', (current_user.id,)
|
||||
).fetchone()
|
||||
|
||||
if clan:
|
||||
conn.execute('UPDATE users SET clan_id = NULL WHERE id = ? AND clan_id = ?', (admin_id, clan['id']))
|
||||
conn.commit()
|
||||
flash("Ο διαχειριστής αφαιρέθηκε.", "success")
|
||||
|
||||
conn.close()
|
||||
return redirect(url_for('auth.options'))
|
||||
# ------------------------------------------------------------------
|
||||
# POST /auth/clan/leave
|
||||
# ------------------------------------------------------------------
|
||||
@auth.route('/auth/clan/leave', methods=['POST'])
|
||||
@login_required
|
||||
def leave_clan():
|
||||
conn = get_db()
|
||||
if current_user.clan_id:
|
||||
clan = conn.execute('SELECT owner_id FROM clans WHERE id = ?', (current_user.clan_id,)).fetchone()
|
||||
if clan and clan['owner_id'] != current_user.id:
|
||||
conn.execute('UPDATE users SET clan_id = NULL WHERE id = ?', (current_user.id,))
|
||||
conn.commit()
|
||||
current_user.clan_id = None
|
||||
flash("Έχετε αποχωρήσει από την ομάδα.", "success")
|
||||
conn.close()
|
||||
return redirect(url_for('auth.options'))
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from db import get_db
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
@@ -11,21 +12,42 @@ dashboard = Blueprint('dashboard', __name__)
|
||||
# Serve the dashboard HTML
|
||||
# ------------------------------------------------------------------
|
||||
@dashboard.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
conn = get_db()
|
||||
|
||||
# Get the clan the logged-in user belongs to
|
||||
clan_id = current_user.clan_id
|
||||
|
||||
if not clan_id:
|
||||
# User has no clan yet — send them to options to create/join one
|
||||
conn.close()
|
||||
return render_template('index.html', players=[], no_clan=True)
|
||||
|
||||
# Only fetch players that are members of this clan
|
||||
rows = conn.execute('''
|
||||
SELECT player, player_id, MAX(updated_at) as last_seen, MAX(world_id) as world_id
|
||||
FROM town_state
|
||||
WHERE player IS NOT NULL
|
||||
GROUP BY player, player_id
|
||||
ORDER BY player ASC
|
||||
''').fetchall()
|
||||
|
||||
# Pre-fetch all active captchas
|
||||
SELECT ts.player, ts.player_id, ts.world_id, MAX(ts.updated_at) as last_seen
|
||||
FROM town_state ts
|
||||
INNER JOIN clan_members cm ON cm.player_id = ts.player_id AND cm.clan_id = ?
|
||||
WHERE ts.player IS NOT NULL
|
||||
GROUP BY ts.player, ts.player_id, ts.world_id
|
||||
ORDER BY ts.player ASC, ts.world_id ASC
|
||||
''', (clan_id,)).fetchall()
|
||||
|
||||
captcha_rows = conn.execute("SELECT key, value FROM kv_store WHERE key LIKE 'captcha_active_%'").fetchall()
|
||||
active_captchas = {r['key'].replace('captcha_active_', ''): True for r in captcha_rows if r['value'] == '1'}
|
||||
# Key format is captcha_active_{player_id}_{world_id} — build a (player_id, world_id) → bool map
|
||||
active_captchas = {}
|
||||
for r in captcha_rows:
|
||||
if r['value'] != '1':
|
||||
continue
|
||||
parts = r['key'].replace('captcha_active_', '').split('_', 1)
|
||||
if len(parts) == 2:
|
||||
active_captchas[(parts[0], parts[1])] = True
|
||||
else:
|
||||
# Legacy key (player_id only) — keep working
|
||||
active_captchas[(parts[0], '')] = True
|
||||
conn.close()
|
||||
|
||||
|
||||
players = []
|
||||
now = datetime.utcnow()
|
||||
for r in rows:
|
||||
@@ -37,29 +59,48 @@ def index():
|
||||
is_online = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
wid = r['world_id'] or ''
|
||||
captcha_active = (
|
||||
active_captchas.get((r['player_id'], wid), False) or
|
||||
active_captchas.get((r['player_id'], ''), False) # legacy fallback
|
||||
)
|
||||
players.append({
|
||||
'player': r['player'],
|
||||
'player_id': r['player_id'],
|
||||
'world_id': r['world_id'] or 'Unknown',
|
||||
'is_online': is_online,
|
||||
'captcha_active': active_captchas.get(r['player_id'], False)
|
||||
'player': r['player'],
|
||||
'player_id': r['player_id'],
|
||||
'world_id': wid or 'Unknown',
|
||||
'is_online': is_online,
|
||||
'captcha_active': captcha_active
|
||||
})
|
||||
|
||||
return render_template('index.html', players=players)
|
||||
|
||||
@dashboard.route('/player/<player_id>')
|
||||
def player_hub(player_id):
|
||||
return render_template('hub.html', player_id=player_id)
|
||||
return render_template('index.html', players=players, no_clan=False)
|
||||
|
||||
@dashboard.route('/player/<player_id>/admin')
|
||||
def player_dashboard(player_id):
|
||||
return render_template('dashboard.html', player_id=player_id)
|
||||
|
||||
@dashboard.route('/player/<player_id>/farm')
|
||||
def player_farm(player_id):
|
||||
return render_template('farm.html', player_id=player_id)
|
||||
@dashboard.route('/player/<player_id>/<world_id>')
|
||||
@login_required
|
||||
def player_hub(player_id, world_id):
|
||||
return render_template('hub.html', player_id=player_id, world_id=world_id)
|
||||
|
||||
@dashboard.route('/player/<player_id>/<world_id>/admin')
|
||||
@login_required
|
||||
def player_dashboard(player_id, world_id):
|
||||
return render_template('dashboard.html', player_id=player_id, world_id=world_id)
|
||||
|
||||
@dashboard.route('/player/<player_id>/<world_id>/farm')
|
||||
@login_required
|
||||
def player_farm(player_id, world_id):
|
||||
return render_template('farm.html', player_id=player_id, world_id=world_id)
|
||||
|
||||
@dashboard.route('/player/<player_id>/<world_id>/tracker')
|
||||
@login_required
|
||||
def player_tracker(player_id, world_id):
|
||||
return render_template('tracker.html', player_id=player_id, world_id=world_id)
|
||||
|
||||
@dashboard.route('/player/<player_id>/<world_id>/agora')
|
||||
@login_required
|
||||
def player_agora(player_id, world_id):
|
||||
return render_template('agora.html', player_id=player_id, world_id=world_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /dashboard/farm-settings — returns current farm config
|
||||
@@ -68,9 +109,12 @@ def player_farm(player_id):
|
||||
@dashboard.route('/dashboard/farm-settings', methods=['GET'])
|
||||
def get_farm_settings():
|
||||
player_id = request.args.get('player_id')
|
||||
world_id = request.args.get('world_id', '')
|
||||
player_key = f"{player_id}_{world_id}" if world_id else player_id
|
||||
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
'SELECT enabled, loot_option FROM farm_settings WHERE player_id = ?', (player_id,)
|
||||
'SELECT enabled, loot_option FROM farm_settings WHERE player_id = ?', (player_key,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
@@ -83,6 +127,9 @@ def set_farm_settings():
|
||||
if not data or 'player_id' not in data:
|
||||
return jsonify({'error': 'missing player_id'}), 400
|
||||
player_id = data['player_id']
|
||||
world_id = data.get('world_id', '')
|
||||
player_key = f"{player_id}_{world_id}" if world_id else player_id
|
||||
|
||||
enabled = 1 if data.get('enabled') else 0
|
||||
loot_option = int(data.get('loot_option', 1))
|
||||
conn = get_db()
|
||||
@@ -93,7 +140,7 @@ def set_farm_settings():
|
||||
enabled = excluded.enabled,
|
||||
loot_option = excluded.loot_option,
|
||||
updated_at = excluded.updated_at
|
||||
''', (player_id, enabled, loot_option, datetime.utcnow().isoformat()))
|
||||
''', (player_key, enabled, loot_option, datetime.utcnow().isoformat()))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
@@ -106,10 +153,24 @@ def set_farm_settings():
|
||||
@dashboard.route('/dashboard/farm-data', methods=['GET'])
|
||||
def get_farm_data():
|
||||
player_id = request.args.get('player_id')
|
||||
world_id = request.args.get('world_id')
|
||||
conn = get_db()
|
||||
rows = conn.execute(
|
||||
'SELECT town_id, town_name, data FROM town_state WHERE player_id = ?', (player_id,)
|
||||
).fetchall()
|
||||
if world_id:
|
||||
rows = conn.execute(
|
||||
'SELECT town_id, town_name, data FROM town_state WHERE player_id = ? AND world_id = ?',
|
||||
(player_id, world_id)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
'SELECT town_id, town_name, data FROM town_state WHERE player_id = ?', (player_id,)
|
||||
).fetchall()
|
||||
|
||||
# Also fetch when the bot last farmed (per world)
|
||||
lf_key = f'last_farmed_{player_id}_{world_id}' if world_id else f'last_farmed_{player_id}'
|
||||
lf_row = conn.execute(
|
||||
"SELECT value FROM kv_store WHERE key = ?", (lf_key,)
|
||||
).fetchone()
|
||||
last_farmed_at = lf_row['value'] if lf_row else None
|
||||
conn.close()
|
||||
|
||||
now_ts = int(datetime.utcnow().timestamp())
|
||||
@@ -126,7 +187,25 @@ def get_farm_data():
|
||||
'ready_farms': len(ready),
|
||||
'next_ready_at': min((f['lootable_at'] for f in farm_data if f.get('lootable_at', 0) > now_ts and f.get('relation_status', 0) == 1), default=None)
|
||||
})
|
||||
return jsonify(farms_summary)
|
||||
return jsonify({'towns': farms_summary, 'last_farmed_at': last_farmed_at})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /dashboard/market-data
|
||||
# Returns the latest market scan data for a player.
|
||||
# ------------------------------------------------------------------
|
||||
@dashboard.route('/dashboard/market-data', methods=['GET'])
|
||||
def get_market_data():
|
||||
player_id = request.args.get('player_id')
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT value, updated_at FROM kv_store WHERE key = ?", (f'market_data_{player_id}', )
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
return jsonify({'data': json.loads(row['value']), 'updated_at': row['updated_at']})
|
||||
return jsonify({'data': None, 'updated_at': None})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -136,14 +215,26 @@ def get_farm_data():
|
||||
@dashboard.route('/dashboard/towns', methods=['GET'])
|
||||
def get_towns():
|
||||
player_id = request.args.get('player_id')
|
||||
world_id = request.args.get('world_id')
|
||||
conn = get_db()
|
||||
rows = conn.execute('''
|
||||
SELECT town_id, town_name, player, player_id, alliance_id,
|
||||
world_id, x, y, sea, data, updated_at
|
||||
FROM town_state
|
||||
WHERE player_id = ?
|
||||
ORDER BY town_name ASC
|
||||
''', (player_id, )).fetchall()
|
||||
|
||||
query = '''
|
||||
SELECT ts.town_id, ts.town_name, ts.player, ts.player_id, ts.alliance_id,
|
||||
ts.world_id, ts.x, ts.y, ts.sea, ts.data, ts.updated_at,
|
||||
tb.blueprint_name, tb.is_active as blueprint_active
|
||||
FROM town_state ts
|
||||
LEFT JOIN town_blueprints tb ON ts.town_id = tb.town_id AND tb.is_active = 1
|
||||
WHERE ts.player_id = ?
|
||||
'''
|
||||
params = [player_id]
|
||||
|
||||
if world_id:
|
||||
query += ' AND ts.world_id = ?'
|
||||
params.append(world_id)
|
||||
|
||||
query += ' ORDER BY ts.town_name ASC'
|
||||
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
|
||||
towns = []
|
||||
@@ -167,12 +258,14 @@ def get_towns():
|
||||
'storage': d.get('storage', 0),
|
||||
'market_capacity': d.get('market_capacity', 0),
|
||||
'population': d.get('population', 0),
|
||||
'favor': d.get('favor', 0),
|
||||
},
|
||||
'buildings': d.get('buildings', {}),
|
||||
'units': d.get('units', {}),
|
||||
'points': d.get('points', 0),
|
||||
'god': d.get('god', None),
|
||||
'build_queue': d.get('buildingOrder', []),
|
||||
'unit_queue': d.get('unitOrder', []),
|
||||
'build_data': d.get('buildData', {}),
|
||||
'unit_data': d.get('unitData', {}),
|
||||
'researches': d.get('researches', {}),
|
||||
@@ -180,11 +273,52 @@ def get_towns():
|
||||
'bonuses': d.get('bonuses', {}),
|
||||
'wonder_points': d.get('wonder_points', 0),
|
||||
'total_points': d.get('total_points', 0),
|
||||
'alliance_name': d.get('alliance_name', None)
|
||||
'alliance_name': d.get('alliance_name', None),
|
||||
'blueprint_name': row['blueprint_name'],
|
||||
'blueprint_active': bool(row['blueprint_active']),
|
||||
'battle_points': d.get('battle_points', {'att': 0, 'def': 0, 'available': 0, 'used': 0})
|
||||
})
|
||||
return jsonify(towns)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /dashboard/blueprints
|
||||
# Toggle a blueprint for a specific town
|
||||
# ------------------------------------------------------------------
|
||||
@dashboard.route('/dashboard/blueprints', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_blueprint():
|
||||
data = request.get_json(silent=True) or {}
|
||||
town_id = data.get('town_id')
|
||||
blueprint_name = data.get('blueprint_name', 'Standard Growth')
|
||||
|
||||
if not town_id:
|
||||
return jsonify({'error': 'missing town_id'}), 400
|
||||
|
||||
conn = get_db()
|
||||
|
||||
# Check if currently active
|
||||
row = conn.execute('SELECT is_active FROM town_blueprints WHERE town_id = ?', (town_id,)).fetchone()
|
||||
|
||||
new_state = 1
|
||||
if row and row['is_active'] == 1:
|
||||
new_state = 0 # Toggle off
|
||||
|
||||
conn.execute('''
|
||||
INSERT INTO town_blueprints (town_id, blueprint_name, is_active, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(town_id) DO UPDATE SET
|
||||
blueprint_name = excluded.blueprint_name,
|
||||
is_active = excluded.is_active,
|
||||
updated_at = excluded.updated_at
|
||||
''', (town_id, blueprint_name, new_state, datetime.utcnow().isoformat()))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return jsonify({'ok': True, 'is_active': bool(new_state), 'blueprint_name': blueprint_name})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /dashboard/client-status
|
||||
# Returns whether the Tampermonkey client is considered online.
|
||||
@@ -219,16 +353,74 @@ def client_status():
|
||||
# ------------------------------------------------------------------
|
||||
@dashboard.route('/dashboard/captcha-status', methods=['GET'])
|
||||
def captcha_status():
|
||||
player_id = request.args.get('player_id')
|
||||
player_id = request.args.get('player_id', '').strip()
|
||||
world_id = request.args.get('world_id', '').strip()
|
||||
conn = get_db()
|
||||
# Try world-specific key first, fall back to legacy player-only key
|
||||
key = f'captcha_active_{player_id}_{world_id}' if world_id else f'captcha_active_{player_id}'
|
||||
row = conn.execute(
|
||||
"SELECT value FROM kv_store WHERE key = ?", (f'captcha_active_{player_id}', )
|
||||
"SELECT value FROM kv_store WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
if not row and world_id:
|
||||
# Legacy fallback
|
||||
row = conn.execute(
|
||||
"SELECT value FROM kv_store WHERE key = ?",
|
||||
(f'captcha_active_{player_id}',)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
active = bool(row and row['value'] == '1')
|
||||
return jsonify({'captcha_active': active})
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /dashboard/commands/queue
|
||||
# Returns pending+executing BUILD commands for a specific town,
|
||||
# ordered by their manual position (for the per-town build queue UI).
|
||||
# ------------------------------------------------------------------
|
||||
@dashboard.route('/dashboard/commands/queue', methods=['GET'])
|
||||
def get_town_build_queue():
|
||||
player_id = request.args.get('player_id')
|
||||
town_id = request.args.get('town_id')
|
||||
conn = get_db()
|
||||
rows = conn.execute('''
|
||||
SELECT id, town_id, town_name, type, payload, status, result_msg, position, created_at, updated_at
|
||||
FROM commands
|
||||
WHERE player_id = ? AND town_id = ? AND type = 'build'
|
||||
AND status IN ('pending', 'executing')
|
||||
ORDER BY position ASC, id ASC
|
||||
''', (player_id, town_id)).fetchall()
|
||||
conn.close()
|
||||
return jsonify([dict(r) for r in rows])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /dashboard/commands/reorder
|
||||
# Accepts { player_id, town_id, order: [id1, id2, ...] }
|
||||
# and updates position for each command in the list.
|
||||
# ------------------------------------------------------------------
|
||||
@dashboard.route('/dashboard/commands/reorder', methods=['POST'])
|
||||
def reorder_commands():
|
||||
data = request.get_json(silent=True) or {}
|
||||
player_id = data.get('player_id')
|
||||
town_id = data.get('town_id')
|
||||
order = data.get('order', []) # list of command ids in desired order
|
||||
|
||||
if not player_id or not town_id or not order:
|
||||
return jsonify({'error': 'missing player_id, town_id or order'}), 400
|
||||
|
||||
conn = get_db()
|
||||
for idx, cmd_id in enumerate(order):
|
||||
conn.execute('''
|
||||
UPDATE commands
|
||||
SET position = ?
|
||||
WHERE id = ? AND player_id = ? AND town_id = ?
|
||||
''', (idx + 1, cmd_id, player_id, str(town_id)))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /dashboard/commands
|
||||
# Returns command history (last 50) for the log panel.
|
||||
@@ -237,13 +429,24 @@ def captcha_status():
|
||||
def get_commands():
|
||||
player_id = request.args.get('player_id')
|
||||
conn = get_db()
|
||||
rows = conn.execute('''
|
||||
SELECT id, town_id, town_name, type, payload, status, result_msg, created_at, updated_at
|
||||
FROM commands
|
||||
WHERE player_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 50
|
||||
''', (player_id, )).fetchall()
|
||||
world_id = request.args.get('world_id')
|
||||
conn = get_db()
|
||||
|
||||
query = '''
|
||||
SELECT c.id, c.town_id, c.town_name, c.type, c.payload, c.status, c.result_msg, c.created_at, c.updated_at
|
||||
FROM commands c
|
||||
JOIN town_state ts ON c.town_id = ts.town_id
|
||||
WHERE c.player_id = ?
|
||||
'''
|
||||
params = [player_id]
|
||||
|
||||
if world_id:
|
||||
query += ' AND ts.world_id = ?'
|
||||
params.append(world_id)
|
||||
|
||||
query += ' ORDER BY c.id DESC LIMIT 50'
|
||||
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
|
||||
return jsonify([dict(r) for r in rows])
|
||||
@@ -266,8 +469,9 @@ def create_command():
|
||||
return jsonify({'error': f'missing field: {field}'}), 400
|
||||
|
||||
cmd_type = data['type']
|
||||
if cmd_type not in ('build', 'recruit', 'market_offer', 'farm_loot', 'farm_upgrade'):
|
||||
return jsonify({'error': 'type must be build, recruit, market_offer, farm_loot, or farm_upgrade'}), 400
|
||||
if cmd_type not in ('build', 'recruit', 'market_offer', 'farm_loot', 'farm_upgrade', 'research', 'culture'):
|
||||
return jsonify({'error': 'type must be build, recruit, market_offer, farm_loot, farm_upgrade, research, or culture'}), 400
|
||||
|
||||
|
||||
# Reject if the Tampermonkey client is offline (no state push in last 150 s)
|
||||
conn = get_db()
|
||||
@@ -286,14 +490,28 @@ def create_command():
|
||||
return jsonify({'error': 'client_offline', 'message': 'Το script είναι offline — δεν μπορείτε να στείλετε εντολές.'}), 503
|
||||
|
||||
c = conn.cursor()
|
||||
|
||||
# Assign position = one more than the current max for this town's pending build queue
|
||||
if cmd_type == 'build':
|
||||
pos_row = c.execute(
|
||||
"SELECT MAX(position) as max_pos FROM commands"
|
||||
" WHERE player_id = ? AND town_id = ? AND type = 'build'"
|
||||
" AND status IN ('pending', 'executing')",
|
||||
(str(data['player_id']), str(data['town_id']))
|
||||
).fetchone()
|
||||
position = (pos_row['max_pos'] or 0) + 1
|
||||
else:
|
||||
position = None
|
||||
|
||||
c.execute('''
|
||||
INSERT INTO commands (town_id, town_name, type, payload, status, created_at, updated_at, player_id)
|
||||
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?)
|
||||
INSERT INTO commands (town_id, town_name, type, payload, status, position, created_at, updated_at, player_id)
|
||||
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?)
|
||||
''', (
|
||||
str(data['town_id']),
|
||||
data.get('town_name', ''),
|
||||
cmd_type,
|
||||
json.dumps(data['payload']),
|
||||
position,
|
||||
datetime.utcnow().isoformat(),
|
||||
datetime.utcnow().isoformat(),
|
||||
str(data['player_id'])
|
||||
@@ -339,3 +557,485 @@ def fail_stale_commands():
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'failed': affected})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /dashboard/bot-settings — fetch bootcamp + rural trade config
|
||||
# POST /dashboard/bot-settings — save config
|
||||
# ------------------------------------------------------------------
|
||||
@dashboard.route('/dashboard/bot-settings', methods=['GET', 'POST'])
|
||||
def bot_settings():
|
||||
player_id = request.args.get('player_id') or (request.json or {}).get('player_id')
|
||||
world_id = request.args.get('world_id') or (request.json or {}).get('world_id', '')
|
||||
if not player_id:
|
||||
return jsonify({'error': 'missing player_id'}), 400
|
||||
|
||||
player_key = f"{player_id}_{world_id}" if world_id else player_id
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
if request.method == 'GET':
|
||||
row = c.execute(
|
||||
'SELECT * FROM bot_settings WHERE player_id = ?', (player_key,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
return jsonify(dict(row))
|
||||
return jsonify({
|
||||
'player_id': player_id,
|
||||
'bootcamp_enabled': 0,
|
||||
'bootcamp_use_def': 0,
|
||||
'rural_trade_enabled': 0,
|
||||
'rural_trade_ratio': 3,
|
||||
})
|
||||
|
||||
# POST — upsert
|
||||
data = request.json or {}
|
||||
c.execute('''
|
||||
INSERT INTO bot_settings (player_id, bootcamp_enabled, bootcamp_use_def,
|
||||
rural_trade_enabled, rural_trade_ratio, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(player_id) DO UPDATE SET
|
||||
bootcamp_enabled = excluded.bootcamp_enabled,
|
||||
bootcamp_use_def = excluded.bootcamp_use_def,
|
||||
rural_trade_enabled = excluded.rural_trade_enabled,
|
||||
rural_trade_ratio = excluded.rural_trade_ratio,
|
||||
updated_at = excluded.updated_at
|
||||
''', (
|
||||
player_key,
|
||||
int(bool(data.get('bootcamp_enabled', 0))),
|
||||
int(bool(data.get('bootcamp_use_def', 0))),
|
||||
int(bool(data.get('rural_trade_enabled', 0))),
|
||||
int(data.get('rural_trade_ratio', 3)),
|
||||
datetime.utcnow().isoformat()
|
||||
))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /dashboard/bot-logs?player_id=&feature= — last 50 log lines
|
||||
# POST /dashboard/bot-logs — append + prune
|
||||
# ------------------------------------------------------------------
|
||||
@dashboard.route('/dashboard/bot-logs', methods=['GET', 'POST'])
|
||||
def bot_logs():
|
||||
player_id = request.args.get('player_id') or (request.json or {}).get('player_id')
|
||||
world_id = request.args.get('world_id') or (request.json or {}).get('world_id', '')
|
||||
if not player_id:
|
||||
return jsonify({'error': 'missing player_id'}), 400
|
||||
|
||||
player_key = f"{player_id}_{world_id}" if world_id else player_id
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
if request.method == 'GET':
|
||||
feature = request.args.get('feature', '')
|
||||
query = 'SELECT * FROM bot_logs WHERE player_id = ?'
|
||||
params = [player_key]
|
||||
if feature:
|
||||
query += ' AND feature = ?'
|
||||
params.append(feature)
|
||||
query += ' ORDER BY id DESC LIMIT 50'
|
||||
rows = c.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
return jsonify([dict(r) for r in rows])
|
||||
|
||||
# POST — append entry and prune to last 50
|
||||
data = request.json or {}
|
||||
feature = data.get('feature', 'bootcamp')
|
||||
message = data.get('message', '')
|
||||
c.execute(
|
||||
'INSERT INTO bot_logs (player_id, feature, message) VALUES (?, ?, ?)',
|
||||
(player_key, feature, message)
|
||||
)
|
||||
# Prune: keep only the latest 50 per player/feature
|
||||
c.execute('''
|
||||
DELETE FROM bot_logs
|
||||
WHERE player_id = ? AND feature = ?
|
||||
AND id NOT IN (
|
||||
SELECT id FROM bot_logs
|
||||
WHERE player_id = ? AND feature = ?
|
||||
ORDER BY id DESC LIMIT 50
|
||||
)
|
||||
''', (player_key, feature, player_key, feature))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /dashboard/bootcamp-attack-now
|
||||
# Sets a one-shot flag consumed by the TM bot on the next poll.
|
||||
# ------------------------------------------------------------------
|
||||
@dashboard.route('/dashboard/bootcamp-attack-now', methods=['POST'])
|
||||
def bootcamp_attack_now():
|
||||
player_id = (request.json or {}).get('player_id')
|
||||
world_id = (request.json or {}).get('world_id', '')
|
||||
if not player_id:
|
||||
return jsonify({'error': 'missing player_id'}), 400
|
||||
|
||||
player_key = f"{player_id}_{world_id}" if world_id else player_id
|
||||
key = f'bootcamp_attack_now_{player_key}'
|
||||
conn = get_db()
|
||||
conn.execute('''
|
||||
INSERT INTO kv_store (key, value, updated_at) VALUES (?, '1', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value='1', updated_at=excluded.updated_at
|
||||
''', (key, datetime.utcnow().isoformat()))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /dashboard/agora
|
||||
# Returns per-town celebration eligibility for the Αγορά tab.
|
||||
# For each town owned by player_id + world_id we calculate:
|
||||
# • party — has 15k wood / 18k stone / 15k iron? on cooldown?
|
||||
# • triumph — has 300+ battle points? on cooldown?
|
||||
# ------------------------------------------------------------------
|
||||
PARTY_COST = {'wood': 15000, 'stone': 18000, 'iron': 15000}
|
||||
TRIUMPH_COST = 300 # battle points
|
||||
|
||||
@dashboard.route('/dashboard/agora', methods=['GET'])
|
||||
@login_required
|
||||
def get_agora():
|
||||
player_id = request.args.get('player_id')
|
||||
world_id = request.args.get('world_id', '')
|
||||
if not player_id:
|
||||
return jsonify({'error': 'missing player_id'}), 400
|
||||
|
||||
conn = get_db()
|
||||
now_ts = int(datetime.utcnow().timestamp())
|
||||
|
||||
# ── Town snapshots ───────────────────────────────────────────────
|
||||
town_rows = conn.execute('''
|
||||
SELECT town_id, town_name, data
|
||||
FROM town_state
|
||||
WHERE player_id = ? AND world_id = ?
|
||||
ORDER BY town_name ASC
|
||||
''', (player_id, world_id)).fetchall()
|
||||
|
||||
# ── Active celebrations (cooldowns) ──────────────────────────────
|
||||
cel_rows = conn.execute('''
|
||||
SELECT town_id, celebration_type, finished_at
|
||||
FROM celebrations
|
||||
WHERE player_id = ? AND world_id = ? AND finished_at > ?
|
||||
''', (player_id, world_id, now_ts)).fetchall()
|
||||
|
||||
# Build a lookup: (town_id, cel_type) → finished_at
|
||||
cooldowns = {}
|
||||
for r in cel_rows:
|
||||
cooldowns[(r['town_id'], r['celebration_type'])] = r['finished_at']
|
||||
|
||||
# ── Auto settings ────────────────────────────────────────
|
||||
settings_rows = conn.execute('''
|
||||
SELECT town_id, auto_party, auto_triumph
|
||||
FROM culture_settings
|
||||
WHERE player_id = ? AND world_id = ?
|
||||
''', (player_id, world_id)).fetchall()
|
||||
auto_settings = {} # town_id → {auto_party, auto_triumph}
|
||||
for s in settings_rows:
|
||||
auto_settings[(s['town_id'], 'auto_party')] = s['auto_party']
|
||||
auto_settings[(s['town_id'], 'auto_triumph')] = s['auto_triumph']
|
||||
|
||||
conn.close()
|
||||
|
||||
# ── Per-town eligibility ─────────────────────────────────────────
|
||||
towns_out = []
|
||||
for row in town_rows:
|
||||
d = json.loads(row['data'])
|
||||
wood = d.get('wood', 0)
|
||||
stone = d.get('stone', 0)
|
||||
iron = d.get('iron', 0)
|
||||
bp = d.get('battle_points', {}).get('available', 0)
|
||||
academy = d.get('buildings', {}).get('academy', 0)
|
||||
|
||||
tid = row['town_id']
|
||||
|
||||
# ── Party (Γιορτή πόλης) ─────────────────────────────────────
|
||||
party_cd = cooldowns.get((tid, 'party'), 0)
|
||||
if party_cd:
|
||||
party_status = 'cooldown'
|
||||
party_reason = f'Ενεργή — λήγει σε {_fmt_seconds(party_cd - now_ts)}'
|
||||
party_ok = False
|
||||
elif academy < 30:
|
||||
party_status = 'unavailable'
|
||||
party_reason = f'Ακαδημία {academy}/30'
|
||||
party_ok = False
|
||||
elif wood < PARTY_COST['wood'] or stone < PARTY_COST['stone'] or iron < PARTY_COST['iron']:
|
||||
missing = []
|
||||
if wood < PARTY_COST['wood']: missing.append(f'ξύλο {wood:,}/{PARTY_COST["wood"]:,}')
|
||||
if stone < PARTY_COST['stone']: missing.append(f'πέτρα {stone:,}/{PARTY_COST["stone"]:,}')
|
||||
if iron < PARTY_COST['iron']: missing.append(f'σίδερο {iron:,}/{PARTY_COST["iron"]:,}')
|
||||
party_status = 'unavailable'
|
||||
party_reason = 'Ανεπαρκείς πόροι: ' + ', '.join(missing)
|
||||
party_ok = False
|
||||
else:
|
||||
party_status = 'available'
|
||||
party_reason = ''
|
||||
party_ok = True
|
||||
|
||||
# ── Triumph (Παρέλαση θριάμβου) ──────────────────────────────
|
||||
triumph_cd = cooldowns.get((tid, 'triumph'), 0)
|
||||
if triumph_cd:
|
||||
triumph_status = 'cooldown'
|
||||
triumph_reason = f'Ενεργή — λήγει σε {_fmt_seconds(triumph_cd - now_ts)}'
|
||||
triumph_ok = False
|
||||
elif bp < TRIUMPH_COST:
|
||||
triumph_status = 'unavailable'
|
||||
triumph_reason = f'Ανεπαρκείς πόντοι μάχης: {bp}/{TRIUMPH_COST}'
|
||||
triumph_ok = False
|
||||
else:
|
||||
triumph_status = 'available'
|
||||
triumph_reason = ''
|
||||
triumph_ok = True
|
||||
|
||||
towns_out.append({
|
||||
'town_id': tid,
|
||||
'town_name': row['town_name'],
|
||||
'resources': {'wood': wood, 'stone': stone, 'iron': iron},
|
||||
'battle_points': bp,
|
||||
'academy': academy,
|
||||
'party': {
|
||||
'status': party_status,
|
||||
'reason': party_reason,
|
||||
'available': party_ok,
|
||||
'cooldown_until': party_cd or None,
|
||||
},
|
||||
'triumph': {
|
||||
'status': triumph_status,
|
||||
'reason': triumph_reason,
|
||||
'available': triumph_ok,
|
||||
'cooldown_until': triumph_cd or None,
|
||||
},
|
||||
'auto_party': bool(auto_settings.get((tid, 'auto_party'), 0)),
|
||||
'auto_triumph': bool(auto_settings.get((tid, 'auto_triumph'), 0)),
|
||||
})
|
||||
|
||||
return jsonify({'towns': towns_out, 'costs': {'party': PARTY_COST, 'triumph': TRIUMPH_COST}})
|
||||
|
||||
|
||||
def _fmt_seconds(secs):
|
||||
"""Format a duration in seconds to a human-readable Greek string."""
|
||||
secs = max(0, int(secs))
|
||||
h, rem = divmod(secs, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
if h:
|
||||
return f'{h}ω {m}λ'
|
||||
if m:
|
||||
return f'{m}λ {s}δ'
|
||||
return f'{s}δ'
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /dashboard/culture-command
|
||||
# Dashboard fires when the user confirms a celebration.
|
||||
# Body: { player_id, world_id, town_id, town_name, celebration_type }
|
||||
# • Validates client is online.
|
||||
# • Validates eligibility (resources / battle_points / cooldown).
|
||||
# • Inserts a 'culture' command into the culture_queue.
|
||||
# • Inserts a 'pending' row into culture_log.
|
||||
# ------------------------------------------------------------------
|
||||
@dashboard.route('/dashboard/culture-command', methods=['POST'])
|
||||
@login_required
|
||||
def culture_command():
|
||||
data = request.get_json(silent=True) or {}
|
||||
player_id = data.get('player_id')
|
||||
world_id = data.get('world_id', '')
|
||||
town_id = str(data.get('town_id', ''))
|
||||
town_name = data.get('town_name', '')
|
||||
cel_type = data.get('celebration_type', '') # 'party' | 'triumph'
|
||||
|
||||
if not all([player_id, town_id, cel_type]):
|
||||
return jsonify({'error': 'missing player_id, town_id or celebration_type'}), 400
|
||||
if cel_type not in ('party', 'triumph'):
|
||||
return jsonify({'error': 'celebration_type must be party or triumph'}), 400
|
||||
|
||||
conn = get_db()
|
||||
|
||||
# ── Client online check ──────────────────────────────────────────
|
||||
row = conn.execute(
|
||||
'SELECT MAX(updated_at) AS last_seen FROM town_state WHERE player_id = ?', (player_id,)
|
||||
).fetchone()
|
||||
last_seen = row['last_seen'] if row else None
|
||||
client_online = False
|
||||
if last_seen:
|
||||
try:
|
||||
dt = datetime.fromisoformat(last_seen)
|
||||
client_online = (datetime.utcnow() - dt).total_seconds() <= 150
|
||||
except Exception:
|
||||
pass
|
||||
if not client_online:
|
||||
conn.close()
|
||||
return jsonify({'error': 'client_offline',
|
||||
'message': 'Το script είναι offline — δεν μπορείτε να στείλετε εντολές.'}), 503
|
||||
|
||||
# ── Re-validate eligibility server-side ─────────────────────────
|
||||
town_row = conn.execute(
|
||||
'SELECT data FROM town_state WHERE town_id = ? AND player_id = ?', (town_id, player_id)
|
||||
).fetchone()
|
||||
if not town_row:
|
||||
conn.close()
|
||||
return jsonify({'error': 'town not found'}), 404
|
||||
|
||||
d = json.loads(town_row['data'])
|
||||
now_t = int(datetime.utcnow().timestamp())
|
||||
|
||||
# Cooldown check
|
||||
cel_row = conn.execute('''
|
||||
SELECT finished_at FROM celebrations
|
||||
WHERE player_id = ? AND world_id = ? AND town_id = ? AND celebration_type = ? AND finished_at > ?
|
||||
''', (player_id, world_id, town_id, cel_type, now_t)).fetchone()
|
||||
if cel_row:
|
||||
conn.close()
|
||||
return jsonify({'error': 'on_cooldown',
|
||||
'message': f'Η εορτή τρέχει ήδη — λήγει σε {_fmt_seconds(cel_row["finished_at"] - now_t)}'}), 409
|
||||
|
||||
# Cost determination & resource check
|
||||
if cel_type == 'party':
|
||||
cost_wood = PARTY_COST['wood']
|
||||
cost_stone = PARTY_COST['stone']
|
||||
cost_iron = PARTY_COST['iron']
|
||||
cost_bp = 0
|
||||
if (d.get('wood', 0) < cost_wood or d.get('stone', 0) < cost_stone
|
||||
or d.get('iron', 0) < cost_iron):
|
||||
conn.close()
|
||||
return jsonify({'error': 'insufficient_resources',
|
||||
'message': 'Ανεπαρκείς πόροι για τη Γιορτή πόλης.'}), 409
|
||||
else: # triumph
|
||||
cost_wood = cost_stone = cost_iron = 0
|
||||
cost_bp = TRIUMPH_COST
|
||||
available_bp = d.get('battle_points', {}).get('available', 0)
|
||||
if available_bp < TRIUMPH_COST:
|
||||
conn.close()
|
||||
return jsonify({'error': 'insufficient_battle_points',
|
||||
'message': f'Ανεπαρκείς πόντοι μάχης ({available_bp}/{TRIUMPH_COST}).'}), 409
|
||||
|
||||
# ── Insert into culture_queue ────────────────────────────────
|
||||
c = conn.cursor()
|
||||
|
||||
# One-at-a-time check: reject if this type already pending/executing
|
||||
existing = c.execute('''
|
||||
SELECT id FROM culture_queue
|
||||
WHERE player_id = ? AND world_id = ? AND town_id = ? AND celebration_type = ?
|
||||
AND status IN ('pending', 'executing')
|
||||
''', (str(player_id), world_id, town_id, cel_type)).fetchone()
|
||||
if existing:
|
||||
conn.close()
|
||||
label = 'Γιορτή πόλης' if cel_type == 'party' else 'Παρέλαση θριάμβου'
|
||||
return jsonify({'error': 'already_queued',
|
||||
'message': f'{label} για την πόλη αυτή είναι ήδη σε αναμονή εκτέλεσης.'}), 409
|
||||
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
|
||||
c.execute('''
|
||||
INSERT INTO culture_queue
|
||||
(player_id, world_id, town_id, town_name, celebration_type, status, source, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', 'manual', ?)
|
||||
''', (str(player_id), world_id, town_id, town_name, cel_type, now_iso))
|
||||
queue_id = c.lastrowid
|
||||
|
||||
# ── Append culture_log row (status=pending until bot confirms) ───
|
||||
c.execute('''
|
||||
INSERT INTO culture_log
|
||||
(player_id, world_id, town_id, town_name, celebration_type,
|
||||
cost_wood, cost_stone, cost_iron, cost_battle_pts, status, source, fired_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 'manual', ?)
|
||||
''', (str(player_id), world_id, town_id, town_name, cel_type,
|
||||
cost_wood, cost_stone, cost_iron, cost_bp, now_iso))
|
||||
log_id = c.lastrowid
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'queue_id': queue_id, 'log_id': log_id})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /dashboard/culture-log
|
||||
# Returns the last 50 Αγορά log entries for a player + world.
|
||||
# ------------------------------------------------------------------
|
||||
@dashboard.route('/dashboard/culture-log', methods=['GET'])
|
||||
@login_required
|
||||
def get_culture_log():
|
||||
player_id = request.args.get('player_id')
|
||||
world_id = request.args.get('world_id', '')
|
||||
if not player_id:
|
||||
return jsonify({'error': 'missing player_id'}), 400
|
||||
|
||||
conn = get_db()
|
||||
rows = conn.execute('''
|
||||
SELECT id, town_id, town_name, celebration_type,
|
||||
cost_wood, cost_stone, cost_iron, cost_battle_pts,
|
||||
status, result_msg, source, fired_at, confirmed_at
|
||||
FROM culture_log
|
||||
WHERE player_id = ? AND world_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 50
|
||||
''', (player_id, world_id)).fetchall()
|
||||
conn.close()
|
||||
return jsonify([dict(r) for r in rows])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /dashboard/culture-settings?player_id=&world_id=&town_id=
|
||||
# POST /dashboard/culture-settings
|
||||
# Body: { player_id, world_id, town_id, auto_party, auto_triumph }
|
||||
# Save/retrieve per-town auto-celebration preferences.
|
||||
# ------------------------------------------------------------------
|
||||
@dashboard.route('/dashboard/culture-settings', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def culture_settings():
|
||||
if request.method == 'GET':
|
||||
player_id = request.args.get('player_id')
|
||||
world_id = request.args.get('world_id', '')
|
||||
town_id = request.args.get('town_id', '')
|
||||
if not player_id:
|
||||
return jsonify({'error': 'missing player_id'}), 400
|
||||
|
||||
conn = get_db()
|
||||
if town_id:
|
||||
row = conn.execute(
|
||||
'SELECT auto_party, auto_triumph FROM culture_settings WHERE player_id=? AND world_id=? AND town_id=?',
|
||||
(player_id, world_id, town_id)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
return jsonify({'auto_party': bool(row['auto_party']), 'auto_triumph': bool(row['auto_triumph'])})
|
||||
return jsonify({'auto_party': False, 'auto_triumph': False})
|
||||
else:
|
||||
rows = conn.execute(
|
||||
'SELECT town_id, auto_party, auto_triumph FROM culture_settings WHERE player_id=? AND world_id=?',
|
||||
(player_id, world_id)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return jsonify([dict(r) for r in rows])
|
||||
|
||||
# POST — save settings
|
||||
data = request.get_json(silent=True) or {}
|
||||
player_id = data.get('player_id')
|
||||
world_id = data.get('world_id', '')
|
||||
town_id = str(data.get('town_id', ''))
|
||||
auto_party = 1 if data.get('auto_party') else 0
|
||||
auto_triumph = 1 if data.get('auto_triumph') else 0
|
||||
|
||||
if not player_id or not town_id:
|
||||
return jsonify({'error': 'missing player_id or town_id'}), 400
|
||||
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
conn = get_db()
|
||||
conn.execute('''
|
||||
INSERT INTO culture_settings (player_id, world_id, town_id, auto_party, auto_triumph, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(player_id, world_id, town_id) DO UPDATE SET
|
||||
auto_party = excluded.auto_party,
|
||||
auto_triumph = excluded.auto_triumph,
|
||||
updated_at = excluded.updated_at
|
||||
''', (str(player_id), world_id, town_id, auto_party, auto_triumph, now_iso))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'auto_party': bool(auto_party), 'auto_triumph': bool(auto_triumph)})
|
||||
|
||||
|
||||
214
routes/tracker.py
Normal file
214
routes/tracker.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
routes/tracker.py — Live Tracker Blueprint
|
||||
Handles:
|
||||
POST /api/<world_id>/movements (Tampermonkey pushes movement data)
|
||||
GET /api/<world_id>/movements/<player_id> (Dashboard initial load)
|
||||
GET /api/<world_id>/movements/<player_id>/stream (SSE push stream)
|
||||
|
||||
All data is isolated per player_id + world_id.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, Response, stream_with_context
|
||||
from db import get_db
|
||||
from datetime import datetime
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import logging
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
tracker = Blueprint('tracker', __name__)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# In-memory SSE subscriber registry
|
||||
# Key: "<world_id>:<player_id>" Value: list of queue.Queue()
|
||||
# One Queue per open dashboard tab. Thread-safe via a lock.
|
||||
# ----------------------------------------------------------------
|
||||
_subscribers = {}
|
||||
_sub_lock = threading.Lock()
|
||||
|
||||
|
||||
def _sub_key(player_id, world_id):
|
||||
return f"{world_id}:{player_id}"
|
||||
|
||||
|
||||
def _notify(player_id, world_id, payload):
|
||||
"""Push a JSON payload to all SSE subscribers for this player+world."""
|
||||
key = _sub_key(player_id, world_id)
|
||||
data = json.dumps(payload)
|
||||
with _sub_lock:
|
||||
for q in _subscribers.get(key, []):
|
||||
try:
|
||||
q.put_nowait(data)
|
||||
except queue.Full:
|
||||
pass # slow consumer — drop silently
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Helper: read clan from X-Clan-Key header (same as api.py)
|
||||
# ----------------------------------------------------------------
|
||||
def _get_clan_from_request():
|
||||
key = request.headers.get('X-Clan-Key', '').strip()
|
||||
if not key:
|
||||
return None
|
||||
conn = get_db()
|
||||
clan = conn.execute('SELECT * FROM clans WHERE clan_key = ?', (key,)).fetchone()
|
||||
conn.close()
|
||||
return clan
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# POST /api/<world_id>/movements
|
||||
# Tampermonkey sends the current movement snapshot.
|
||||
# Requires X-Clan-Key header (same as all other bot endpoints).
|
||||
# Body: { player_id, world_id, movements: [{...}, ...] }
|
||||
# ----------------------------------------------------------------
|
||||
@tracker.route('/api/<world_id>/movements', methods=['POST'])
|
||||
def receive_movements(world_id):
|
||||
clan = _get_clan_from_request()
|
||||
if not clan:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({'error': 'no data'}), 400
|
||||
|
||||
player_id = str(data.get('player_id', '')).strip()
|
||||
movements = data.get('movements', [])
|
||||
|
||||
if not player_id:
|
||||
return jsonify({'error': 'missing player_id'}), 400
|
||||
|
||||
# Normalise world_id — trust the URL param, not the body
|
||||
world_id = world_id.strip()
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
# 1. Upsert each movement (UNIQUE on player_id+world_id+command_id)
|
||||
for m in movements:
|
||||
cmd_id = str(m.get('id', '')).strip()
|
||||
if not cmd_id:
|
||||
continue
|
||||
c.execute('''
|
||||
INSERT INTO movements
|
||||
(player_id, world_id, command_id, cmd_type,
|
||||
origin_town, origin_player, target_town, target_player,
|
||||
arrival_at, raw_data, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(player_id, world_id, command_id) DO UPDATE SET
|
||||
cmd_type = excluded.cmd_type,
|
||||
origin_town = excluded.origin_town,
|
||||
origin_player = excluded.origin_player,
|
||||
target_town = excluded.target_town,
|
||||
target_player = excluded.target_player,
|
||||
arrival_at = excluded.arrival_at,
|
||||
raw_data = excluded.raw_data,
|
||||
updated_at = excluded.updated_at
|
||||
''', (
|
||||
player_id, world_id, cmd_id,
|
||||
m.get('type', 'unknown'),
|
||||
m.get('origin_town'), m.get('origin_player'),
|
||||
m.get('target_town'), m.get('target_player'),
|
||||
m.get('arrival_at'),
|
||||
json.dumps(m),
|
||||
now
|
||||
))
|
||||
|
||||
# 2. Purge stale entries: movements that are no longer in the snapshot
|
||||
# (the game already resolved them). We use command_ids sent in this batch.
|
||||
live_ids = [str(m.get('id', '')) for m in movements if m.get('id')]
|
||||
if live_ids:
|
||||
placeholders = ','.join('?' * len(live_ids))
|
||||
c.execute(f'''
|
||||
DELETE FROM movements
|
||||
WHERE player_id = ? AND world_id = ?
|
||||
AND command_id NOT IN ({placeholders})
|
||||
''', [player_id, world_id] + live_ids)
|
||||
else:
|
||||
# Empty snapshot → all movements resolved, clear the table for this player+world
|
||||
c.execute(
|
||||
'DELETE FROM movements WHERE player_id = ? AND world_id = ?',
|
||||
(player_id, world_id)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 3. Read back the current state and notify SSE subscribers immediately
|
||||
rows = c.execute('''
|
||||
SELECT command_id, cmd_type, origin_town, origin_player,
|
||||
target_town, target_player, arrival_at
|
||||
FROM movements
|
||||
WHERE player_id = ? AND world_id = ?
|
||||
ORDER BY arrival_at ASC
|
||||
''', (player_id, world_id)).fetchall()
|
||||
conn.close()
|
||||
|
||||
result = [dict(r) for r in rows]
|
||||
_notify(player_id, world_id, {'movements': result})
|
||||
|
||||
return jsonify({'ok': True, 'stored': len(result)})
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# GET /api/<world_id>/movements/<player_id>
|
||||
# Dashboard initial load — returns current snapshot from DB.
|
||||
# ----------------------------------------------------------------
|
||||
@tracker.route('/api/<world_id>/movements/<player_id>', methods=['GET'])
|
||||
def get_movements(world_id, player_id):
|
||||
conn = get_db()
|
||||
rows = conn.execute('''
|
||||
SELECT command_id, cmd_type, origin_town, origin_player,
|
||||
target_town, target_player, arrival_at
|
||||
FROM movements
|
||||
WHERE player_id = ? AND world_id = ?
|
||||
ORDER BY arrival_at ASC
|
||||
''', (player_id, world_id)).fetchall()
|
||||
conn.close()
|
||||
return jsonify({'movements': [dict(r) for r in rows]})
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# GET /api/<world_id>/movements/<player_id>/stream
|
||||
# SSE endpoint — keeps connection open, pushes updates to dashboard.
|
||||
# Each connected dashboard tab gets its own Queue.
|
||||
# ----------------------------------------------------------------
|
||||
@tracker.route('/api/<world_id>/movements/<player_id>/stream', methods=['GET'])
|
||||
def stream_movements(world_id, player_id):
|
||||
key = _sub_key(player_id, world_id)
|
||||
q = queue.Queue(maxsize=20)
|
||||
|
||||
with _sub_lock:
|
||||
_subscribers.setdefault(key, []).append(q)
|
||||
|
||||
def generate():
|
||||
# Send a comment immediately so the browser confirms the connection
|
||||
yield ': connected\n\n'
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
data = q.get(timeout=30)
|
||||
yield f'data: {data}\n\n'
|
||||
except queue.Empty:
|
||||
# Heartbeat — keeps the connection alive through proxies/firewalls
|
||||
yield ': heartbeat\n\n'
|
||||
except GeneratorExit:
|
||||
pass
|
||||
finally:
|
||||
with _sub_lock:
|
||||
subs = _subscribers.get(key, [])
|
||||
if q in subs:
|
||||
subs.remove(q)
|
||||
if not subs:
|
||||
_subscribers.pop(key, None)
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
content_type='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no', # disables nginx buffering (important!)
|
||||
}
|
||||
)
|
||||
68
split_script.py
Normal file
68
split_script.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
input_file = '/media/haunter/e11cc3d4-c894-42cd-8c43-fe2cb25293fd/Vcode_raidbot/grepo-remote/GrepolisRemoteControl.user.js'
|
||||
out_dir = '/media/haunter/e11cc3d4-c894-42cd-8c43-fe2cb25293fd/Vcode_raidbot/grepo-remote/bot_modules'
|
||||
|
||||
if not os.path.exists(out_dir):
|
||||
os.makedirs(out_dir)
|
||||
|
||||
with open(input_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Find the start of the IIFE
|
||||
start_idx = 0
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith('(function'):
|
||||
start_idx = i + 1
|
||||
break
|
||||
|
||||
# Extract the body of the IIFE (excluding the last '})();')
|
||||
end_idx = len(lines)
|
||||
for i in range(len(lines) - 1, -1, -1):
|
||||
if line.strip().startswith('})();'):
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
# Remove use strict if present
|
||||
body_lines = [line for line in lines[start_idx:end_idx] if 'use strict' not in line]
|
||||
|
||||
# We will group them manually into files based on logical sections
|
||||
sections = []
|
||||
current_section = []
|
||||
for line in body_lines:
|
||||
current_section.append(line)
|
||||
|
||||
content = "".join(body_lines)
|
||||
|
||||
# Split by known markers
|
||||
blocks = {
|
||||
'00_config.js': [],
|
||||
'01_ui.js': [],
|
||||
'02_state.js': [],
|
||||
'03_captcha.js': [],
|
||||
'04_execute.js': [],
|
||||
'05_main.js': []
|
||||
}
|
||||
|
||||
current_file = '00_config.js'
|
||||
|
||||
for line in body_lines:
|
||||
if '// Toolbar indicator button' in line:
|
||||
current_file = '01_ui.js'
|
||||
elif '// Push town state to relay' in line:
|
||||
current_file = '02_state.js'
|
||||
elif '// Captcha detection' in line:
|
||||
current_file = '03_captcha.js'
|
||||
elif '// Execute: Farm Upgrade' in line:
|
||||
current_file = '04_execute.js'
|
||||
elif '// Poll for and execute pending commands' in line:
|
||||
current_file = '05_main.js'
|
||||
|
||||
blocks[current_file].append(line)
|
||||
|
||||
for filename, lines in blocks.items():
|
||||
with open(os.path.join(out_dir, filename), 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
print("Split completed.")
|
||||
@@ -163,6 +163,10 @@ select:focus, input:focus { outline: none; border-color: #c8a44a; }
|
||||
.btn-danger { background: #8b2222; color: #fff; }
|
||||
.btn-sm { padding: 5px 10px; font-size: 0.75rem; }
|
||||
|
||||
.seg-btn:hover { background: rgba(200, 164, 74, 0.1) !important; color: #c8a44a !important; }
|
||||
.seg-btn.active { background: #c8a44a !important; color: #1a1a2e !important; font-weight: bold; box-shadow: 0 2px 5px rgba(0,0,0,0.3); }
|
||||
|
||||
|
||||
#no-town-selected {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
@@ -314,9 +318,9 @@ tr:hover td { background: #1e1e40; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Building Picker Modal
|
||||
Building, Academy, Unit, Market & Blueprint Modals
|
||||
========================================================================== */
|
||||
#building-modal-overlay {
|
||||
#building-modal-overlay, #academy-modal-overlay, #unit-modal-overlay, #market-modal-overlay, #blueprints-modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -325,9 +329,9 @@ tr:hover td { background: #1e1e40; }
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#building-modal-overlay.open { display: flex; }
|
||||
#building-modal-overlay.open, #academy-modal-overlay.open, #unit-modal-overlay.open, #market-modal-overlay.open, #blueprints-modal-overlay.open { display: flex; }
|
||||
|
||||
#building-modal {
|
||||
#building-modal, #academy-modal, #unit-modal, #market-modal, #blueprints-modal {
|
||||
background: #16213e;
|
||||
border: 2px solid #c8a44a;
|
||||
border-radius: 10px;
|
||||
@@ -342,7 +346,7 @@ tr:hover td { background: #1e1e40; }
|
||||
from { transform: scale(0.92); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
#building-modal-header {
|
||||
#building-modal-header, #academy-modal-header, .modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -350,12 +354,12 @@ tr:hover td { background: #1e1e40; }
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #2a4a6a;
|
||||
}
|
||||
#building-modal-header h3 {
|
||||
#building-modal-header h3, #academy-modal-header h3, .modal-header h3 {
|
||||
color: #c8a44a;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
#building-modal-close {
|
||||
#building-modal-close, #academy-modal-close, #unit-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
@@ -364,7 +368,7 @@ tr:hover td { background: #1e1e40; }
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
}
|
||||
#building-modal-close:hover { color: #fff; }
|
||||
#building-modal-close:hover, #academy-modal-close:hover, #unit-modal-close:hover { color: #fff; }
|
||||
|
||||
#building-grid {
|
||||
display: grid;
|
||||
@@ -436,3 +440,26 @@ tr:hover td { background: #1e1e40; }
|
||||
@media (max-width: 600px) {
|
||||
#building-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* Academy Grid specific styling */
|
||||
#academy-grid {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.academy-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 140px;
|
||||
}
|
||||
.academy-col-header {
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #c8a44a;
|
||||
border-bottom: 1px solid #2a4a6a;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
window.fetchTowns = async function() {
|
||||
try {
|
||||
const res = await fetch('/dashboard/towns?player_id=' + window.PLAYER_ID);
|
||||
const res = await fetch(`/dashboard/towns?player_id=${window.PLAYER_ID}&world_id=${window.WORLD_ID}`);
|
||||
window.towns = await res.json();
|
||||
window.renderTowns();
|
||||
window.updateServerStatus(true);
|
||||
@@ -16,9 +16,14 @@ window.fetchTowns = async function() {
|
||||
|
||||
if (window.selectedTownId) {
|
||||
window.renderBuildQueuePreview();
|
||||
window.renderUnitQueuePreview();
|
||||
window.renderBuildingDropdown();
|
||||
window.renderUnitDropdown();
|
||||
window.renderTownDetails();
|
||||
// Refresh the build queue panel if in queue mode
|
||||
if (window._logPanelMode === 'queue') {
|
||||
window.fetchBuildQueue(window.selectedTownId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
window.updateServerStatus(false);
|
||||
@@ -48,10 +53,12 @@ window.fetchClientStatus = async function() {
|
||||
|
||||
window.fetchLog = async function() {
|
||||
try {
|
||||
const res = await fetch('/dashboard/commands?player_id=' + window.PLAYER_ID);
|
||||
const res = await fetch(`/dashboard/commands?player_id=${window.PLAYER_ID}&world_id=${window.WORLD_ID}`);
|
||||
const cmds = await res.json();
|
||||
window.cmds = cmds; // Save globally so viewer can see reserved resources
|
||||
window.renderLog(cmds);
|
||||
if (window._logPanelMode === 'log') {
|
||||
window.renderLog(cmds);
|
||||
}
|
||||
if (window.selectedTownId) window.renderTownDetails();
|
||||
} catch (e) {}
|
||||
};
|
||||
@@ -77,7 +84,7 @@ window.updateClientStatus = function(online) {
|
||||
el.className = 'conn-badge offline';
|
||||
}
|
||||
// Enable/disable the Send button
|
||||
const btn = document.querySelector('#command-form-wrap .btn-gold');
|
||||
const btn = document.getElementById('btn-send');
|
||||
if (btn) {
|
||||
btn.disabled = !online;
|
||||
btn.title = online ? '' : 'Script is offline — cannot send commands';
|
||||
@@ -91,8 +98,8 @@ window.sendCommand = async function() {
|
||||
const town = window.getSelectedTown();
|
||||
if (!town) return alert('Select a town first.');
|
||||
|
||||
const type = document.getElementById('cmd-type').value;
|
||||
if (!type) return alert('Παρακαλώ επιλέξτε Ενέργεια (Command Type) πρώτα.');
|
||||
const type = window.currentCmdType;
|
||||
if (!type) return alert('Παρακαλώ επιλέξτε Ενέργεια (Κατασκευή/Στρατός/Παζάρι/Έρευνα) πρώτα.');
|
||||
|
||||
let payload = {};
|
||||
|
||||
@@ -126,7 +133,7 @@ window.sendCommand = async function() {
|
||||
|
||||
payload = { building_id };
|
||||
} else if (type === 'recruit') {
|
||||
const unit_id = document.getElementById('unit-select').value;
|
||||
const unit_id = window.selectedUnitId;
|
||||
if (!unit_id) return alert('Παρακαλώ επιλέξτε Μονάδα προς εκπαίδευση.');
|
||||
|
||||
const amount = parseInt(document.getElementById('recruit-amount').value) || 1;
|
||||
@@ -164,6 +171,42 @@ window.sendCommand = async function() {
|
||||
const visibility = document.getElementById('market-visibility').value;
|
||||
|
||||
payload = { offer, offer_type, demand, demand_type, max_delivery_time, visibility };
|
||||
} else if (type === 'research') {
|
||||
const research_id = window.selectedResearchId;
|
||||
if (!research_id) return alert('Παρακαλώ επιλέξτε Έρευνα από την Ακαδημία.');
|
||||
|
||||
const academyLevel = town.buildings?.academy || 0;
|
||||
const rData = window.RESEARCH_DATA[research_id];
|
||||
|
||||
if (rData && academyLevel < rData.academy_level) {
|
||||
return alert(`❌ ΑΔΥΝΑΤΗ Η ΕΡΕΥΝΑ: Απαιτείται Ακαδημία Επίπεδο ${rData.academy_level} (Έχετε ${academyLevel})`);
|
||||
}
|
||||
|
||||
payload = { research_id };
|
||||
} else if (type === 'blueprints') {
|
||||
const blueprint_name = window.selectedBlueprintName;
|
||||
if (!blueprint_name) return alert('Παρακαλώ επιλέξτε Blueprint.');
|
||||
|
||||
try {
|
||||
const res = await fetch('/dashboard/blueprints', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
town_id: town.town_id,
|
||||
blueprint_name: blueprint_name
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
alert(data.is_active ? 'Blueprint ενεργοποιήθηκε!' : 'Blueprint απενεργοποιήθηκε!');
|
||||
window.fetchTowns(); // refresh towns to update UI state
|
||||
} else {
|
||||
alert('Σφάλμα: ' + data.error);
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Failed to toggle blueprint: ' + e);
|
||||
}
|
||||
return; // Exit here as we don't send this to /dashboard/commands
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -180,7 +223,12 @@ window.sendCommand = async function() {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
window.fetchLog();
|
||||
// Refresh whichever panel is active
|
||||
if (type === 'build' && window._logPanelMode === 'queue') {
|
||||
window.fetchBuildQueue(town.town_id);
|
||||
} else {
|
||||
window.fetchLog();
|
||||
}
|
||||
} else if (data.error === 'client_offline') {
|
||||
alert(data.message || 'Το script είναι offline.');
|
||||
} else {
|
||||
@@ -198,18 +246,18 @@ window.cancelCommand = async function(id) {
|
||||
|
||||
window.fetchCaptchaStatus = async function() {
|
||||
try {
|
||||
const res = await fetch('/dashboard/captcha-status?player_id=' + window.PLAYER_ID);
|
||||
const res = await fetch(
|
||||
`/dashboard/captcha-status?player_id=${window.PLAYER_ID}&world_id=${window.WORLD_ID}`
|
||||
);
|
||||
const data = await res.json();
|
||||
const banner = document.getElementById('captcha-banner');
|
||||
if (!banner) return;
|
||||
|
||||
|
||||
if (data.captcha_active) {
|
||||
// Only show it if the user hasn't explicitly clicked 'close' for this specific alert
|
||||
if (banner.dataset.dismissed !== '1') {
|
||||
banner.style.display = 'flex';
|
||||
}
|
||||
} else {
|
||||
// Captcha cleared from the game - hide banner and reset dismiss state for next time
|
||||
banner.style.display = 'none';
|
||||
banner.dataset.dismissed = '0';
|
||||
}
|
||||
@@ -248,3 +296,5 @@ window.requestLiveSync = async function() {
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
window.fetchTowns();
|
||||
window.fetchLog();
|
||||
window.fetchLog(); // pre-loads cmds globally even in queue mode
|
||||
window.fetchClientStatus();
|
||||
window.fetchCaptchaStatus();
|
||||
setInterval(window.fetchTowns, window.POLL_INTERVAL);
|
||||
setInterval(window.fetchLog, window.POLL_INTERVAL);
|
||||
// In log mode: fetchLog refreshes the panel. In queue mode: refreshLogPanel polls the queue.
|
||||
setInterval(() => {
|
||||
window.fetchLog(); // always keep cmds cache fresh for resource display
|
||||
window.refreshLogPanel(); // refresh whichever panel is visible
|
||||
}, window.POLL_INTERVAL);
|
||||
setInterval(window.fetchClientStatus, window.POLL_INTERVAL);
|
||||
setInterval(window.fetchCaptchaStatus, 5000); // check every 5s
|
||||
});
|
||||
|
||||
@@ -2,16 +2,110 @@
|
||||
// Command Form Component
|
||||
// ================================================================
|
||||
|
||||
window.onCmdTypeChange = function() {
|
||||
const type = document.getElementById('cmd-type').value;
|
||||
document.getElementById('build-options').style.display = type === 'build' ? '' : 'none';
|
||||
document.getElementById('recruit-options').style.display = type === 'recruit' ? '' : 'none';
|
||||
document.getElementById('amount-group').style.display = type === 'recruit' ? '' : 'none';
|
||||
document.getElementById('market-options').style.display = type === 'market_offer' ? '' : 'none';
|
||||
window.currentCmdType = null;
|
||||
|
||||
window.setCmdType = function(type, openModal = false) {
|
||||
window.currentCmdType = type;
|
||||
|
||||
// Update segmented control active state
|
||||
document.querySelectorAll('.seg-btn').forEach(btn => btn.classList.remove('active'));
|
||||
const activeBtn = document.getElementById('seg-' + (type === 'market_offer' ? 'market' : type));
|
||||
if (activeBtn) activeBtn.classList.add('active');
|
||||
|
||||
// Show selection area
|
||||
document.getElementById('selection-area').style.display = 'flex';
|
||||
document.getElementById('recruit-amount-wrap').style.display = type === 'recruit' ? 'flex' : 'none';
|
||||
|
||||
// Update selection label with current choice if any, or default
|
||||
window.updateSelectionDisplay();
|
||||
|
||||
if (openModal) {
|
||||
window.reopenActiveModal();
|
||||
}
|
||||
};
|
||||
|
||||
window.updateSelectionDisplay = function() {
|
||||
const type = window.currentCmdType;
|
||||
const labelEl = document.getElementById('selection-label');
|
||||
|
||||
if (type === 'build') {
|
||||
if (window.selectedBuildingId) {
|
||||
labelEl.innerHTML = `🏗️ ${window.BUILDING_NAMES_GR[window.selectedBuildingId] || window.selectedBuildingId}`;
|
||||
} else {
|
||||
labelEl.innerHTML = `-- Επιλέξτε Κατασκευή --`;
|
||||
}
|
||||
} else if (type === 'recruit') {
|
||||
if (window.selectedUnitId) {
|
||||
labelEl.innerHTML = `⚔️ ${window.UNIT_NAMES_GR[window.selectedUnitId] || window.selectedUnitId}`;
|
||||
} else {
|
||||
labelEl.innerHTML = `-- Επιλέξτε Μονάδα --`;
|
||||
}
|
||||
} else if (type === 'research') {
|
||||
if (window.selectedResearchId) {
|
||||
const rd = window.RESEARCH_DATA[window.selectedResearchId];
|
||||
labelEl.innerHTML = `🦉 ${rd ? rd.name : window.selectedResearchId}`;
|
||||
} else {
|
||||
labelEl.innerHTML = `-- Επιλέξτε Έρευνα --`;
|
||||
}
|
||||
} else if (type === 'market_offer') {
|
||||
labelEl.innerHTML = `🛒 Ρυθμίσεις Παζαριού`;
|
||||
} else if (type === 'blueprints') {
|
||||
if (window.selectedBlueprintName) {
|
||||
labelEl.innerHTML = `📜 ${window.selectedBlueprintName}`;
|
||||
} else {
|
||||
labelEl.innerHTML = `-- Επιλέξτε Blueprint --`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.reopenActiveModal = function() {
|
||||
const type = window.currentCmdType;
|
||||
if (type === 'build') window.openBuildingModal();
|
||||
else if (type === 'recruit') window.openUnitModal();
|
||||
else if (type === 'research') window.openAcademyModal();
|
||||
else if (type === 'market_offer') window.openMarketModal();
|
||||
else if (type === 'blueprints') window.openBlueprintsModal();
|
||||
};
|
||||
|
||||
window.selectedBlueprintName = null;
|
||||
window.selectBlueprint = function(name) {
|
||||
window.selectedBlueprintName = name;
|
||||
window.updateSelectionDisplay();
|
||||
window.closeBlueprintsModal();
|
||||
};
|
||||
|
||||
window.openBlueprintsModal = function() {
|
||||
document.getElementById('blueprints-modal-overlay').classList.add('open');
|
||||
};
|
||||
window.closeBlueprintsModal = function(e) {
|
||||
if (e && e.target.id !== 'blueprints-modal-overlay' && !e.target.classList.contains('modal-close')) return;
|
||||
document.getElementById('blueprints-modal-overlay').classList.remove('open');
|
||||
};
|
||||
|
||||
window.openMarketModal = function() {
|
||||
document.getElementById('market-modal-overlay').classList.add('open');
|
||||
};
|
||||
|
||||
window.closeMarketModal = function(e) {
|
||||
if (e && !e.target.classList.contains('modal-overlay') && !e.target.classList.contains('modal-close')) return;
|
||||
document.getElementById('market-modal-overlay').classList.remove('open');
|
||||
};
|
||||
|
||||
window.saveMarketModal = function() {
|
||||
const oType = document.getElementById('market-offer-type').value;
|
||||
const oAmt = document.getElementById('market-offer-amount').value;
|
||||
const dType = document.getElementById('market-demand-type').value;
|
||||
const dAmt = document.getElementById('market-demand-amount').value;
|
||||
|
||||
const labelEl = document.getElementById('selection-label');
|
||||
const resGr = { wood: 'Ξύλο', stone: 'Πέτρα', iron: 'Ασήμι' };
|
||||
labelEl.innerHTML = `🛒 ${oAmt} ${resGr[oType]} ➔ ${dAmt} ${resGr[dType]}`;
|
||||
|
||||
window.closeMarketModal();
|
||||
};
|
||||
|
||||
// Building emoji icons for the visual grid
|
||||
const BUILDING_ICONS = {
|
||||
window.BUILDING_ICONS = {
|
||||
main: '🏛️', storage: '🏚️', farm: '🌾', academy: '📜',
|
||||
temple: '⛩️', barracks: '⚔️', docks: '⚓', market: '🛒',
|
||||
hide: '🕳️', lumber: '🪵', stoner: '🪨', ironer: '⛏️', wall: '🧱'
|
||||
@@ -34,7 +128,7 @@ window.openBuildingModal = function() {
|
||||
grid.innerHTML = Object.entries(window.BUILDING_NAMES_GR).map(([key, nameGr]) => {
|
||||
const level = bLevels[key] !== undefined ? bLevels[key] : '?';
|
||||
const data = bData[key];
|
||||
const icon = BUILDING_ICONS[key] || '🏗️';
|
||||
const icon = window.BUILDING_ICONS[key] || '🏗️';
|
||||
const isSelected = key === window.selectedBuildingId;
|
||||
|
||||
if (!data) {
|
||||
@@ -117,78 +211,270 @@ window.closeBuildingModal = function(e) {
|
||||
|
||||
window.selectBuilding = function(key, nameGr) {
|
||||
window.selectedBuildingId = key;
|
||||
// Update the trigger button label
|
||||
document.getElementById('selected-building-label').textContent = `🏗️ ${nameGr}`;
|
||||
// Re-render grid to show new selection highlight
|
||||
window.updateSelectionDisplay();
|
||||
window.openBuildingModal();
|
||||
// Close after brief visual feedback
|
||||
setTimeout(() => document.getElementById('building-modal-overlay').classList.remove('open'), 180);
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// Academy Research Logic
|
||||
// ================================================================
|
||||
window.selectedResearchId = null;
|
||||
|
||||
window.RESEARCH_DATA = {
|
||||
"slinger": { "name": "Εκσφενδονιστές", "academy_level": 1, "wood": 300, "stone": 500, "iron": 200, "points": 4 },
|
||||
"archer": { "name": "Τοξότες", "academy_level": 1, "wood": 550, "stone": 100, "iron": 400, "points": 8 },
|
||||
"town_guard": { "name": "Φρουρά πόλης", "academy_level": 1, "wood": 400, "stone": 300, "iron": 300, "points": 3 },
|
||||
"hoplite": { "name": "Οπλίτες", "academy_level": 4, "wood": 600, "stone": 200, "iron": 850, "points": 8 },
|
||||
"meteorology": { "name": "Μετεωρολογία", "academy_level": 4, "wood": 2500, "stone": 1700, "iron": 6500, "points": 4 },
|
||||
"espionage": { "name": "Κατασκοπεία", "academy_level": 7, "wood": 900, "stone": 900, "iron": 1100, "points": 3 },
|
||||
"booty": { "name": "Αφοσίωση χωρικών", "academy_level": 7, "wood": 1300, "stone": 1300, "iron": 1300, "points": 6 },
|
||||
"pottery": { "name": "Κεραμικά", "academy_level": 7, "wood": 700, "stone": 1500, "iron": 900, "points": 4 },
|
||||
"rider": { "name": "Ιππείς", "academy_level": 10, "wood": 1400, "stone": 700, "iron": 1800, "points": 8 },
|
||||
"architecture": { "name": "Αρχιτεκτονική", "academy_level": 10, "wood": 1900, "stone": 2100, "iron": 1300, "points": 6 },
|
||||
"instructor": { "name": "Εκπαιδευτής", "academy_level": 10, "wood": 800, "stone": 1300, "iron": 1600, "points": 4 },
|
||||
"bireme": { "name": "Διήρεις", "academy_level": 13, "wood": 2800, "stone": 1300, "iron": 2200, "points": 8 },
|
||||
"building_crane": { "name": "Γερανός", "academy_level": 13, "wood": 3000, "stone": 1800, "iron": 1400, "points": 4 },
|
||||
"shipwright": { "name": "Ναυπηγός", "academy_level": 13, "wood": 5000, "stone": 2000, "iron": 3900, "points": 6 },
|
||||
"colonize_ship": { "name": "Αποικιακά πλοία", "academy_level": 13, "wood": 7500, "stone": 7500, "iron": 9500, "points": 0 },
|
||||
"chariot": { "name": "Άρματα", "academy_level": 16, "wood": 3700, "stone": 1900, "iron": 2800, "points": 8 },
|
||||
"attack_ship": { "name": "Πλοία-φάροι", "academy_level": 16, "wood": 4400, "stone": 2000, "iron": 2400, "points": 8 },
|
||||
"conscription": { "name": "Στρατολόγηση", "academy_level": 16, "wood": 3800, "stone": 4200, "iron": 6000, "points": 4 },
|
||||
"demolition_ship": { "name": "Πυρπολικά", "academy_level": 19, "wood": 5300, "stone": 2600, "iron": 2700, "points": 8 },
|
||||
"catapult": { "name": "Καταπέλτες", "academy_level": 19, "wood": 5500, "stone": 2900, "iron": 3600, "points": 8 },
|
||||
"cryptography": { "name": "Κρυπτογραφία", "academy_level": 19, "wood": 2500, "stone": 3000, "iron": 5100, "points": 6 },
|
||||
"small_transporter": { "name": "Γρήγορα μεταφορικά πλοία", "academy_level": 22, "wood": 6500, "stone": 2800, "iron": 3200, "points": 8 },
|
||||
"plow": { "name": "Άροτρο", "academy_level": 22, "wood": 3000, "stone": 3300, "iron": 2100, "points": 4 },
|
||||
"berth": { "name": "Κουκέτες", "academy_level": 22, "wood": 8900, "stone": 5200, "iron": 7800, "points": 6 },
|
||||
"trireme": { "name": "Τριήρεις", "academy_level": 25, "wood": 6500, "stone": 3800, "iron": 4700, "points": 8 },
|
||||
"phalanx": { "name": "Φάλαγγα", "academy_level": 25, "wood": 4000, "stone": 4000, "iron": 15000, "points": 9 },
|
||||
"breach": { "name": "Διάσπαση εχθρικού μετώπου", "academy_level": 25, "wood": 8000, "stone": 8000, "iron": 9000, "points": 6 },
|
||||
"mathematics": { "name": "Μαθηματικά", "academy_level": 25, "wood": 7100, "stone": 4400, "iron": 8600, "points": 6 },
|
||||
"ram": { "name": "Πολιορκητικός κριός", "academy_level": 28, "wood": 7900, "stone": 9200, "iron": 14000, "points": 10 },
|
||||
"cartography": { "name": "Χαρτογραφία", "academy_level": 28, "wood": 10000, "stone": 6700, "iron": 12500, "points": 8 },
|
||||
"take_over": { "name": "Κατάκτηση", "academy_level": 28, "wood": 12000, "stone": 12000, "iron": 16000, "points": 0 },
|
||||
"stone_storm": { "name": "Καταιγισμός από πέτρες", "academy_level": 31, "wood": 8500, "stone": 5900, "iron": 6600, "points": 4 },
|
||||
"temple_looting": { "name": "Λεηλασία ναού", "academy_level": 31, "wood": 9200, "stone": 5300, "iron": 10000, "points": 6 },
|
||||
"divine_selection": { "name": "Θεϊκή επιλογή", "academy_level": 31, "wood": 10000, "stone": 8000, "iron": 12000, "points": 10 },
|
||||
"combat_experience": { "name": "Εμπειρία μάχης", "academy_level": 34, "wood": 9800, "stone": 11400, "iron": 14200, "points": 6 },
|
||||
"strong_wine": { "name": "Δυνατό κρασί", "academy_level": 34, "wood": 8000, "stone": 6500, "iron": 11000, "points": 4 },
|
||||
"set_sail": { "name": "Σαλπάρισμα!", "academy_level": 34, "wood": 13000, "stone": 9700, "iron": 15500, "points": 8 }
|
||||
};
|
||||
|
||||
window.renderUnitDropdown = function() {
|
||||
window.openAcademyModal = function() {
|
||||
const town = window.getSelectedTown();
|
||||
if (!town) return;
|
||||
const uSelect = document.getElementById('unit-select');
|
||||
const uData = town.unit_data || {};
|
||||
const grid = document.getElementById('academy-grid');
|
||||
|
||||
const currentVal = uSelect.value;
|
||||
uSelect.innerHTML = '<option value="" disabled selected>-- Επιλέξτε Μονάδα --</option>';
|
||||
// Group researches by academy level
|
||||
const levels = [1, 4, 7, 10, 13, 16, 19, 22, 25, 28, 31, 34];
|
||||
const townResearches = town.researches || {};
|
||||
const townBuildings = town.buildings || {};
|
||||
const townResources = town.resources || { wood: 0, stone: 0, iron: 0 };
|
||||
const academyLvl = townBuildings.academy || 0;
|
||||
|
||||
for (const [key, nameGr] of Object.entries(window.UNIT_NAMES_GR)) {
|
||||
if (key === 'militia') continue;
|
||||
let html = '';
|
||||
for (const lvl of levels) {
|
||||
const researchesInLvl = Object.entries(window.RESEARCH_DATA).filter(([k, v]) => v.academy_level === lvl);
|
||||
if (researchesInLvl.length === 0) continue;
|
||||
|
||||
const data = uData[key];
|
||||
let text = `${nameGr}`;
|
||||
|
||||
if (data) {
|
||||
const w = window.fmt(data.wood || 0);
|
||||
const st = window.fmt(data.stone || 0);
|
||||
const i = window.fmt(data.iron || 0);
|
||||
const pop = data.pop || 0;
|
||||
html += `<div class="academy-col">
|
||||
<div class="academy-col-header">Επίπεδο ${lvl}</div>`;
|
||||
|
||||
// Unit build_time is usually raw seconds in GameData
|
||||
let t = data.build_time || 0;
|
||||
let tStr = `${t}s`;
|
||||
if (t > 60) {
|
||||
let m = Math.floor(t / 60);
|
||||
let s = t % 60;
|
||||
tStr = `${m}m ${s}s`;
|
||||
}
|
||||
for (const [key, data] of researchesInLvl) {
|
||||
const isResearched = !!townResearches[key];
|
||||
const isSelected = key === window.selectedResearchId;
|
||||
const isLocked = academyLvl < lvl;
|
||||
const noResources = townResources.wood < data.wood || townResources.stone < data.stone || townResources.iron < data.iron;
|
||||
|
||||
const costStr = `Ξ:${w} Π:${st} Α:${i} 🧔:${pop} · ⏱ ${tStr}`;
|
||||
|
||||
const missingKeys = data.missing_dependencies ? Object.keys(data.missing_dependencies) : [];
|
||||
const isLocked = missingKeys.length > 0;
|
||||
|
||||
const option = document.createElement('option');
|
||||
option.value = key;
|
||||
|
||||
if (isLocked) {
|
||||
option.textContent = `${text} — 🔒 Κλειδωμένο`;
|
||||
option.style.color = '#ff4444';
|
||||
} else if (data.enough_resources === false) {
|
||||
option.textContent = `${text} — ❌ ${costStr} (Λείπουν Πόροι 1x)`;
|
||||
option.style.color = '#aa5555';
|
||||
let statusClass, statusLabel, cardClass = '';
|
||||
if (isResearched) {
|
||||
statusClass = 'maxed'; statusLabel = '✓ Ερευνήθηκε'; cardClass = 'bld-maxed';
|
||||
} else if (isLocked) {
|
||||
statusClass = 'locked'; statusLabel = '🔒 Κλειδωμένο'; cardClass = 'bld-locked';
|
||||
} else if (noResources) {
|
||||
statusClass = 'no-resources'; statusLabel = '❌ Λείπουν Πόροι';
|
||||
} else {
|
||||
option.textContent = `${text} — ✅ ${costStr}`;
|
||||
statusClass = 'can-build'; statusLabel = '✅ Έρευνα';
|
||||
}
|
||||
|
||||
const clickable = !isResearched && !isLocked;
|
||||
const onclick = clickable ? `onclick="window.selectResearch('${key}', '${data.name}')"` : '';
|
||||
const costStr = `Ξ:${window.fmt(data.wood)} Π:${window.fmt(data.stone)} Α:${window.fmt(data.iron)}`;
|
||||
|
||||
html += `<div class="bld-card ${cardClass}${isSelected ? ' bld-selected' : ''}" ${onclick}>
|
||||
<span class="bld-name" style="margin-top:6px; font-size:0.8rem;">${data.name}</span>
|
||||
<span class="bld-status ${statusClass}">${statusLabel}</span>
|
||||
<span class="bld-cost" style="color:#a88; margin-top:4px;">Πόντοι: ${data.points}</span>
|
||||
<span class="bld-cost">${costStr}</span>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
grid.innerHTML = html;
|
||||
document.getElementById('academy-modal-overlay').classList.add('open');
|
||||
};
|
||||
|
||||
window.closeAcademyModal = function(e) {
|
||||
if (e && e.target !== document.getElementById('academy-modal-overlay') && e.target !== document.getElementById('academy-modal-close')) return;
|
||||
document.getElementById('academy-modal-overlay').classList.remove('open');
|
||||
};
|
||||
|
||||
window.selectResearch = function(key, name) {
|
||||
window.selectedResearchId = key;
|
||||
window.updateSelectionDisplay();
|
||||
window.openAcademyModal();
|
||||
setTimeout(() => document.getElementById('academy-modal-overlay').classList.remove('open'), 180);
|
||||
};
|
||||
|
||||
|
||||
|
||||
window.selectedUnitId = null;
|
||||
|
||||
window.UNIT_ICONS = {
|
||||
sword: '⚔️', slinger: '🪨', archer: '🏹', hoplite: '🛡️',
|
||||
rider: '🐎', chariot: '🛷', catapult: '☄️', godsent: '👼',
|
||||
big_transporter: '⛴️', small_transporter: '🚤', bireme: '🛶',
|
||||
attack_ship: '🔥', trireme: '🔱', colonize_ship: '⚓',
|
||||
medusa: '🐍', zyklop: '👁️', harpy: '🦅', pegasus: '🐴',
|
||||
minotaur: '🐂', manticore: '🦁', cerberus: '🐕',
|
||||
hydra: '🐉', sea_monster: '🦑', militia: '🧑🌾'
|
||||
};
|
||||
|
||||
window.renderUnitDropdown = function() {
|
||||
// No-op - selection now happens via modal
|
||||
};
|
||||
|
||||
window.openUnitModal = function() {
|
||||
const town = window.getSelectedTown();
|
||||
if (!town) return;
|
||||
const grid = document.getElementById('unit-grid');
|
||||
const uData = town.unit_data || {};
|
||||
|
||||
// Group units into categories for better display
|
||||
const categories = {
|
||||
'Ξηρά': ['sword', 'slinger', 'archer', 'hoplite', 'rider', 'chariot', 'catapult', 'godsent'],
|
||||
'Ναυτικές': ['big_transporter', 'small_transporter', 'bireme', 'attack_ship', 'trireme', 'colonize_ship'],
|
||||
'Μυθικές': ['medusa', 'zyklop', 'harpy', 'pegasus', 'minotaur', 'manticore', 'cerberus', 'hydra', 'sea_monster']
|
||||
};
|
||||
|
||||
let html = '';
|
||||
|
||||
for (const [catName, units] of Object.entries(categories)) {
|
||||
let catHtml = '';
|
||||
|
||||
for (const key of units) {
|
||||
if (key === 'militia') continue;
|
||||
|
||||
const nameGr = window.UNIT_NAMES_GR[key] || key;
|
||||
const data = uData[key];
|
||||
const icon = window.UNIT_ICONS[key] || '💂';
|
||||
const isSelected = key === window.selectedUnitId;
|
||||
|
||||
let statusClass, statusLabel, cardClass = '';
|
||||
let costStr = '';
|
||||
let clickable = false;
|
||||
|
||||
let maxBuild = 0;
|
||||
let currentCount = town.units ? (town.units[key] || 0) : 0;
|
||||
|
||||
const requiredGod = window.UNIT_GODS ? window.UNIT_GODS[key] : null;
|
||||
const isWrongGod = requiredGod && town.god !== requiredGod;
|
||||
const isNoGod = key === 'godsent' && !town.god;
|
||||
|
||||
if (isWrongGod) {
|
||||
statusClass = 'locked'; statusLabel = '🔒 Άλλος Θεός'; cardClass = 'bld-locked';
|
||||
const greekGods = { zeus: 'Δία', poseidon: 'Ποσειδώνα', hera: 'Ήρα', athena: 'Αθηνά', hades: 'Άδη', artemis: 'Άρτεμις', aphrodite: 'Αφροδίτη', ares: 'Άρη' };
|
||||
costStr = `Απαιτεί: ${greekGods[requiredGod] || requiredGod}`;
|
||||
} else if (isNoGod) {
|
||||
statusClass = 'locked'; statusLabel = '🔒 Χωρίς Θεό'; cardClass = 'bld-locked';
|
||||
costStr = `Απαιτείται Ναός`;
|
||||
} else if (data) {
|
||||
const missingKeys = data.missing_dependencies ? Object.keys(data.missing_dependencies) : [];
|
||||
const isLocked = missingKeys.length > 0;
|
||||
|
||||
const w = window.fmt(data.wood || 0);
|
||||
const st = window.fmt(data.stone || 0);
|
||||
const i = window.fmt(data.iron || 0);
|
||||
const pop = data.pop || 0;
|
||||
|
||||
// Calculate max buildable
|
||||
if (!isLocked) {
|
||||
const tRes = town.resources || {};
|
||||
const mWood = data.wood ? Math.floor((tRes.wood || 0) / data.wood) : Infinity;
|
||||
const mStone = data.stone ? Math.floor((tRes.stone || 0) / data.stone) : Infinity;
|
||||
const mIron = data.iron ? Math.floor((tRes.iron || 0) / data.iron) : Infinity;
|
||||
const mPop = data.pop ? Math.floor((tRes.population || 0) / data.pop) : Infinity;
|
||||
const mFavor = data.favor ? Math.floor((tRes.favor || 0) / data.favor) : Infinity;
|
||||
|
||||
maxBuild = Math.min(mWood, mStone, mIron, mPop, mFavor);
|
||||
if (maxBuild === Infinity) maxBuild = 0;
|
||||
}
|
||||
|
||||
let t = data.build_time || 0;
|
||||
let tStr = `${t}s`;
|
||||
if (t > 60) {
|
||||
let m = Math.floor(t / 60);
|
||||
let s = t % 60;
|
||||
tStr = `${m}m ${s}s`;
|
||||
}
|
||||
|
||||
// Show favor if it's a mythical unit or godsent
|
||||
const favorStr = (data.favor && data.favor > 0) ? ` ⚡:${data.favor}` : '';
|
||||
costStr = `Ξ:${w} Π:${st} Α:${i}${favorStr} 🧔:${pop} · ⏱ ${tStr}`;
|
||||
|
||||
if (isLocked) {
|
||||
statusClass = 'locked'; statusLabel = '🔒 Κλειδωμένο'; cardClass = 'bld-locked';
|
||||
} else if (maxBuild <= 0) {
|
||||
statusClass = 'no-resources'; statusLabel = '❌ Λείπουν Πόροι';
|
||||
} else {
|
||||
statusClass = 'can-build'; statusLabel = '✅ Διαθέσιμο';
|
||||
clickable = true;
|
||||
}
|
||||
} else {
|
||||
// If no data is available for this unit, treat it as locked/unknown
|
||||
statusClass = 'locked'; statusLabel = '🔒 Άγνωστο'; cardClass = 'bld-locked';
|
||||
}
|
||||
|
||||
uSelect.appendChild(option);
|
||||
} else {
|
||||
const option = document.createElement('option');
|
||||
option.value = key;
|
||||
option.textContent = text;
|
||||
uSelect.appendChild(option);
|
||||
const onclick = clickable ? `onclick="window.selectUnit('${key}', '${nameGr}')"` : '';
|
||||
|
||||
catHtml += `<div class="bld-card ${cardClass}${isSelected ? ' bld-selected' : ''}" ${onclick} style="width:140px; justify-content:flex-start; position:relative;">
|
||||
<span class="bld-icon" style="font-size:2rem; margin-bottom: 2px;">${icon}</span>
|
||||
<div style="position:absolute; top:4px; right:6px; font-weight:bold; font-size:1.1rem; text-shadow: 1px 1px 2px #000; color:#fff;">${currentCount}</div>
|
||||
<div style="position:absolute; top:24px; right:6px; font-weight:bold; font-size:0.85rem; color:#c8a44a; text-shadow: 1px 1px 2px #000;">+${maxBuild}</div>
|
||||
<span class="bld-name" style="margin-top:2px; font-size:0.85rem;">${nameGr}</span>
|
||||
<span class="bld-status ${statusClass}" style="margin:2px 0;">${statusLabel}</span>
|
||||
<span class="bld-cost" style="font-size:0.65rem; color:#bbb; text-align:center;">${costStr}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (catHtml) {
|
||||
html += `<div style="width:100%; margin-top:10px;"><h4 style="color:#c8a44a; margin-bottom:10px; border-bottom:1px solid #2a4a6a; padding-bottom:4px;">${catName}</h4>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:10px;">${catHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentVal && Array.from(uSelect.options).some(o => o.value === currentVal)) {
|
||||
uSelect.value = currentVal;
|
||||
}
|
||||
grid.innerHTML = html;
|
||||
document.getElementById('unit-modal-overlay').classList.add('open');
|
||||
};
|
||||
|
||||
window.closeUnitModal = function(e) {
|
||||
if (e && e.target !== document.getElementById('unit-modal-overlay') && e.target !== document.getElementById('unit-modal-close')) return;
|
||||
document.getElementById('unit-modal-overlay').classList.remove('open');
|
||||
};
|
||||
|
||||
window.selectUnit = function(key, nameGr) {
|
||||
window.selectedUnitId = key;
|
||||
window.updateSelectionDisplay();
|
||||
window.openUnitModal();
|
||||
setTimeout(() => document.getElementById('unit-modal-overlay').classList.remove('open'), 180);
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
|
||||
|
||||
window.renderBuildQueuePreview = function() {
|
||||
const town = window.getSelectedTown();
|
||||
const el = document.getElementById('build-queue-preview');
|
||||
@@ -203,3 +489,27 @@ window.renderBuildQueuePreview = function() {
|
||||
}).join('');
|
||||
el.innerHTML = `<div style="margin-top:6px;color:#888;font-size:0.72rem;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Current queue</div>${items}`;
|
||||
};
|
||||
|
||||
window.renderUnitQueuePreview = function() {
|
||||
const town = window.getSelectedTown();
|
||||
const el = document.getElementById('unit-queue-preview');
|
||||
if (!el) return;
|
||||
|
||||
if (!town || !town.unit_queue || !town.unit_queue.length) {
|
||||
el.innerHTML = '<span style="color:#444">Recruit queue: empty</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const items = town.unit_queue.map(o => {
|
||||
const raw = o.unit_type || o.unit_id || 'unknown';
|
||||
const nameGr = window.UNIT_NAMES_GR ? (window.UNIT_NAMES_GR[raw] || raw) : raw;
|
||||
const total = o.count || 0;
|
||||
const left = o.units_left !== undefined ? o.units_left : total;
|
||||
const countStr = left < total ? `${left}/${total}` : `${total}`;
|
||||
return `<span style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; margin-right: 4px; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 4px;">
|
||||
<span style="color:#fff; font-weight:bold;">${countStr}x</span> ${nameGr}
|
||||
</span>`;
|
||||
}).join('');
|
||||
|
||||
el.innerHTML = `<div style="margin-top:6px;color:#888;font-size:0.72rem;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Recruitment queue</div>${items}`;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,171 @@
|
||||
// ================================================================
|
||||
// Command Log Component
|
||||
// Command Log & Build Queue Component
|
||||
// ================================================================
|
||||
|
||||
// -- Panel state: 'queue' | 'log' ----------------------------
|
||||
window._logPanelMode = 'queue';
|
||||
|
||||
// ---- Toggle buttons -------------------------------------------
|
||||
window.switchToQueueMode = function() {
|
||||
window._logPanelMode = 'queue';
|
||||
document.getElementById('tab-queue').classList.add('tab-active');
|
||||
document.getElementById('tab-log').classList.remove('tab-active');
|
||||
window.refreshLogPanel();
|
||||
};
|
||||
|
||||
window.switchToLogMode = function() {
|
||||
window._logPanelMode = 'log';
|
||||
document.getElementById('tab-log').classList.add('tab-active');
|
||||
document.getElementById('tab-queue').classList.remove('tab-active');
|
||||
window.fetchLog();
|
||||
};
|
||||
|
||||
// ---- Main dispatcher ------------------------------------------
|
||||
window.refreshLogPanel = function() {
|
||||
if (window._logPanelMode === 'queue') {
|
||||
const town = window.getSelectedTown();
|
||||
if (town) {
|
||||
window.fetchBuildQueue(town.town_id);
|
||||
} else {
|
||||
document.getElementById('log-content').innerHTML =
|
||||
'<p style="color:#555;font-size:0.85rem;padding:12px 0;">← Επιλέξτε πόλη για να δείτε την ουρά.</p>';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// BUILD QUEUE (per-town, draggable)
|
||||
// ================================================================
|
||||
window.fetchBuildQueue = async function(townId) {
|
||||
if (window._logPanelMode !== 'queue') return;
|
||||
try {
|
||||
const res = await fetch(`/dashboard/commands/queue?player_id=${window.PLAYER_ID}&town_id=${encodeURIComponent(townId)}`);
|
||||
const cmds = await res.json();
|
||||
window.renderBuildQueue(cmds, townId);
|
||||
} catch(e) {}
|
||||
};
|
||||
|
||||
// Drag state
|
||||
let _dragSrcIdx = null;
|
||||
|
||||
window.renderBuildQueue = function(cmds, townId) {
|
||||
const el = document.getElementById('log-content');
|
||||
|
||||
if (!cmds || cmds.length === 0) {
|
||||
el.innerHTML = `
|
||||
<div style="text-align:center;padding:2rem 1rem;color:#444;">
|
||||
<div style="font-size:2rem;margin-bottom:0.5rem;">🏗️</div>
|
||||
<p style="font-size:0.85rem;">Η ουρά κατασκευών είναι κενή.</p>
|
||||
<p style="font-size:0.75rem;color:#333;margin-top:0.3rem;">Χρησιμοποιήστε την φόρμα για να προσθέσετε κατασκευές.</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = cmds.map((cmd, idx) => {
|
||||
const p = typeof cmd.payload === 'string' ? JSON.parse(cmd.payload) : cmd.payload;
|
||||
const nameGr = window.BUILDING_NAMES_GR?.[p.building_id] || p.building_id || '?';
|
||||
const icon = window.BUILDING_ICONS?.[p.building_id] || '🏗️';
|
||||
const isExec = cmd.status === 'executing';
|
||||
|
||||
const statusDot = isExec
|
||||
? `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#4acc64;box-shadow:0 0 5px #4acc64;flex-shrink:0;" title="Εκτελείται"></span>`
|
||||
: `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:#555;flex-shrink:0;" title="Σε αναμονή"></span>`;
|
||||
|
||||
return `
|
||||
<div class="bq-row" draggable="true"
|
||||
data-idx="${idx}" data-id="${cmd.id}" data-town="${townId}"
|
||||
ondragstart="window._bqDragStart(event,${idx})"
|
||||
ondragover="window._bqDragOver(event)"
|
||||
ondrop="window._bqDrop(event,${idx},'${townId}')"
|
||||
ondragend="window._bqDragEnd(event)">
|
||||
<span class="bq-handle" title="Σύρε για αναδιάταξη">⠿</span>
|
||||
<span class="bq-pos">${idx + 1}</span>
|
||||
${statusDot}
|
||||
<span class="bq-icon">${icon}</span>
|
||||
<span class="bq-name">${nameGr}</span>
|
||||
<button class="bq-cancel-btn" onclick="window._bqCancel(${cmd.id})" title="Ακύρωση">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
el.innerHTML = `<div id="bq-list">${rows}</div>`;
|
||||
};
|
||||
|
||||
// ---- Drag-and-drop handlers -----------------------------------
|
||||
window._bqDragStart = function(e, idx) {
|
||||
_dragSrcIdx = idx;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
setTimeout(() => {
|
||||
const rows = document.querySelectorAll('.bq-row');
|
||||
if (rows[idx]) rows[idx].style.opacity = '0.4';
|
||||
}, 0);
|
||||
};
|
||||
|
||||
window._bqDragOver = function(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
// Highlight target row
|
||||
document.querySelectorAll('.bq-row').forEach(r => r.classList.remove('bq-drag-over'));
|
||||
const row = e.currentTarget;
|
||||
if (row) row.classList.add('bq-drag-over');
|
||||
};
|
||||
|
||||
window._bqDrop = function(e, targetIdx, townId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (_dragSrcIdx === null || _dragSrcIdx === targetIdx) return;
|
||||
|
||||
// Re-order the DOM
|
||||
const list = document.getElementById('bq-list');
|
||||
const rows = Array.from(list.querySelectorAll('.bq-row'));
|
||||
const movedRow = rows.splice(_dragSrcIdx, 1)[0];
|
||||
rows.splice(targetIdx, 0, movedRow);
|
||||
|
||||
// Update numbering & opacity
|
||||
rows.forEach((r, i) => {
|
||||
r.style.opacity = '1';
|
||||
r.classList.remove('bq-drag-over');
|
||||
r.dataset.idx = i;
|
||||
r.querySelector('.bq-pos').textContent = i + 1;
|
||||
r.setAttribute('ondragstart', `window._bqDragStart(event,${i})`);
|
||||
r.setAttribute('ondrop', `window._bqDrop(event,${i},'${townId}')`);
|
||||
});
|
||||
list.innerHTML = '';
|
||||
rows.forEach(r => list.appendChild(r));
|
||||
|
||||
// Persist new order to server
|
||||
const orderedIds = rows.map(r => parseInt(r.dataset.id));
|
||||
fetch('/dashboard/commands/reorder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ player_id: window.PLAYER_ID, town_id: townId, order: orderedIds })
|
||||
});
|
||||
|
||||
_dragSrcIdx = null;
|
||||
};
|
||||
|
||||
window._bqDragEnd = function(e) {
|
||||
document.querySelectorAll('.bq-row').forEach(r => {
|
||||
r.style.opacity = '1';
|
||||
r.classList.remove('bq-drag-over');
|
||||
});
|
||||
_dragSrcIdx = null;
|
||||
};
|
||||
|
||||
window._bqCancel = async function(id) {
|
||||
await fetch(`/dashboard/commands/${id}`, { method: 'DELETE' });
|
||||
// Refresh the queue for the currently selected town
|
||||
const town = window.getSelectedTown();
|
||||
if (town) window.fetchBuildQueue(town.town_id);
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// COMMAND LOG (full history, existing behaviour)
|
||||
// ================================================================
|
||||
window.renderLog = function(cmds) {
|
||||
if (window._logPanelMode !== 'log') return;
|
||||
const el = document.getElementById('log-content');
|
||||
if (!cmds.length) {
|
||||
el.innerHTML = '<p id="empty-log">No commands sent yet.</p>';
|
||||
el.innerHTML = '<p id="empty-log" style="color:#555;font-size:0.85rem;padding:12px 0;">No commands sent yet.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -20,12 +180,13 @@ window.renderLog = function(cmds) {
|
||||
desc = `Recruit: ${p.amount}x ${nameGr}`;
|
||||
} else if (cmd.type === 'market_offer') {
|
||||
desc = `Market: ${p.offer} ${p.offer_type} ➞ ${p.demand} ${p.demand_type}`;
|
||||
} else {
|
||||
desc = cmd.type;
|
||||
}
|
||||
const statusClass = `status-${cmd.status}`;
|
||||
const cancelBtn = `<button class="btn btn-danger btn-sm" onclick="cancelCommand(${cmd.id})">✕</button>`;
|
||||
|
||||
const timeStr = new Date(cmd.created_at + 'Z').toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
|
||||
|
||||
|
||||
return `<tr>
|
||||
<td style="color:#888;font-size:0.75rem">#${cmd.id}<br><span style="font-size:0.65rem;color:#555;">${timeStr}</span></td>
|
||||
<td>${cmd.town_name || cmd.town_id}</td>
|
||||
|
||||
@@ -12,8 +12,7 @@ window.renderTowns = function() {
|
||||
// Get active filters
|
||||
const searchTerm = (document.getElementById('town-search')?.value || '').toLowerCase();
|
||||
const reqFullWh = document.getElementById('filter-full-wh')?.checked;
|
||||
const reqFestival = document.getElementById('filter-festival')?.checked;
|
||||
const reqPoints = document.getElementById('filter-points')?.checked;
|
||||
const reqNotBuilding = document.getElementById('filter-not-building')?.checked;
|
||||
|
||||
const filteredTowns = window.towns.filter(t => {
|
||||
// 1. Search by name
|
||||
@@ -29,13 +28,8 @@ window.renderTowns = function() {
|
||||
// 2. Full WH (>95%)
|
||||
if (reqFullWh && Math.max(wPct, sPct, iPct) < 0.95) return false;
|
||||
|
||||
// 3. Festival (Wood>15k, Stone>18k, Iron>15k)
|
||||
// City Festival exact costs = 15000, 18000, 15000
|
||||
if (reqFestival && ((res.wood || 0) < 15000 || (res.stone || 0) < 18000 || (res.iron || 0) < 15000)) return false;
|
||||
|
||||
// 4. Large Points
|
||||
const pts = typeof t.points === 'number' ? t.points : parseInt(t.points) || 0;
|
||||
if (reqPoints && pts < 10000) return false;
|
||||
// 3. Not Building (no items in build_queue)
|
||||
if (reqNotBuilding && t.build_queue && t.build_queue.length > 0) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
@@ -62,9 +56,6 @@ window.renderTowns = function() {
|
||||
if (wPct >= 0.95 || sPct >= 0.95 || iPct >= 0.95) {
|
||||
markers += '<span title="Γεμάτη Αποθήκη!" style="margin-right:4px;">⚠️</span>';
|
||||
}
|
||||
if ((res.wood || 0) >= 15000 && (res.stone || 0) >= 18000 && (res.iron || 0) >= 15000) {
|
||||
markers += '<span title="Αρκετοί πόροι για Φεστιβάλ!" style="margin-right:4px;">🎭</span>';
|
||||
}
|
||||
|
||||
const getC = (pct) => pct >= 0.95 ? 'color:#ff4a4a;font-weight:bold;' : '';
|
||||
|
||||
@@ -96,9 +87,14 @@ window.selectTown = function(id) {
|
||||
document.getElementById('town-details-panel').style.display = 'block';
|
||||
|
||||
window.renderBuildQueuePreview();
|
||||
window.renderUnitQueuePreview();
|
||||
window.renderBuildingDropdown();
|
||||
window.renderUnitDropdown();
|
||||
window.renderTownDetails();
|
||||
// Refresh build queue panel for the newly selected town
|
||||
if (window._logPanelMode === 'queue') {
|
||||
window.fetchBuildQueue(id);
|
||||
}
|
||||
};
|
||||
|
||||
window.getSelectedTown = function() {
|
||||
@@ -169,6 +165,8 @@ window.renderTownDetails = function() {
|
||||
document.getElementById('td-market').innerHTML = mCap > 0
|
||||
? `📦 Εμπορική Χωρητικότητα: <strong>${window.fmt(mCap)}</strong>`
|
||||
: '';
|
||||
const mCapLabel = document.getElementById('market-capacity-label');
|
||||
if (mCapLabel) mCapLabel.textContent = `Χωρητικότητα: ${window.fmt(mCap)}`;
|
||||
|
||||
const godName = t.god ? t.god.charAt(0).toUpperCase() + t.god.slice(1) : 'Κανένας';
|
||||
const seaStr = t.sea != null ? `Θ${t.sea}` : '—';
|
||||
@@ -181,12 +179,18 @@ window.renderTownDetails = function() {
|
||||
allianceHtml = `<div>Συμμαχία: ID <strong style="color:#aaa">${t.alliance_id}</strong></div>`;
|
||||
}
|
||||
|
||||
let bp = t.battle_points || { att: 0, def: 0, used: 0, available: 0 };
|
||||
let bpTotal = (bp.att || 0) + (bp.def || 0);
|
||||
let bpHtml = `<div>Πόντοι Μάχης: <strong style="color:#2ecc71">${bp.available}</strong><span style="color:#666;font-size:0.8rem"> / ${bpTotal} (⚔${bp.att||0} 🛡${bp.def||0})</span></div>`;
|
||||
|
||||
document.getElementById('td-general').innerHTML = `
|
||||
<div>Πόντοι: <strong>${t.points}</strong>${t.wonder_points ? ` / Θαύμα: <strong>${t.wonder_points}</strong>` : ''}</div>
|
||||
<div>Θεός: <strong>${godName}</strong></div>
|
||||
<div>Θάλασσα: <strong style="color:#6fcfcf">${seaStr}</strong><span style="color:#666;font-size:0.72rem">${coordStr}</span></div>
|
||||
<div>Παίκτης: <strong style="color:#aaa">${t.player}</strong>${t.has_premium ? ' <span style="color:#c8a44a" title="Premium">★</span>' : ''}</div>
|
||||
${allianceHtml}
|
||||
${bpHtml}
|
||||
${t.god ? `<div>Εύνοια: <strong style="color:#f1c40f">${t.resources?.favor || 0}</strong></div>` : ''}
|
||||
`;
|
||||
|
||||
// ---- Researches ----
|
||||
@@ -215,4 +219,43 @@ window.renderTownDetails = function() {
|
||||
if(unitsHtml === '') unitsHtml = '<div style="color:#666">Κανένα στράτευμα</div>';
|
||||
|
||||
document.getElementById('td-units').innerHTML = unitsHtml;
|
||||
|
||||
// ---- Blueprint Lock & Indicator ----
|
||||
const isBlueprintActive = !!t.blueprint_active;
|
||||
const bpName = t.blueprint_name || 'Standard Growth';
|
||||
|
||||
if (isBlueprintActive) {
|
||||
document.getElementById('td-general').innerHTML += `
|
||||
<div style="margin-top:10px; padding: 4px 8px; background: rgba(200,164,74,0.15); border-left: 3px solid #c8a44a; border-radius:4px; font-size:0.8rem;">
|
||||
🤖 Blueprint: <strong style="color:#c8a44a">${bpName}</strong> <span style="color:#2ecc71">(ΕΝΕΡΓΟ)</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const btnBuild = document.getElementById('seg-build');
|
||||
const btnResearch = document.getElementById('seg-research');
|
||||
|
||||
if (btnBuild && btnResearch) {
|
||||
if (isBlueprintActive) {
|
||||
btnBuild.style.opacity = '0.3';
|
||||
btnBuild.style.pointerEvents = 'none';
|
||||
btnBuild.title = 'Απενεργοποιημένο λόγω Blueprint';
|
||||
|
||||
btnResearch.style.opacity = '0.3';
|
||||
btnResearch.style.pointerEvents = 'none';
|
||||
btnResearch.title = 'Απενεργοποιημένο λόγω Blueprint';
|
||||
|
||||
if (window.currentCmdType === 'build' || window.currentCmdType === 'research') {
|
||||
window.setCmdType('recruit');
|
||||
}
|
||||
} else {
|
||||
btnBuild.style.opacity = '1';
|
||||
btnBuild.style.pointerEvents = 'auto';
|
||||
btnBuild.title = '';
|
||||
|
||||
btnResearch.style.opacity = '1';
|
||||
btnResearch.style.pointerEvents = 'auto';
|
||||
btnResearch.title = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -24,8 +24,8 @@ window.BUILDING_NAMES_GR = {
|
||||
};
|
||||
|
||||
window.UNIT_NAMES_GR = {
|
||||
sword: "Ξιφομάχος", slinger: "Σφενδονήτης", archer: "Τοξότης", hoplite: "Οπλίτης",
|
||||
rider: "Ιππέας", chariot: "Άρμα", catapult: "Καταπέλτης",
|
||||
sword: "Ξιφομάχος", slinger: "Εκσφενδονιστής", archer: "Τοξότης", hoplite: "Οπλίτης",
|
||||
rider: "Ιππέας", chariot: "Άρμα", catapult: "Καταπέλτης", godsent: "Θεόσταλτος",
|
||||
big_transporter: "Μεταφορικό", small_transporter: "Γρήγ. Μεταφορικό", bireme: "Διήρης",
|
||||
attack_ship: "Πλοίο Φάρος", trireme: "Τριήρης", colonize_ship: "Αποικιακό",
|
||||
medusa: "Μέδουσα", zyklop: "Κύκλωπας", harpy: "Άρπυια", pegasus: "Πήγασος",
|
||||
@@ -33,6 +33,17 @@ window.UNIT_NAMES_GR = {
|
||||
hydra: "Ύδρα", sea_monster: "Τέρας Θάλασσας", militia: "Εθνοφρουρά"
|
||||
};
|
||||
|
||||
window.UNIT_GODS = {
|
||||
minotaur: 'zeus', manticore: 'zeus',
|
||||
zyklop: 'poseidon', hydra: 'poseidon',
|
||||
harpy: 'hera', medusa: 'hera',
|
||||
pegasus: 'athena', centaur: 'athena',
|
||||
cerberus: 'hades', erinys: 'hades',
|
||||
griffon: 'artemis', calydonian_boar: 'artemis',
|
||||
siren: 'aphrodite', satyr: 'aphrodite',
|
||||
spartoi: 'ares', ladon: 'ares'
|
||||
};
|
||||
|
||||
window.RES_ICONS = {
|
||||
wood: '<span class="res-icon res-wood" style="display:inline-block; margin-right:4px;"></span>',
|
||||
stone: '<span class="res-icon res-stone" style="display:inline-block; margin-right:4px;"></span>',
|
||||
|
||||
768
templates/agora.html
Normal file
768
templates/agora.html
Normal file
@@ -0,0 +1,768 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="el">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Αγορά — Grepolis Remote</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0f0f1a;
|
||||
--panel: #181824;
|
||||
--border: #2a2a3e;
|
||||
--purple: #b482dc;
|
||||
--purple-dim: rgba(180,130,220,0.12);
|
||||
--gold: #c8a44a;
|
||||
--green: #4acc64;
|
||||
--red: #e05555;
|
||||
--yellow: #e0b847;
|
||||
--text: #e0e0e0;
|
||||
--muted: #666;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
font-family: 'Inter', sans-serif;
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Top bar ── */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 1.5rem;
|
||||
background: #13131f;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topbar-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 800;
|
||||
color: var(--purple);
|
||||
flex: 1;
|
||||
}
|
||||
.topbar a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
transition: color .2s;
|
||||
}
|
||||
.topbar a:hover { color: var(--text); }
|
||||
.status-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.status-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
|
||||
/* ── Main layout ── */
|
||||
.main {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
height: calc(100vh - 52px);
|
||||
}
|
||||
|
||||
/* ── Left panel ── */
|
||||
.left-panel {
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
.panel-header {
|
||||
padding: 1rem 1.2rem 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.search-box {
|
||||
padding: 0.7rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
background: #1e1e2e;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
color: var(--text);
|
||||
font-size: 0.82rem;
|
||||
outline: none;
|
||||
transition: border-color .2s;
|
||||
}
|
||||
.search-box input:focus { border-color: var(--purple); }
|
||||
|
||||
.town-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.town-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.65rem 1.1rem;
|
||||
border-bottom: 1px solid #1e1e2e;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
position: relative;
|
||||
}
|
||||
.town-row:hover { background: #1e1e30; }
|
||||
.town-row.active { background: var(--purple-dim); border-left: 3px solid var(--purple); }
|
||||
.town-row .t-name { font-size: 0.84rem; font-weight: 600; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.town-row .t-dots { display: flex; gap: 4px; }
|
||||
.t-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
}
|
||||
.t-dot.available { background: var(--green); }
|
||||
.t-dot.cooldown { background: var(--yellow); }
|
||||
.t-dot.unavailable { background: #333; }
|
||||
|
||||
/* ── Right panel ── */
|
||||
.right-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cards-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.2rem;
|
||||
align-content: start;
|
||||
}
|
||||
.no-selection {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
/* ── Celebration cards ── */
|
||||
.cel-card {
|
||||
background: #1e1e30;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 1.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
transition: border-color .2s;
|
||||
}
|
||||
.cel-card.available { border-color: rgba(180,130,220,0.35); }
|
||||
.cel-card.cooldown { border-color: rgba(224,184,71,0.35); }
|
||||
.cel-card-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--purple);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.cel-card.cooldown .cel-card-title { color: var(--yellow); }
|
||||
|
||||
.cost-row {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cost-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cost-item.ok { color: var(--green); }
|
||||
.cost-item.bad { color: var(--red); }
|
||||
.cost-item.neutral { color: var(--text); }
|
||||
|
||||
.reason-box {
|
||||
font-size: 0.78rem;
|
||||
color: var(--yellow);
|
||||
padding: 6px 10px;
|
||||
background: rgba(224,184,71,0.08);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(224,184,71,0.2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.reason-box.error {
|
||||
color: var(--red);
|
||||
background: rgba(224,85,85,0.08);
|
||||
border-color: rgba(224,85,85,0.2);
|
||||
}
|
||||
|
||||
.cel-btn {
|
||||
padding: 9px 16px;
|
||||
border-radius: 9px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
transition: all .2s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.cel-btn.primary {
|
||||
background: var(--purple);
|
||||
color: #fff;
|
||||
}
|
||||
.cel-btn.primary:hover { background: #c99ef0; transform: translateY(-1px); }
|
||||
.cel-btn:disabled {
|
||||
background: #2a2a3e;
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ── Log panel ── */
|
||||
.log-panel {
|
||||
border-top: 1px solid var(--border);
|
||||
background: #13131f;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.log-header span { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
|
||||
.log-body {
|
||||
overflow-y: auto;
|
||||
max-height: 170px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.77rem;
|
||||
display: none;
|
||||
}
|
||||
.log-body.open { display: block; }
|
||||
.log-entry {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px solid #1a1a28;
|
||||
align-items: center;
|
||||
}
|
||||
.log-time { color: var(--muted); min-width: 55px; }
|
||||
.log-town { color: var(--purple); min-width: 140px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.log-type { color: var(--text); min-width: 150px; }
|
||||
.log-status.success { color: var(--green); }
|
||||
.log-status.failed { color: var(--red); }
|
||||
.log-status.pending { color: var(--yellow); }
|
||||
.log-msg { color: var(--muted); font-size: 0.72rem; flex: 1; }
|
||||
.log-empty { color: var(--muted); padding: 0.6rem 0; font-size: 0.8rem; }
|
||||
|
||||
/* ── Confirm Modal ── */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
z-index: 100;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal-box {
|
||||
background: #1e1e30;
|
||||
border: 1px solid var(--purple);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
min-width: 340px;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 20px 60px rgba(180,130,220,0.2);
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
color: var(--purple);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.modal-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-row span:first-child { color: var(--muted); }
|
||||
.modal-row span:last-child { font-weight: 600; }
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
margin-top: 1.4rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.btn-cancel {
|
||||
padding: 8px 18px;
|
||||
border-radius: 8px;
|
||||
background: #2a2a3e;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-cancel:hover { border-color: var(--purple); }
|
||||
.btn-confirm {
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
background: var(--purple);
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
transition: background .2s;
|
||||
}
|
||||
.btn-confirm:hover { background: #c99ef0; }
|
||||
.btn-confirm:disabled { background: #444; color: var(--muted); cursor: not-allowed; }
|
||||
|
||||
/* ── Auto toggle ── */
|
||||
.auto-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.auto-toggle button {
|
||||
padding: 5px 12px;
|
||||
border: none;
|
||||
background: #1e1e30;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all .2s;
|
||||
}
|
||||
.auto-toggle button.active-manual { background: #2a2a40; color: var(--text); }
|
||||
.auto-toggle button.active-auto { background: rgba(180,130,220,0.25); color: var(--purple); }
|
||||
.cel-card.auto-on { border-color: rgba(180,130,220,0.6); box-shadow: 0 0 12px rgba(180,130,220,0.08); }
|
||||
.auto-badge {
|
||||
font-size: 0.7rem;
|
||||
background: rgba(180,130,220,0.15);
|
||||
color: var(--purple);
|
||||
border: 1px solid rgba(180,130,220,0.3);
|
||||
border-radius: 6px;
|
||||
padding: 2px 7px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
padding: 10px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
z-index: 200;
|
||||
opacity: 0;
|
||||
transition: opacity .3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
.toast.ok { background: rgba(74,204,100,0.15); border: 1px solid var(--green); color: var(--green); }
|
||||
.toast.err { background: rgba(224,85,85,0.15); border: 1px solid var(--red); color: var(--red); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Top Bar -->
|
||||
<div class="topbar">
|
||||
<div class="topbar-title">🎭 Αγορά</div>
|
||||
<span id="online-indicator"><span class="status-dot" id="status-dot"></span><span id="status-text">Φόρτωση...</span></span>
|
||||
<a href="/player/{{ player_id }}/{{ world_id }}">← Hub</a>
|
||||
</div>
|
||||
|
||||
<!-- Main Layout -->
|
||||
<div class="main">
|
||||
|
||||
<!-- Left: Town List -->
|
||||
<div class="left-panel">
|
||||
<div class="panel-header">Πόλεις (<span id="town-count">0</span>)</div>
|
||||
<div class="search-box">
|
||||
<input type="text" id="search" placeholder="Αναζήτηση πόλης...">
|
||||
</div>
|
||||
<div class="town-list" id="town-list">
|
||||
<div style="padding:1.5rem;color:#555;font-size:0.82rem">Φόρτωση...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Cards + Log -->
|
||||
<div class="right-panel">
|
||||
|
||||
<div class="cards-area" id="cards-area">
|
||||
<div class="no-selection">⬅ Επέλεξε πόλη για να δεις τις διαθέσιμες εορτές</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Panel -->
|
||||
<div class="log-panel">
|
||||
<div class="log-header" onclick="toggleLog()">
|
||||
<span>📜 Αρχείο Αγοράς</span>
|
||||
<span id="log-toggle-icon">▲</span>
|
||||
</div>
|
||||
<div class="log-body open" id="log-body">
|
||||
<div class="log-empty" id="log-empty">Δεν υπάρχουν εγγραφές ακόμα.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Modal -->
|
||||
<div class="modal-overlay" id="confirm-modal">
|
||||
<div class="modal-box">
|
||||
<div class="modal-title" id="modal-title">Επιβεβαίωση Εορτής</div>
|
||||
<div id="modal-rows"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" onclick="closeModal()">Ακύρωση</button>
|
||||
<button class="btn-confirm" id="btn-confirm" onclick="fireCommand()">⚡ Εκτέλεση</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const PLAYER_ID = '{{ player_id }}';
|
||||
const WORLD_ID = '{{ world_id }}';
|
||||
|
||||
const TYPE_LABELS = { party: 'Γιορτή πόλης 🎉', triumph: 'Παρέλαση θριάμβου ⚔️' };
|
||||
const PARTY_COSTS = { wood: 15000, stone: 18000, iron: 15000 };
|
||||
|
||||
let agoraData = [];
|
||||
let selectedId = null;
|
||||
let pendingCmd = null; // { town, cel_type }
|
||||
let logOpen = true;
|
||||
|
||||
// ── Fetch agora data ─────────────────────────────────────────────
|
||||
async function fetchAgora() {
|
||||
try {
|
||||
const r = await fetch(`/dashboard/agora?player_id=${PLAYER_ID}&world_id=${WORLD_ID}`);
|
||||
const d = await r.json();
|
||||
agoraData = d.towns || [];
|
||||
document.getElementById('town-count').textContent = agoraData.length;
|
||||
renderTownList();
|
||||
if (selectedId) renderCards(agoraData.find(t => t.town_id === selectedId));
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
// ── Render town list ─────────────────────────────────────────────
|
||||
function renderTownList() {
|
||||
const q = document.getElementById('search').value.toLowerCase();
|
||||
const list = document.getElementById('town-list');
|
||||
const filtered = agoraData.filter(t => t.town_name.toLowerCase().includes(q));
|
||||
|
||||
if (!filtered.length) {
|
||||
list.innerHTML = '<div style="padding:1rem;color:#555;font-size:0.82rem">Δεν βρέθηκαν πόλεις.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = filtered.map(t => `
|
||||
<div class="town-row ${t.town_id === selectedId ? 'active' : ''}"
|
||||
onclick="selectTown('${t.town_id}')">
|
||||
<span class="t-name">${t.town_name}</span>
|
||||
<span class="t-dots">
|
||||
<span class="t-dot ${t.party.status}" title="Γιορτή πόλης: ${t.party.status}"></span>
|
||||
<span class="t-dot ${t.triumph.status}" title="Παρέλαση: ${t.triumph.status}"></span>
|
||||
</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function selectTown(townId) {
|
||||
selectedId = townId;
|
||||
renderTownList();
|
||||
renderCards(agoraData.find(t => t.town_id === townId));
|
||||
}
|
||||
|
||||
// ── Render celebration cards ─────────────────────────────────────
|
||||
function renderCards(town) {
|
||||
const area = document.getElementById('cards-area');
|
||||
if (!town) {
|
||||
area.innerHTML = '<div class="no-selection">⬅ Επέλεξε πόλη για να δεις τις διαθέσιμες εορτές</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
area.innerHTML = `
|
||||
${celebCard(town, 'party')}
|
||||
${celebCard(town, 'triumph')}
|
||||
`;
|
||||
}
|
||||
|
||||
function celebCard(town, type) {
|
||||
const cel = town[type];
|
||||
const label = TYPE_LABELS[type];
|
||||
const autoKey = type === 'party' ? 'auto_party' : 'auto_triumph';
|
||||
const autoOn = !!town[autoKey];
|
||||
|
||||
let costsHtml = '';
|
||||
if (type === 'party') {
|
||||
const r = town.resources;
|
||||
costsHtml = `
|
||||
<div class="cost-row">
|
||||
<span class="cost-item ${r.wood >= PARTY_COSTS.wood ? 'ok' : 'bad'}">🪵 ${fmt(r.wood)} / ${fmt(PARTY_COSTS.wood)}</span>
|
||||
<span class="cost-item ${r.stone >= PARTY_COSTS.stone ? 'ok' : 'bad'}">🪨 ${fmt(r.stone)} / ${fmt(PARTY_COSTS.stone)}</span>
|
||||
<span class="cost-item ${r.iron >= PARTY_COSTS.iron ? 'ok' : 'bad'}">⚙️ ${fmt(r.iron)} / ${fmt(PARTY_COSTS.iron)}</span>
|
||||
</div>`;
|
||||
} else {
|
||||
const needed = 300;
|
||||
costsHtml = `
|
||||
<div class="cost-row">
|
||||
<span class="cost-item ${town.battle_points >= needed ? 'ok' : 'bad'}">⚔️ ${fmt(town.battle_points)} / ${needed} πόντοι</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const reasonHtml = cel.reason
|
||||
? `<div class="reason-box ${cel.status === 'cooldown' ? '' : 'error'}">${cel.reason}</div>`
|
||||
: '';
|
||||
|
||||
const autoToggle = `
|
||||
<div class="auto-toggle" title="Αυτόματη εκτέλεση όταν ξεκλειδώσουν πόροι/cooldown">
|
||||
<button id="btn-manual-${town.town_id}-${type}"
|
||||
class="${!autoOn ? 'active-manual' : ''}"
|
||||
onclick="saveAutoSetting('${town.town_id}','${type}',false)">
|
||||
Χειροκίνητο
|
||||
</button>
|
||||
<button id="btn-auto-${town.town_id}-${type}"
|
||||
class="${autoOn ? 'active-auto' : ''}"
|
||||
onclick="saveAutoSetting('${town.town_id}','${type}',true)">
|
||||
⚡ Αυτόματο
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
return `
|
||||
<div class="cel-card ${cel.status}${autoOn ? ' auto-on' : ''}">
|
||||
<div class="cel-card-title">
|
||||
${label}
|
||||
${autoOn ? '<span class="auto-badge">AUTO</span>' : ''}
|
||||
</div>
|
||||
${costsHtml}
|
||||
${reasonHtml}
|
||||
${autoToggle}
|
||||
<button class="cel-btn primary"
|
||||
${(cel.available && !autoOn) ? '' : 'disabled'}
|
||||
onclick="openModal('${town.town_id}','${town.town_name}','${type}')">
|
||||
${autoOn ? '⚡ Αυτόματο ενεργό' : cel.available ? '▶ Εκκίνηση' : (cel.status === 'cooldown' ? '⏰ Σε αναμονή' : '✖ Μη διαθέσιμο')}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
||||
return n;
|
||||
}
|
||||
|
||||
// ── Auto-setting toggle ───────────────────────────────────
|
||||
async function saveAutoSetting(townId, type, enable) {
|
||||
const town = agoraData.find(t => t.town_id === townId);
|
||||
if (!town) return;
|
||||
|
||||
const payload = {
|
||||
player_id: PLAYER_ID,
|
||||
world_id: WORLD_ID,
|
||||
town_id: townId,
|
||||
auto_party: type === 'party' ? enable : !!town.auto_party,
|
||||
auto_triumph: type === 'triumph' ? enable : !!town.auto_triumph
|
||||
};
|
||||
|
||||
try {
|
||||
const r = await fetch('/dashboard/culture-settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok) {
|
||||
// Optimistically update local state and re-render card
|
||||
town[type === 'party' ? 'auto_party' : 'auto_triumph'] = enable;
|
||||
renderCards(town);
|
||||
showToast(enable ? `⚡ Αυτόματο ενεργό — ${TYPE_LABELS[type]}` : `✓ Χειροκίνητο — ${TYPE_LABELS[type]}`, 'ok');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(`❌ Αποτυχία αποθήκευσης: ${e}`, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modal ────────────────────────────────────────────────────────
|
||||
function openModal(townId, townName, celType) {
|
||||
pendingCmd = { townId, townName, celType };
|
||||
document.getElementById('modal-title').textContent = `Επιβεβαίωση — ${TYPE_LABELS[celType]}`;
|
||||
|
||||
let rows = `
|
||||
<div class="modal-row"><span>Πόλη</span><span>${townName}</span></div>`;
|
||||
|
||||
if (celType === 'party') {
|
||||
rows += `
|
||||
<div class="modal-row"><span>Ξύλο</span><span>-15.000</span></div>
|
||||
<div class="modal-row"><span>Πέτρα</span><span>-18.000</span></div>
|
||||
<div class="modal-row"><span>Σίδερο</span><span>-15.000</span></div>`;
|
||||
} else {
|
||||
rows += `<div class="modal-row"><span>Πόντοι Μάχης</span><span>-300</span></div>`;
|
||||
}
|
||||
|
||||
document.getElementById('modal-rows').innerHTML = rows;
|
||||
document.getElementById('btn-confirm').disabled = false;
|
||||
document.getElementById('confirm-modal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('confirm-modal').classList.remove('open');
|
||||
pendingCmd = null;
|
||||
}
|
||||
|
||||
async function fireCommand() {
|
||||
if (!pendingCmd) return;
|
||||
document.getElementById('btn-confirm').disabled = true;
|
||||
|
||||
// Capture before closeModal() nulls pendingCmd
|
||||
const { townId, townName, celType } = pendingCmd;
|
||||
try {
|
||||
const r = await fetch('/dashboard/culture-command', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
player_id: PLAYER_ID,
|
||||
world_id: WORLD_ID,
|
||||
town_id: townId,
|
||||
town_name: townName,
|
||||
celebration_type: celType
|
||||
})
|
||||
});
|
||||
|
||||
// Read raw text first — if the server returns an HTML error page,
|
||||
// JSON.parse would throw a useless SyntaxError. This shows the real problem.
|
||||
const raw = await r.text();
|
||||
let d = {};
|
||||
try { d = JSON.parse(raw); } catch (_) {
|
||||
closeModal();
|
||||
showToast(`❌ Server error (${r.status}): ${raw.substring(0, 120)}`, 'err');
|
||||
return;
|
||||
}
|
||||
|
||||
closeModal();
|
||||
if (r.ok && d.ok) {
|
||||
showToast(`✅ Εντολή στάλθηκε — ${TYPE_LABELS[celType]}`, 'ok');
|
||||
fetchLog();
|
||||
setTimeout(fetchAgora, 3000);
|
||||
} else {
|
||||
showToast(`❌ ${d.message || d.error || 'Αποτυχία'}`, 'err');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(`❌ Σφάλμα: ${e}`, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Log ──────────────────────────────────────────────────────────
|
||||
function toggleLog() {
|
||||
logOpen = !logOpen;
|
||||
document.getElementById('log-body').classList.toggle('open', logOpen);
|
||||
document.getElementById('log-toggle-icon').textContent = logOpen ? '▲' : '▼';
|
||||
}
|
||||
|
||||
async function fetchLog() {
|
||||
try {
|
||||
const r = await fetch(`/dashboard/culture-log?player_id=${PLAYER_ID}&world_id=${WORLD_ID}`);
|
||||
const rows = await r.json();
|
||||
const body = document.getElementById('log-body');
|
||||
const empty = document.getElementById('log-empty');
|
||||
|
||||
if (!rows.length) {
|
||||
if (empty) empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
if (empty) empty.style.display = 'none';
|
||||
|
||||
const existing = new Set([...body.querySelectorAll('.log-entry')].map(el => el.dataset.id));
|
||||
|
||||
rows.forEach(row => {
|
||||
if (existing.has(String(row.id))) return;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'log-entry';
|
||||
el.dataset.id = row.id;
|
||||
|
||||
const t = new Date(row.fired_at + 'Z');
|
||||
const time = t.toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
|
||||
const costStr = row.cost_battle_pts
|
||||
? `-${row.cost_battle_pts} πόντοι`
|
||||
: `-${fmt(row.cost_wood)} ξύλο / -${fmt(row.cost_stone)} πέτρα / -${fmt(row.cost_iron)} σίδερο`;
|
||||
|
||||
el.innerHTML = `
|
||||
<span class="log-time">${time}</span>
|
||||
<span class="log-town">${row.town_name}</span>
|
||||
<span class="log-type">${TYPE_LABELS[row.celebration_type] || row.celebration_type}</span>
|
||||
<span class="log-status ${row.status}">${row.status === 'success' ? '✅' : row.status === 'failed' ? '❌' : '⏳'}</span>
|
||||
<span class="log-msg">
|
||||
${row.source === 'auto' ? '<span class="auto-badge" style="margin-right:4px">AUTO</span>' : ''}
|
||||
${costStr}${row.result_msg ? ' — ' + row.result_msg : ''}
|
||||
</span>`;
|
||||
body.insertBefore(el, body.firstChild);
|
||||
});
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
// ── Online status ────────────────────────────────────────────────
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const r = await fetch(`/dashboard/client-status?player_id=${PLAYER_ID}`);
|
||||
const d = await r.json();
|
||||
const dot = document.getElementById('status-dot');
|
||||
const text = document.getElementById('status-text');
|
||||
dot.className = 'status-dot' + (d.online ? ' online' : '');
|
||||
text.textContent = d.online ? 'Online' : 'Offline';
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
// ── Toast ────────────────────────────────────────────────────────
|
||||
function showToast(msg, type) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = `toast ${type} show`;
|
||||
setTimeout(() => { t.className = 'toast'; }, 3500);
|
||||
}
|
||||
|
||||
// ── Search ───────────────────────────────────────────────────────
|
||||
document.getElementById('search').addEventListener('input', renderTownList);
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────
|
||||
fetchAgora();
|
||||
fetchLog();
|
||||
checkStatus();
|
||||
setInterval(fetchAgora, 30000);
|
||||
setInterval(fetchLog, 15000);
|
||||
setInterval(checkStatus, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -9,8 +9,9 @@
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1><a href="/" style="text-decoration: none; margin-right: 15px; cursor: pointer;" title="Back to Players">⬅️</a> ⚔️ Grepolis Remote</h1>
|
||||
<div class="status-indicator">
|
||||
<h1><a href="/player/{{ player_id }}/{{ world_id }}" style="text-decoration: none; margin-right: 15px; cursor: pointer;" title="Πίσω στο Hub">⬅️</a> ⚔️ Grepolis Remote</h1>
|
||||
<div class="status-indicator" style="display: flex; align-items: center; gap: 10px;">
|
||||
<button class="btn btn-gold btn-sm" id="live-btn" onclick="window.requestLiveSync()" title="Request immediate data update from game" style="padding: 4px 8px; font-size: 0.72rem; border-radius: 4px; border: 1px solid #c8a44a;">⚡ Live Sync</button>
|
||||
<div id="server-status" class="conn-badge">Server…</div>
|
||||
<div id="client-status" class="conn-badge">Client…</div>
|
||||
</div>
|
||||
@@ -29,7 +30,6 @@
|
||||
<div id="town-panel">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<h2 style="margin: 0;">Towns</h2>
|
||||
<button class="btn btn-gold btn-sm" id="live-btn" onclick="window.requestLiveSync()" title="Request immediate data update from game" style="padding: 4px 8px; font-size: 0.72rem;">⚡ Live Sync</button>
|
||||
</div>
|
||||
|
||||
<div id="town-filters" style="margin-bottom: 15px; padding: 10px; background: #0f3460; border-radius: 6px; border: 1px solid #2a4a6a;">
|
||||
@@ -41,11 +41,7 @@
|
||||
</label>
|
||||
|
||||
<label style="font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; gap: 4px; background: rgba(0,0,0,0.2); padding: 4px 8px; border-radius: 12px; border: 1px solid #2a4a6a;">
|
||||
<input type="checkbox" id="filter-festival" onchange="window.renderTowns()"> 🎭 Ελεύθεροι Πόροι
|
||||
</label>
|
||||
|
||||
<label style="font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; gap: 4px; background: rgba(0,0,0,0.2); padding: 4px 8px; border-radius: 12px; border: 1px solid #2a4a6a;">
|
||||
<input type="checkbox" id="filter-points" onchange="window.renderTowns()"> 📈 10k+ Πόντοι
|
||||
<input type="checkbox" id="filter-not-building" onchange="window.renderTowns()"> 🏗️ Δεν χτίζει
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,71 +83,93 @@
|
||||
</div>
|
||||
|
||||
<div id="command-form-wrap" style="display:none">
|
||||
<div class="command-form">
|
||||
|
||||
<div class="form-group">
|
||||
<label>Command Type</label>
|
||||
<select id="cmd-type" onchange="onCmdTypeChange()">
|
||||
<option value="" disabled selected>-- Επιλέξτε --</option>
|
||||
<option value="build">Build / Upgrade</option>
|
||||
<option value="recruit">Recruit Troops</option>
|
||||
<option value="market_offer">Παζάρι - Προσφορά</option>
|
||||
</select>
|
||||
<div class="command-form" style="display: flex; flex-direction: column; gap: 15px; align-items: flex-start;">
|
||||
|
||||
<!-- Segmented Control Row -->
|
||||
<div class="segmented-control" id="cmd-type-buttons" style="display: flex; gap: 5px; background: #16213e; padding: 4px; border-radius: 8px; border: 1px solid #2a4a6a;">
|
||||
<button class="seg-btn" id="seg-build" onclick="window.setCmdType('build', true)" style="flex: 1; padding: 10px; border: none; background: transparent; color: #888; border-radius: 6px; cursor: pointer; transition: 0.2s;">🏗️ Κατασκευές</button>
|
||||
<button class="seg-btn" id="seg-recruit" onclick="window.setCmdType('recruit', true)" style="flex: 1; padding: 10px; border: none; background: transparent; color: #888; border-radius: 6px; cursor: pointer; transition: 0.2s;">⚔️ Στρατός</button>
|
||||
<button class="seg-btn" id="seg-market" onclick="window.setCmdType('market_offer', true)" style="flex: 1; padding: 10px; border: none; background: transparent; color: #888; border-radius: 6px; cursor: pointer; transition: 0.2s;">🛒 Παζάρι</button>
|
||||
<button class="seg-btn" id="seg-research" onclick="window.setCmdType('research', true)" style="flex: 1; padding: 10px; border: none; background: transparent; color: #888; border-radius: 6px; cursor: pointer; transition: 0.2s;">🦉 Έρευνα</button>
|
||||
<button class="seg-btn" id="seg-blueprints" onclick="window.setCmdType('blueprints', true)" style="flex: 1; padding: 10px; border: none; background: transparent; color: #888; border-radius: 6px; cursor: pointer; transition: 0.2s;">📜 Blueprints</button>
|
||||
</div>
|
||||
|
||||
<!-- Build options - now a button that opens the visual picker -->
|
||||
<div class="form-group" id="build-options">
|
||||
<label>Building</label>
|
||||
<button class="btn btn-gold" id="open-building-modal" onclick="window.openBuildingModal()" style="text-align:left; min-width:200px;">
|
||||
<span id="selected-building-label">-- Επιλέξτε Κατασκευή --</span>
|
||||
</button>
|
||||
<!-- Dynamic Selection Area -->
|
||||
<div id="selection-area" style="display:none; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<button class="btn btn-gold" id="active-selection-display" onclick="window.reopenActiveModal()" style="min-width: 250px; padding: 10px 15px; border-radius: 6px; display: flex; justify-content: space-between; align-items: center; font-weight: bold;">
|
||||
<span id="selection-label">-- Επιλέξτε --</span>
|
||||
</button>
|
||||
|
||||
<div id="recruit-amount-wrap" style="display:none; align-items: center; gap: 5px; background: #16213e; padding: 8px 12px; border-radius: 6px; border: 1px solid #2a4a6a;">
|
||||
<label style="font-size:0.85rem; color:#ccc; margin:0;">Ποσότητα:</label>
|
||||
<input type="number" id="recruit-amount" value="1" min="1" max="9999" style="width: 70px; background: #0f3460; color: #fff; border: 1px solid #c8a44a; border-radius: 4px; padding: 4px 8px;">
|
||||
</div>
|
||||
|
||||
<button id="btn-send" class="btn btn-gold" onclick="window.sendCommand()" style="margin-left: auto; padding: 10px 20px; font-size: 1rem;">Send ⚡</button>
|
||||
</div>
|
||||
|
||||
<!-- Recruit options -->
|
||||
<div class="form-group" id="recruit-options" style="display:none">
|
||||
<label>Unit</label>
|
||||
<select id="unit-select">
|
||||
<optgroup label="Ξηρά">
|
||||
<option value="sword">Ξιφομάχος</option>
|
||||
<option value="slinger">Σφενδονήτης</option>
|
||||
<option value="archer">Τοξότης</option>
|
||||
<option value="hoplite">Οπλίτης</option>
|
||||
<option value="rider">Ιππέας</option>
|
||||
<option value="chariot">Άρμα</option>
|
||||
<option value="catapult">Καταπέλτης</option>
|
||||
</optgroup>
|
||||
<optgroup label="Ναυτικές">
|
||||
<option value="big_transporter">Μεταφορικό Πλοίο</option>
|
||||
<option value="small_transporter">Γρήγορο Μεταφορικό Πλοίο</option>
|
||||
<option value="bireme">Διήρης</option>
|
||||
<option value="attack_ship">Πλοίο Φάρος</option>
|
||||
<option value="trireme">Τριήρης</option>
|
||||
<option value="colonize_ship">Αποικιακό Πλοίο</option>
|
||||
</optgroup>
|
||||
<optgroup label="Μυθικές">
|
||||
<option value="medusa">Μέδουσα</option>
|
||||
<option value="zyklop">Κύκλωπας</option>
|
||||
<option value="harpy">Άρπυια</option>
|
||||
<option value="pegasus">Πήγασος</option>
|
||||
<option value="minotaur">Μινώταυρος</option>
|
||||
<option value="manticore">Μαντιχώρας</option>
|
||||
<option value="cerberus">Κέρβερος</option>
|
||||
<option value="hydra">Ύδρα</option>
|
||||
<option value="sea_monster">Τέρας της Θάλασσας</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Market options -->
|
||||
<div class="form-group" id="market-options" style="display:none">
|
||||
<div id="build-queue-preview"></div>
|
||||
<div id="unit-queue-preview" style="margin-top:10px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom right: Build Queue / Command Log (tabbed) -->
|
||||
<div id="log-panel">
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:12px; border-bottom:1px solid #2a3a5a; padding-bottom:10px;">
|
||||
<h2 style="margin:0; flex:1;">Ουρά Κατασκευών</h2>
|
||||
<button id="tab-queue" class="log-tab-btn tab-active" onclick="window.switchToQueueMode()">🏗️ Ουρά</button>
|
||||
<button id="tab-log" class="log-tab-btn" onclick="window.switchToLogMode()">📋 Ιστορικό</button>
|
||||
</div>
|
||||
<div id="log-content">
|
||||
<p style="color:#555;font-size:0.85rem;padding:12px 0;">← Επιλέξτε πόλη για να δείτε την ουρά.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ====== Blueprints Modal ====== -->
|
||||
<div class="modal-overlay" id="blueprints-modal-overlay" onclick="window.closeBlueprintsModal(event)">
|
||||
<div class="custom-modal" id="blueprints-modal" style="max-width: 400px;">
|
||||
<div class="modal-header">
|
||||
<h3>📜 Επιλογή Blueprint</h3>
|
||||
<button class="modal-close" onclick="window.closeBlueprintsModal()">✕</button>
|
||||
</div>
|
||||
<div style="padding: 15px;">
|
||||
<p style="color:#ccc; font-size:0.85rem; margin-bottom:15px;">Επιλέξτε ένα Blueprint για να αναλάβει το Python την αυτόματη κατασκευή της πόλης.</p>
|
||||
|
||||
<div id="blueprint-list" style="display:flex; flex-direction:column; gap:10px; margin-bottom:20px;">
|
||||
<!-- Currently just one blueprint -->
|
||||
<div class="bld-card" id="bp-card-standard" onclick="window.selectBlueprint('Standard Growth')" style="width:100%; justify-content:flex-start; cursor:pointer;">
|
||||
<span class="bld-icon" style="font-size:2rem;">🏙️</span>
|
||||
<div style="display:flex; flex-direction:column; align-items:flex-start;">
|
||||
<span class="bld-name" style="margin-top:0; font-size:1rem; font-weight:bold;">Standard Growth</span>
|
||||
<span style="font-size:0.75rem; color:#888;">Αυτόματη ανάπτυξη κτιρίων & ακαδημίας</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== Market Modal ====== -->
|
||||
<div class="modal-overlay" id="market-modal-overlay" onclick="window.closeMarketModal(event)">
|
||||
<div class="custom-modal" id="market-modal" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h3>🛒 Ρυθμίσεις Παζαριού</h3>
|
||||
<button class="modal-close" onclick="window.closeMarketModal()">✕</button>
|
||||
</div>
|
||||
<div style="padding: 15px;">
|
||||
<div style="display:flex; gap:10px; margin-bottom:10px;">
|
||||
<div style="flex:1;">
|
||||
<label>Προσφορά</label>
|
||||
<input type="number" id="market-offer-amount" value="1000" min="1" max="99999" style="width:100%;">
|
||||
<label style="color:#ccc; font-size:0.85rem;">Προσφορά</label>
|
||||
<input type="number" id="market-offer-amount" value="1000" min="1" max="99999" style="width:100%; background:#0f3460; color:#fff; border:1px solid #2a4a6a; border-radius:4px; padding:6px;">
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
<label>Πόρος Προσφοράς</label>
|
||||
<select id="market-offer-type">
|
||||
<label style="color:#ccc; font-size:0.85rem;">Πόρος Προσφοράς</label>
|
||||
<select id="market-offer-type" style="width:100%; background:#0f3460; color:#fff; border:1px solid #2a4a6a; border-radius:4px; padding:6px;">
|
||||
<option value="wood">Ξύλο</option>
|
||||
<option value="stone">Πέτρα</option>
|
||||
<option value="iron">Ασήμι</option>
|
||||
@@ -160,22 +178,22 @@
|
||||
</div>
|
||||
<div style="display:flex; gap:10px; margin-bottom:10px;">
|
||||
<div style="flex:1;">
|
||||
<label>Ζήτηση</label>
|
||||
<input type="number" id="market-demand-amount" value="1000" min="1" max="99999" style="width:100%;">
|
||||
<label style="color:#ccc; font-size:0.85rem;">Ζήτηση</label>
|
||||
<input type="number" id="market-demand-amount" value="1000" min="1" max="99999" style="width:100%; background:#0f3460; color:#fff; border:1px solid #2a4a6a; border-radius:4px; padding:6px;">
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
<label>Πόρος Ζήτησης</label>
|
||||
<select id="market-demand-type">
|
||||
<label style="color:#ccc; font-size:0.85rem;">Πόρος Ζήτησης</label>
|
||||
<select id="market-demand-type" style="width:100%; background:#0f3460; color:#fff; border:1px solid #2a4a6a; border-radius:4px; padding:6px;">
|
||||
<option value="iron">Ασήμι</option>
|
||||
<option value="stone">Πέτρα</option>
|
||||
<option value="wood">Ξύλο</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:10px;">
|
||||
<div style="display:flex; gap:10px; margin-bottom:20px;">
|
||||
<div style="flex:1;">
|
||||
<label>Χρόνος (Ώρες)</label>
|
||||
<select id="market-max-time">
|
||||
<label style="color:#ccc; font-size:0.85rem;">Χρόνος (Ώρες)</label>
|
||||
<select id="market-max-time" style="width:100%; background:#0f3460; color:#fff; border:1px solid #2a4a6a; border-radius:4px; padding:6px;">
|
||||
<option value="1800">0.5</option>
|
||||
<option value="3600">1</option>
|
||||
<option value="7200">2</option>
|
||||
@@ -187,46 +205,18 @@
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
<label>Ορατότητα</label>
|
||||
<select id="market-visibility">
|
||||
<label style="color:#ccc; font-size:0.85rem;">Ορατότητα</label>
|
||||
<select id="market-visibility" style="width:100%; background:#0f3460; color:#fff; border:1px solid #2a4a6a; border-radius:4px; padding:6px;">
|
||||
<option value="allies">Συμμαχία Μόνο</option>
|
||||
<option value="all">Όλοι</option>
|
||||
<option value="alliance">Συμμαχία</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="amount-group" style="display:none">
|
||||
<label>Amount</label>
|
||||
<input type="number" id="recruit-amount" value="1" min="1" max="9999" style="width:80px;">
|
||||
</div>
|
||||
|
||||
<button class="btn btn-gold" onclick="sendCommand()">Send ⚡</button>
|
||||
</div>
|
||||
|
||||
<div id="build-queue-preview"></div>
|
||||
<button class="btn btn-gold" onclick="window.saveMarketModal()" style="width:100%; padding:10px; font-weight:bold;">✅ Αποθήκευση Προσφοράς</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom right: Command log -->
|
||||
<div id="log-panel">
|
||||
<h2>Command Log</h2>
|
||||
<div id="log-content">
|
||||
<p id="empty-log">No commands sent yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.PLAYER_ID = "{{ player_id }}";
|
||||
</script>
|
||||
<script src="/static/js/state.js"></script>
|
||||
<script src="/static/js/components/townViewer.js"></script>
|
||||
<script src="/static/js/components/commandForm.js"></script>
|
||||
<script src="/static/js/components/commandLog.js"></script>
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<!-- ====== Building Picker Modal ====== -->
|
||||
<div id="building-modal-overlay" onclick="window.closeBuildingModal(event)">
|
||||
<div id="building-modal">
|
||||
@@ -241,5 +231,101 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ====== Unit Picker Modal ====== -->
|
||||
<div id="unit-modal-overlay" class="modal-overlay" onclick="window.closeUnitModal(event)">
|
||||
<div id="unit-modal" class="custom-modal">
|
||||
<div class="modal-header">
|
||||
<h3>⚔️ Επιλογή Μονάδας</h3>
|
||||
<button id="unit-modal-close" onclick="window.closeUnitModal()">✕</button>
|
||||
</div>
|
||||
<div id="unit-grid" style="display:flex; flex-wrap:wrap; gap:10px; max-height:70vh; overflow-y:auto; padding:10px 5px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== Academy Picker Modal ====== -->
|
||||
<div id="academy-modal-overlay" onclick="window.closeAcademyModal(event)">
|
||||
<div id="academy-modal">
|
||||
<div id="academy-modal-header">
|
||||
<h3>🦉 Ακαδημία</h3>
|
||||
<button id="academy-modal-close" onclick="window.closeAcademyModal()">✕</button>
|
||||
</div>
|
||||
<div id="academy-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Tab buttons for queue / log toggle */
|
||||
.log-tab-btn {
|
||||
background: transparent;
|
||||
border: 1px solid #2a3a5a;
|
||||
color: #666;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.log-tab-btn:hover { border-color: #4a7aaa; color: #aaa; }
|
||||
.log-tab-btn.tab-active { border-color: #c8a44a; color: #c8a44a; background: rgba(200,164,74,0.1); }
|
||||
|
||||
/* Draggable build queue row */
|
||||
.bq-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #1a2a3a;
|
||||
margin-bottom: 5px;
|
||||
background: #0d1e30;
|
||||
cursor: default;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.bq-row:hover { background: #112038; border-color: #2a4a6a; }
|
||||
.bq-row.bq-drag-over { border-color: #c8a44a; background: rgba(200,164,74,0.08); }
|
||||
.bq-handle {
|
||||
cursor: grab;
|
||||
font-size: 1.1rem;
|
||||
color: #3a5a7a;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.bq-handle:hover { color: #c8a44a; }
|
||||
.bq-pos {
|
||||
width: 18px;
|
||||
text-align: right;
|
||||
font-size: 0.72rem;
|
||||
color: #3a5a7a;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bq-icon { font-size: 1.1rem; flex-shrink: 0; }
|
||||
.bq-name { flex: 1; font-size: 0.88rem; color: #d0d0d0; }
|
||||
.bq-cancel-btn {
|
||||
background: transparent;
|
||||
border: 1px solid #3a2a2a;
|
||||
color: #884444;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bq-cancel-btn:hover { background: rgba(200,80,80,0.15); border-color: #cc4444; color: #ff6666; }
|
||||
</style>
|
||||
<script>
|
||||
window.PLAYER_ID = "{{ player_id }}";
|
||||
window.WORLD_ID = "{{ world_id }}";
|
||||
</script>
|
||||
<script src="/static/js/state.js?v=6"></script>
|
||||
<script src="/static/js/components/townViewer.js?v=6"></script>
|
||||
<script src="/static/js/components/commandForm.js?v=6"></script>
|
||||
<script src="/static/js/components/commandLog.js?v=6"></script>
|
||||
<script src="/static/js/api.js?v=6"></script>
|
||||
<script src="/static/js/app.js?v=6"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<a href="/player/{{ player_id }}">← Πίσω</a>
|
||||
<a href="/player/{{ player_id }}/{{ world_id }}">← Πίσω</a>
|
||||
<h1>🌾 Farm Manager</h1>
|
||||
<span class="online-dot" id="online-dot" title="Κατάσταση Script"></span>
|
||||
<button class="sync-btn" onclick="requestSync()">Live Sync</button>
|
||||
@@ -242,6 +242,15 @@
|
||||
<!-- Status banner -->
|
||||
<div class="status-bar" id="status-bar"></div>
|
||||
|
||||
<!-- Warehouse-full notice (hidden by default) -->
|
||||
<div id="warehouse-full-banner" style="display:none; background: linear-gradient(90deg, #5a1a00, #8b2500); border: 1px solid #ff6600; border-radius: 8px; padding: 12px 18px; margin-bottom: 1rem; align-items: center; gap: 12px; font-weight: 600;">
|
||||
<span style="font-size: 1.4rem;">📦</span>
|
||||
<span>
|
||||
<strong style="color:#ff9933;">Αποθήκη Γεμάτη!</strong>
|
||||
Όλες οι αποθήκες είναι >95% — το bot παρακάμπτει τη λεηλασία μέχρι να αδειάσει χώρος.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Control Panel -->
|
||||
<div class="panel">
|
||||
<h2>⚙️ Ρυθμίσεις</h2>
|
||||
@@ -275,8 +284,11 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="save-btn" id="save-btn" onclick="saveSettings()">💾 Αποθήκευση</button>
|
||||
<span class="save-status" id="save-status">✓ Αποθηκεύτηκε</span>
|
||||
<div style="display: flex; gap: 1rem; align-items: center;">
|
||||
<button class="save-btn" id="save-btn" onclick="saveSettings()">💾 Αποθήκευση</button>
|
||||
<button class="save-btn" id="manual-loot-btn" onclick="triggerManualLoot()" style="background: linear-gradient(135deg, #2a5a7a, #4a9ccc);">🌾 Λεηλασία Τώρα</button>
|
||||
<span class="save-status" id="save-status">✓ Στάλθηκε</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Farm Upgrade Panel -->
|
||||
@@ -301,6 +313,80 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bandit Camp Panel -->
|
||||
<div class="panel">
|
||||
<h2>🏕️ Αυτόματο Bandit Camp</h2>
|
||||
<p style="font-size:0.85rem;color:#888;margin-bottom:1rem;">
|
||||
Το bot επιτίθεται αυτόματα στο στρατόπεδο ληστών και διεκδικεί αμοιβές.<br>
|
||||
Ελέγχει κάθε <strong>12–22 λεπτά</strong> (τυχαίο) — ανθρώπινος ρυθμός.
|
||||
</p>
|
||||
|
||||
<div class="toggle-row" style="margin-bottom:1rem;">
|
||||
<span class="toggle-label">Αυτόματη Επίθεση</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="bootcamp-enabled">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span style="color:#888;font-size:0.85rem;" id="bootcamp-hint">Ανενεργό</span>
|
||||
</div>
|
||||
|
||||
<div class="toggle-row" style="margin-bottom:1.5rem;">
|
||||
<span class="toggle-label">Συμπ. Αμυντικές Μονάδες (Σπαθ/Τοξ)</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="bootcamp-use-def">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="save-btn" onclick="saveBotSettings()">💾 Αποθήκευση</button>
|
||||
<span class="save-status" id="bot-save-status">✓ Αποθηκεύτηκε</span>
|
||||
|
||||
<div style="margin-top: 1rem; border-top: 1px solid #1a3040; padding-top: 1rem;">
|
||||
<button class="save-btn" id="bootcamp-attack-btn" onclick="attackBootcampNow()" style="background: linear-gradient(135deg, #7a2a2a, #cc4a4a); width: 100%;">⚔️ Επίθεση Τώρα</button>
|
||||
<div style="text-align: center; margin-top: 5px;"><span class="save-status" id="bootcamp-attack-status">Εντολή εστάλη!</span></div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:1.5rem;font-size:0.9rem;color:#aaa;">📋 Ιστορικό</h3>
|
||||
<div id="bootcamp-log" style="background:#0a1520;border:1px solid #1a3040;border-radius:8px;padding:10px;max-height:180px;overflow-y:auto;font-size:0.78rem;font-family:monospace;color:#8ab4d0;">
|
||||
<span style="color:#444;">Αναμονή δεδομένων...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rural Trade Panel -->
|
||||
<div class="panel">
|
||||
<h2>🔄 Αυτόματο Trade Χωριών</h2>
|
||||
<p style="font-size:0.85rem;color:#888;margin-bottom:1rem;">
|
||||
Ενεργοποιείται <strong>μόνο όταν μια κατασκευή κολλάει λόγω πόρων</strong>.<br>
|
||||
Ψάχνει χωριά στο νησί που προσφέρουν τον ελλείποντα πόρο και κάνει trade.<br>
|
||||
Ελέγχει κάθε <strong>25–45 λεπτά</strong> (τυχαίο).
|
||||
</p>
|
||||
|
||||
<div class="toggle-row" style="margin-bottom:1rem;">
|
||||
<span class="toggle-label">Αυτόματο Trade</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="rural-trade-enabled">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span style="color:#888;font-size:0.85rem;" id="rural-trade-hint">Ανενεργό</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:0.75rem;font-size:0.85rem;color:#888;">Ελάχιστο Ratio Trade (τιμή χωριού):</div>
|
||||
<div class="option-grid" style="margin-bottom:1.5rem;">
|
||||
<button class="option-btn" data-ratio="1" onclick="selectRatio(1)"><span class="opt-time">0.25</span><span class="opt-name">Ελάχ.</span></button>
|
||||
<button class="option-btn" data-ratio="2" onclick="selectRatio(2)"><span class="opt-time">0.50</span><span class="opt-name">Χαμηλό</span></button>
|
||||
<button class="option-btn selected" data-ratio="3" onclick="selectRatio(3)"><span class="opt-time">0.75</span><span class="opt-name">Κανον.</span></button>
|
||||
<button class="option-btn" data-ratio="4" onclick="selectRatio(4)"><span class="opt-time">1.00</span><span class="opt-name">Υψηλό</span></button>
|
||||
<button class="option-btn" data-ratio="5" onclick="selectRatio(5)"><span class="opt-time">1.25</span><span class="opt-name">Μέγιστο</span></button>
|
||||
</div>
|
||||
|
||||
<button class="save-btn" onclick="saveBotSettings()">💾 Αποθήκευση</button>
|
||||
|
||||
<h3 style="margin-top:1.5rem;font-size:0.9rem;color:#aaa;">📋 Ιστορικό</h3>
|
||||
<div id="rural-trade-log" style="background:#0a1520;border:1px solid #1a3040;border-radius:8px;padding:10px;max-height:180px;overflow-y:auto;font-size:0.78rem;font-family:monospace;color:#8ab4d0;">
|
||||
<span style="color:#444;">Αναμονή δεδομένων...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Farm Status Table -->
|
||||
<div class="panel">
|
||||
<h2>🏘️ Κατάσταση Χωριών</h2>
|
||||
@@ -312,17 +398,20 @@
|
||||
<th>Έτοιμα</th>
|
||||
<th>Σύνολο</th>
|
||||
<th>Επόμενο</th>
|
||||
<th>Τελευταία Λεηλασία</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="farm-table-body">
|
||||
<tr><td colspan="4"><div class="empty-state">⏳ <p>Φόρτωση δεδομένων...</p></div></td></tr>
|
||||
<tr><td colspan="5"><div class="empty-state">⏳ <p>Φόρτωση δεδομένων...</p></div></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
const PLAYER_ID = '{{ player_id }}';
|
||||
const WORLD_ID = '{{ world_id }}';
|
||||
let selectedOption = 1;
|
||||
|
||||
// -- Loot option buttons --
|
||||
@@ -357,7 +446,7 @@
|
||||
fetch('/dashboard/farm-settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ player_id: PLAYER_ID, enabled, loot_option: selectedOption })
|
||||
body: JSON.stringify({ player_id: PLAYER_ID, world_id: WORLD_ID, enabled, loot_option: selectedOption })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
@@ -369,7 +458,7 @@
|
||||
|
||||
// -- Load current settings --
|
||||
function loadSettings() {
|
||||
fetch(`/dashboard/farm-settings?player_id=${PLAYER_ID}`)
|
||||
fetch(`/dashboard/farm-settings?player_id=${PLAYER_ID}&world_id=${WORLD_ID}`)
|
||||
.then(r => r.json())
|
||||
.then(cfg => {
|
||||
document.getElementById('farm-enabled').checked = cfg.enabled;
|
||||
@@ -383,17 +472,27 @@
|
||||
}
|
||||
|
||||
// -- Load farm data table --
|
||||
function timeAgo(isoStr) {
|
||||
if (!isoStr) return '—';
|
||||
const diff = Math.floor((Date.now() - new Date(isoStr + (isoStr.endsWith('Z') ? '' : 'Z'))) / 1000);
|
||||
if (diff < 60) return `${diff}δ πριν`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}λ πριν`;
|
||||
return `${Math.floor(diff / 3600)}ω πριν`;
|
||||
}
|
||||
|
||||
function loadFarmData() {
|
||||
fetch(`/dashboard/farm-data?player_id=${PLAYER_ID}`)
|
||||
fetch(`/dashboard/farm-data?player_id=${PLAYER_ID}&world_id=${WORLD_ID}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
.then(resp => {
|
||||
const data = resp.towns || [];
|
||||
const lastFarmed = resp.last_farmed_at ? timeAgo(resp.last_farmed_at) : '—';
|
||||
const tbody = document.getElementById('farm-table-body');
|
||||
if (!data || data.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4"><div class="empty-state">🌱 <p>Δεν υπάρχουν δεδομένα χωριών ακόμη.<br>Βεβαιώσου ότι το script v3.3+ τρέχει στο παιχνίδι.</p></div></td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="5"><div class="empty-state">🌱 <p>Δεν υπάρχουν δεδομένα χωριών ακόμη.<br>Βεβαιώσου ότι το script v3.3+ τρέχει στο παιχνίδι.</p></div></td></tr>';
|
||||
return;
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
tbody.innerHTML = data.map(t => {
|
||||
tbody.innerHTML = data.map((t, idx) => {
|
||||
const readyBadge = t.ready_farms > 0
|
||||
? `<span class="badge ready">✓ ${t.ready_farms} Έτοιμα</span>`
|
||||
: `<span class="badge waiting">Αναμονή</span>`;
|
||||
@@ -408,11 +507,16 @@
|
||||
nextStr = '<span class="countdown">Τώρα</span>';
|
||||
}
|
||||
}
|
||||
// Show last farmed only in first row — same value for all rows
|
||||
const lastFarmedCell = idx === 0
|
||||
? `<td rowspan="${data.length}" style="color:#4acc64;font-size:0.82rem;vertical-align:middle;">${lastFarmed}</td>`
|
||||
: '';
|
||||
return `<tr>
|
||||
<td><strong>${t.town_name}</strong></td>
|
||||
<td>${readyBadge}</td>
|
||||
<td><span style="color:#888">${t.total_farms}</span></td>
|
||||
<td>${nextStr}</td>
|
||||
${lastFarmedCell}
|
||||
</tr>`;
|
||||
}).join('');
|
||||
});
|
||||
@@ -460,7 +564,7 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
player_id: PLAYER_ID,
|
||||
town_id: 0,
|
||||
town_id: WORLD_ID ? `0_${WORLD_ID}` : "0",
|
||||
type: 'farm_upgrade',
|
||||
payload: { threshold: threshold, action_type: actionType }
|
||||
})
|
||||
@@ -475,12 +579,154 @@
|
||||
});
|
||||
}
|
||||
|
||||
// -- Trigger Manual Loot --
|
||||
function triggerManualLoot() {
|
||||
const btn = document.getElementById('manual-loot-btn');
|
||||
const originalText = btn.innerText;
|
||||
btn.innerText = '⏳ Αποστολή...';
|
||||
btn.disabled = true;
|
||||
|
||||
fetch('/dashboard/commands', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
player_id: PLAYER_ID,
|
||||
town_id: WORLD_ID ? `0_${WORLD_ID}` : "0",
|
||||
type: 'farm_loot',
|
||||
payload: { loot_option: selectedOption }
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
btn.innerText = '✓ Εστάλη!';
|
||||
setTimeout(() => {
|
||||
btn.innerText = originalText;
|
||||
btn.disabled = false;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function attackBootcampNow() {
|
||||
const btn = document.getElementById('bootcamp-attack-btn');
|
||||
const status = document.getElementById('bootcamp-attack-status');
|
||||
const originalText = btn.innerText;
|
||||
|
||||
btn.innerText = '⏳ Αποστολή...';
|
||||
btn.disabled = true;
|
||||
|
||||
fetch('/dashboard/bootcamp-attack-now', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ player_id: PLAYER_ID, world_id: WORLD_ID })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
btn.innerText = '✓ Εστάλη!';
|
||||
status.style.opacity = '1';
|
||||
setTimeout(() => {
|
||||
btn.innerText = originalText;
|
||||
btn.disabled = false;
|
||||
status.style.opacity = '0';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
async function checkWarehouseStatus() {
|
||||
try {
|
||||
const res = await fetch(`/api/farm_status?player_id=${PLAYER_ID}&world_id=${WORLD_ID}`);
|
||||
const data = await res.json();
|
||||
const banner = document.getElementById('warehouse-full-banner');
|
||||
if (banner) banner.style.display = data.warehouse_full ? 'flex' : 'none';
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── Bot Settings (Bootcamp + Rural Trade) ─────────────────────
|
||||
let selectedRatio = 3;
|
||||
|
||||
function selectRatio(n) {
|
||||
selectedRatio = n;
|
||||
document.querySelectorAll('[data-ratio]').forEach(b => {
|
||||
b.classList.toggle('selected', parseInt(b.dataset.ratio) === n);
|
||||
});
|
||||
}
|
||||
|
||||
function loadBotSettings() {
|
||||
fetch(`/dashboard/bot-settings?player_id=${PLAYER_ID}&world_id=${WORLD_ID}`)
|
||||
.then(r => r.json())
|
||||
.then(cfg => {
|
||||
document.getElementById('bootcamp-enabled').checked = !!cfg.bootcamp_enabled;
|
||||
document.getElementById('bootcamp-hint').textContent = cfg.bootcamp_enabled ? '🟢 Ενεργό' : 'Ανενεργό';
|
||||
document.getElementById('bootcamp-use-def').checked = !!cfg.bootcamp_use_def;
|
||||
document.getElementById('rural-trade-enabled').checked = !!cfg.rural_trade_enabled;
|
||||
document.getElementById('rural-trade-hint').textContent = cfg.rural_trade_enabled ? '🟢 Ενεργό' : 'Ανενεργό';
|
||||
selectRatio(cfg.rural_trade_ratio || 3);
|
||||
});
|
||||
}
|
||||
|
||||
function saveBotSettings() {
|
||||
fetch('/dashboard/bot-settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
player_id: PLAYER_ID,
|
||||
world_id: WORLD_ID,
|
||||
bootcamp_enabled: document.getElementById('bootcamp-enabled').checked,
|
||||
bootcamp_use_def: document.getElementById('bootcamp-use-def').checked,
|
||||
rural_trade_enabled: document.getElementById('rural-trade-enabled').checked,
|
||||
rural_trade_ratio: selectedRatio
|
||||
})
|
||||
}).then(r => r.json()).then(() => {
|
||||
// Update hints
|
||||
document.getElementById('bootcamp-hint').textContent =
|
||||
document.getElementById('bootcamp-enabled').checked ? '🟢 Ενεργό' : 'Ανενεργό';
|
||||
document.getElementById('rural-trade-hint').textContent =
|
||||
document.getElementById('rural-trade-enabled').checked ? '🟢 Ενεργό' : 'Ανενεργό';
|
||||
const s = document.getElementById('bot-save-status');
|
||||
s.classList.add('visible');
|
||||
setTimeout(() => s.classList.remove('visible'), 2500);
|
||||
});
|
||||
}
|
||||
|
||||
// Wire toggle hints live
|
||||
document.getElementById('bootcamp-enabled').addEventListener('change', function() {
|
||||
document.getElementById('bootcamp-hint').textContent = this.checked ? '🟢 Ενεργό' : 'Ανενεργό';
|
||||
});
|
||||
document.getElementById('rural-trade-enabled').addEventListener('change', function() {
|
||||
document.getElementById('rural-trade-hint').textContent = this.checked ? '🟢 Ενεργό' : 'Ανενεργό';
|
||||
});
|
||||
|
||||
// ── Bot Logs ───────────────────────────────────────────────────
|
||||
function renderBotLog(containerId, entries) {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!entries || entries.length === 0) {
|
||||
el.innerHTML = '<span style="color:#444;">Δεν υπάρχουν εγγραφές ακόμη.</span>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = entries.map(e => {
|
||||
const t = new Date(e.created_at + 'Z').toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
|
||||
return `<div><span style="color:#3a6a8a;">[${t}]</span> ${e.message}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function loadBotLogs() {
|
||||
fetch(`/dashboard/bot-logs?player_id=${PLAYER_ID}&world_id=${WORLD_ID}&feature=bootcamp`)
|
||||
.then(r => r.json()).then(data => renderBotLog('bootcamp-log', data));
|
||||
fetch(`/dashboard/bot-logs?player_id=${PLAYER_ID}&world_id=${WORLD_ID}&feature=rural_trade`)
|
||||
.then(r => r.json()).then(data => renderBotLog('rural-trade-log', data));
|
||||
}
|
||||
|
||||
// -- Boot --
|
||||
loadSettings();
|
||||
loadBotSettings();
|
||||
loadFarmData();
|
||||
loadBotLogs();
|
||||
checkOnline();
|
||||
setInterval(loadFarmData, 15000);
|
||||
setInterval(checkOnline, 20000);
|
||||
checkWarehouseStatus();
|
||||
setInterval(loadFarmData, 15000);
|
||||
setInterval(loadBotLogs, 15000);
|
||||
setInterval(checkOnline, 20000);
|
||||
setInterval(checkWarehouseStatus, 20000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -95,9 +95,15 @@
|
||||
.hub-card.farm::before { background: radial-gradient(circle at top left, rgba(74,200,100,0.08), transparent 70%); }
|
||||
.hub-card.farm:hover { border-color: #4acc64; box-shadow: 0 12px 40px rgba(74,200,100,0.15); }
|
||||
|
||||
/* Live Tracker — blue (coming soon, dimmed) */
|
||||
.hub-card.tracker { border-color: #1a2030; opacity: 0.65; cursor: not-allowed; }
|
||||
.hub-card.tracker:hover { transform: none; box-shadow: none; border-color: #1a2030; }
|
||||
/* Live Tracker — teal */
|
||||
.hub-card.tracker { border-color: #1a3035; }
|
||||
.hub-card.tracker::before { background: radial-gradient(circle at top left, rgba(111,207,207,0.08), transparent 70%); }
|
||||
.hub-card.tracker:hover { border-color: #6fcfcf; box-shadow: 0 12px 40px rgba(111,207,207,0.15); }
|
||||
|
||||
/* Αγορά — purple/gold */
|
||||
.hub-card.agora { border-color: #2d1f3f; }
|
||||
.hub-card.agora::before { background: radial-gradient(circle at top left, rgba(180,130,220,0.08), transparent 70%); }
|
||||
.hub-card.agora:hover { border-color: #b482dc; box-shadow: 0 12px 40px rgba(180,130,220,0.18); }
|
||||
|
||||
.card-icon {
|
||||
font-size: 2.8rem;
|
||||
@@ -112,6 +118,7 @@
|
||||
.hub-card.admin .card-title { color: #c8a44a; }
|
||||
.hub-card.farm .card-title { color: #4acc64; }
|
||||
.hub-card.tracker .card-title { color: #6fcfcf; }
|
||||
.hub-card.agora .card-title { color: #b482dc; }
|
||||
|
||||
.card-desc {
|
||||
font-size: 0.875rem;
|
||||
@@ -154,24 +161,29 @@
|
||||
|
||||
<div class="hub-grid">
|
||||
|
||||
<a href="/player/{{ player_id }}/admin" class="hub-card admin">
|
||||
<a href="/player/{{ player_id }}/{{ world_id }}/admin" class="hub-card admin">
|
||||
<span class="card-icon">🏛️</span>
|
||||
<div class="card-title">Admin Mode</div>
|
||||
<div class="card-desc">Κτίρια, στρατολόγηση, αγορά, ουρά κατασκευών και πλήρης έλεγχος πόλεων.</div>
|
||||
</a>
|
||||
|
||||
<a href="/player/{{ player_id }}/farm" class="hub-card farm">
|
||||
<a href="/player/{{ player_id }}/{{ world_id }}/farm" class="hub-card farm">
|
||||
<span class="card-icon">🌾</span>
|
||||
<div class="card-title">Farm Manager</div>
|
||||
<div class="card-desc">Αυτόματη συλλογή πόρων από χωριά. Ρυθμίσεις χρόνου λεηλασίας και έλεγχος με ένα κλικ.</div>
|
||||
</a>
|
||||
|
||||
<div class="hub-card tracker">
|
||||
<span class="soon-badge">Σύντομα</span>
|
||||
<a href="/player/{{ player_id }}/{{ world_id }}/tracker" class="hub-card tracker">
|
||||
<span class="card-icon">🛡️</span>
|
||||
<div class="card-title">Live Tracker</div>
|
||||
<div class="card-desc">Παρακολούθηση κινήσεων στρατού σε πραγματικό χρόνο. Εισερχόμενες επιθέσεις και ενισχύσεις.</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/player/{{ player_id }}/{{ world_id }}/agora" class="hub-card agora">
|
||||
<span class="card-icon">🎭</span>
|
||||
<div class="card-title">Αγορά</div>
|
||||
<div class="card-desc">Εκκίνηση Γιορτής πόλης και Παρέλασης θριάμβου ανά πόλη. Έλεγχος πόρων, cooldown και ιστορικό εκτελέσεων.</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -180,7 +192,8 @@
|
||||
<script>
|
||||
// Fetch player name to show in the badge
|
||||
const playerId = '{{ player_id }}';
|
||||
fetch(`/dashboard/towns?player_id=${playerId}`)
|
||||
const worldId = '{{ world_id }}';
|
||||
fetch(`/dashboard/towns?player_id=${playerId}&world_id=${worldId}`)
|
||||
.then(r => r.json())
|
||||
.then(towns => {
|
||||
if (towns && towns.length > 0) {
|
||||
|
||||
@@ -5,26 +5,64 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Grepolis Remote Dashboard - Select Player</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Inter', 'Segoe UI', sans-serif;
|
||||
background-color: #0d1117;
|
||||
min-height: 100vh;
|
||||
color: #e6edf3;
|
||||
}
|
||||
/* --- Top nav --- */
|
||||
.topbar {
|
||||
background: #161b22;
|
||||
border-bottom: 1px solid #30363d;
|
||||
padding: 14px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.topbar-logo { font-size: 1.1rem; font-weight: 700; color: #c8a44a; }
|
||||
.topbar-nav { display: flex; gap: 16px; align-items: center; }
|
||||
.topbar-nav a {
|
||||
color: #8b949e; font-size: 0.875rem; text-decoration: none;
|
||||
padding: 6px 12px; border-radius: 6px; transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.topbar-nav a:hover { background: #21262d; color: #e6edf3; }
|
||||
.topbar-nav .user-badge { color: #c8a44a; font-size: 0.875rem; font-weight: 600; }
|
||||
.btn-logout {
|
||||
background: rgba(248,81,73,0.1); color: #f85149;
|
||||
border: 1px solid rgba(248,81,73,0.3); padding: 6px 14px; border-radius: 6px;
|
||||
font-size: 0.875rem; font-family: inherit; text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-logout:hover { background: rgba(248,81,73,0.2) !important; }
|
||||
|
||||
/* --- Main content --- */
|
||||
.main-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background-color: #1a1a24;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
align-items: flex-start;
|
||||
padding: 60px 20px;
|
||||
min-height: calc(100vh - 57px);
|
||||
}
|
||||
.landing-container {
|
||||
background: #2a2a36;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
}
|
||||
.landing-container h1 { color: #c8a44a; margin-bottom: 5px; font-size: 1.5rem; }
|
||||
.landing-container > p { color: #8b949e; margin-bottom: 20px; font-size: 0.9rem; }
|
||||
|
||||
.player-card {
|
||||
background: #3a3a46;
|
||||
background: #21262d;
|
||||
margin: 10px 0;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
@@ -33,66 +71,86 @@
|
||||
color: white;
|
||||
display: block;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
font-size: 1.1rem;
|
||||
border: 1px solid #4a4a56;
|
||||
font-size: 1rem;
|
||||
border: 1px solid #30363d;
|
||||
}
|
||||
.player-card:hover {
|
||||
background: #5a5a66;
|
||||
transform: translateY(-2px);
|
||||
border-color: #c8a44a;
|
||||
.player-card:hover { background: #30363d; transform: translateY(-2px); border-color: #c8a44a; }
|
||||
.player-card span { color: #8b949e; font-size: 0.8rem; margin-left: 8px; }
|
||||
|
||||
.no-clan-box {
|
||||
background: rgba(200,164,74,0.1);
|
||||
border: 1px solid rgba(200,164,74,0.3);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.player-card span {
|
||||
color: #888;
|
||||
font-size: 0.8rem;
|
||||
margin-left: 10px;
|
||||
}
|
||||
h1 {
|
||||
color: #c8a44a;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
p {
|
||||
color: #aaa;
|
||||
margin-bottom: 20px;
|
||||
.no-clan-box p { color: #c8a44a; margin-bottom: 12px; }
|
||||
.btn-create {
|
||||
display: inline-block;
|
||||
background: #c8a44a; color: #0d1117;
|
||||
padding: 9px 20px; border-radius: 6px;
|
||||
font-weight: 700; font-size: 0.875rem; text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-create:hover { background: #e0b85a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="landing-container">
|
||||
<h1>⚔️ Grepolis Remote</h1>
|
||||
<p>Select an active account to manage</p>
|
||||
|
||||
{% if not players %}
|
||||
<p style="color: #ffaa55;">No players found! Install the Tampermonkey script and log into the game first.</p>
|
||||
{% endif %}
|
||||
|
||||
{% for p in players %}
|
||||
<a href="/player/{{ p.player_id }}" class="player-card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<strong>{{ p.player }}</strong> <span style="color: #6fcfcf;">[{{ p.world_id }}]</span> <span>(ID: {{ p.player_id }})</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
{% if p.captcha_active %}
|
||||
<span style="display: flex; align-items: center; gap: 6px; background: rgba(255, 100, 100, 0.2); padding: 5px 12px; border-radius: 20px; font-size: 0.8rem; color: #ff6464; font-weight: bold; border: 1px solid rgba(255, 100, 100, 0.5); box-shadow: 0 0 8px rgba(255, 100, 100, 0.4);">
|
||||
⚠️ Captcha
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if p.is_online %}
|
||||
<span style="display: flex; align-items: center; gap: 6px; background: rgba(50, 150, 50, 0.2); padding: 5px 12px; border-radius: 20px; font-size: 0.8rem; color: #7bcc7b; font-weight: bold; border: 1px solid rgba(123, 204, 123, 0.3);">
|
||||
<span style="display: inline-block; width: 8px; height: 8px; background: #7bcc7b; border-radius: 50%; box-shadow: 0 0 6px #7bcc7b;"></span>
|
||||
Online
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="display: flex; align-items: center; gap: 6px; background: rgba(150, 50, 50, 0.2); padding: 5px 12px; border-radius: 20px; font-size: 0.8rem; color: #cc7b7b; font-weight: bold; border: 1px solid rgba(204, 123, 123, 0.3);">
|
||||
<span style="display: inline-block; width: 8px; height: 8px; background: #cc7b7b; border-radius: 50%;"></span>
|
||||
Offline
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
<div class="topbar">
|
||||
<span class="topbar-logo">⚔️ Grepolis Remote</span>
|
||||
<div class="topbar-nav">
|
||||
{% if current_user.is_authenticated %}
|
||||
<span class="user-badge">{{ current_user.username }}</span>
|
||||
<a href="/auth/options">Ρυθμίσεις</a>
|
||||
<a href="/auth/logout" class="btn-logout">Αποσύνδεση</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="landing-container">
|
||||
<h1>⚔️ Grepolis Remote</h1>
|
||||
<p>Select an active account to manage</p>
|
||||
|
||||
{% if no_clan %}
|
||||
<div class="no-clan-box">
|
||||
<p>Δεν έχετε δημιουργήσει clan ακόμη. Πηγαίνετε στις Ρυθμίσεις για να ξεκινήσετε.</p>
|
||||
<a href="/auth/options" class="btn-create">🏰 Δημιουργία Clan →</a>
|
||||
</div>
|
||||
{% elif not players %}
|
||||
<p style="color:#ffaa55;">Κανένας παίκτης δεν έχει συνδεθεί ακόμη. Βεβαιωθείτε ότι το Loader script τρέχει με το σωστό Clan Key.</p>
|
||||
{% endif %}
|
||||
|
||||
{% for p in players %}
|
||||
<a href="/player/{{ p.player_id }}/{{ p.world_id }}" class="player-card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<strong>{{ p.player }}</strong>
|
||||
<span style="color:#6fcfcf;">[{{ p.world_id }}]</span>
|
||||
<span>(ID: {{ p.player_id }})</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
{% if p.captcha_active %}
|
||||
<span style="display:flex;align-items:center;gap:6px;background:rgba(255,100,100,0.2);padding:5px 12px;border-radius:20px;font-size:0.8rem;color:#ff6464;font-weight:bold;border:1px solid rgba(255,100,100,0.5);">
|
||||
⚠️ Captcha
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if p.is_online %}
|
||||
<span style="display:flex;align-items:center;gap:6px;background:rgba(50,150,50,0.2);padding:5px 12px;border-radius:20px;font-size:0.8rem;color:#7bcc7b;font-weight:bold;border:1px solid rgba(123,204,123,0.3);">
|
||||
<span style="display:inline-block;width:8px;height:8px;background:#7bcc7b;border-radius:50%;box-shadow:0 0 6px #7bcc7b;"></span>Online
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="display:flex;align-items:center;gap:6px;background:rgba(150,50,50,0.2);padding:5px 12px;border-radius:20px;font-size:0.8rem;color:#cc7b7b;font-weight:bold;border:1px solid rgba(204,123,123,0.3);">
|
||||
<span style="display:inline-block;width:8px;height:8px;background:#cc7b7b;border-radius:50%;"></span>Offline
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
115
templates/login.html
Normal file
115
templates/login.html
Normal file
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="el">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Grepolis Remote — Σύνδεση</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: #0d1117;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #e6edf3;
|
||||
}
|
||||
.card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
padding: 40px 36px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
}
|
||||
.logo {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.logo h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: #c8a44a;
|
||||
}
|
||||
.logo p { color: #8b949e; font-size: 0.9rem; margin-top: 6px; }
|
||||
.form-group { margin-bottom: 18px; }
|
||||
label { display: block; font-size: 0.85rem; font-weight: 500; color: #8b949e; margin-bottom: 6px; }
|
||||
input[type="text"], input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input:focus { outline: none; border-color: #c8a44a; }
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 11px;
|
||||
background: #c8a44a;
|
||||
color: #0d1117;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.btn:hover { background: #e0b85a; transform: translateY(-1px); }
|
||||
.error {
|
||||
background: rgba(248,81,73,0.12);
|
||||
border: 1px solid rgba(248,81,73,0.4);
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
color: #f85149;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.footer-link {
|
||||
text-align: center;
|
||||
margin-top: 22px;
|
||||
font-size: 0.85rem;
|
||||
color: #8b949e;
|
||||
}
|
||||
.footer-link a { color: #c8a44a; text-decoration: none; }
|
||||
.footer-link a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<h1>⚔️ Grepolis Remote</h1>
|
||||
<p>Συνδεθείτε στον λογαριασμό σας</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/auth/login">
|
||||
<div class="form-group">
|
||||
<label for="username">Όνομα Χρήστη</label>
|
||||
<input type="text" id="username" name="username" autocomplete="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Κωδικός</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">Σύνδεση →</button>
|
||||
</form>
|
||||
|
||||
<div class="footer-link">
|
||||
Δεν έχετε λογαριασμό; <a href="/auth/register">Εγγραφή</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
372
templates/options.html
Normal file
372
templates/options.html
Normal file
@@ -0,0 +1,372 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="el">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Grepolis Remote — Ρυθμίσεις Clan</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Inter', sans-serif; background: #0d1117; color: #e6edf3; min-height: 100vh; }
|
||||
|
||||
/* --- Top nav --- */
|
||||
.topbar {
|
||||
background: #161b22;
|
||||
border-bottom: 1px solid #30363d;
|
||||
padding: 14px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.topbar-logo { font-size: 1.1rem; font-weight: 700; color: #c8a44a; text-decoration: none; }
|
||||
.topbar-nav { display: flex; gap: 16px; align-items: center; }
|
||||
.topbar-nav a {
|
||||
color: #8b949e; font-size: 0.875rem; text-decoration: none;
|
||||
padding: 6px 12px; border-radius: 6px; transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.topbar-nav a:hover { background: #21262d; color: #e6edf3; }
|
||||
.topbar-nav a.active { color: #c8a44a; }
|
||||
.topbar-nav .btn-logout {
|
||||
background: rgba(248,81,73,0.1); color: #f85149;
|
||||
border: 1px solid rgba(248,81,73,0.3); padding: 6px 14px; border-radius: 6px;
|
||||
cursor: pointer; font-size: 0.875rem; font-family: inherit; text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.topbar-nav .btn-logout:hover { background: rgba(248,81,73,0.2); }
|
||||
|
||||
/* --- Page layout --- */
|
||||
.page { max-width: 760px; margin: 40px auto; padding: 0 20px; }
|
||||
.page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 6px; }
|
||||
.page-subtitle { color: #8b949e; font-size: 0.9rem; margin-bottom: 32px; }
|
||||
|
||||
/* --- Cards --- */
|
||||
.card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 10px;
|
||||
padding: 24px 28px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 1rem; font-weight: 600;
|
||||
margin-bottom: 18px; padding-bottom: 12px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
|
||||
/* --- Key display --- */
|
||||
.key-box {
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
padding: 14px 18px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; margin-bottom: 14px;
|
||||
}
|
||||
.key-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #c8a44a;
|
||||
letter-spacing: 3px;
|
||||
}
|
||||
.btn-copy {
|
||||
background: #21262d; color: #e6edf3;
|
||||
border: 1px solid #30363d; border-radius: 6px;
|
||||
padding: 6px 14px; font-size: 0.8rem; cursor: pointer;
|
||||
font-family: inherit; transition: background 0.2s; white-space: nowrap;
|
||||
}
|
||||
.btn-copy:hover { background: #30363d; }
|
||||
|
||||
/* --- Create clan form --- */
|
||||
.inline-form { display: flex; gap: 10px; }
|
||||
.inline-form input[type="text"] {
|
||||
flex: 1; padding: 9px 14px;
|
||||
background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
|
||||
color: #e6edf3; font-size: 0.9rem; font-family: inherit;
|
||||
}
|
||||
.inline-form input:focus { outline: none; border-color: #c8a44a; }
|
||||
|
||||
/* --- Buttons --- */
|
||||
.btn-primary {
|
||||
background: #c8a44a; color: #0d1117;
|
||||
border: none; border-radius: 6px; padding: 9px 18px;
|
||||
font-weight: 700; font-size: 0.875rem; font-family: inherit;
|
||||
cursor: pointer; transition: background 0.2s, transform 0.1s; white-space: nowrap;
|
||||
}
|
||||
.btn-primary:hover { background: #e0b85a; transform: translateY(-1px); }
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(248,81,73,0.1); color: #f85149;
|
||||
border: 1px solid rgba(248,81,73,0.3); border-radius: 6px;
|
||||
padding: 5px 12px; font-size: 0.8rem; font-family: inherit;
|
||||
cursor: pointer; transition: background 0.2s;
|
||||
}
|
||||
.btn-danger:hover { background: rgba(248,81,73,0.2); }
|
||||
|
||||
.btn-warning {
|
||||
background: rgba(210,153,34,0.1); color: #d99512;
|
||||
border: 1px solid rgba(210,153,34,0.3); border-radius: 6px;
|
||||
padding: 7px 14px; font-size: 0.8rem; font-family: inherit;
|
||||
cursor: pointer; transition: background 0.2s; margin-top: 6px;
|
||||
}
|
||||
.btn-warning:hover { background: rgba(210,153,34,0.2); }
|
||||
|
||||
/* --- Members table --- */
|
||||
.members-table { width: 100%; border-collapse: collapse; }
|
||||
.members-table th {
|
||||
text-align: left; font-size: 0.75rem; color: #8b949e;
|
||||
text-transform: uppercase; letter-spacing: 0.5px;
|
||||
padding: 0 0 10px 0; border-bottom: 1px solid #30363d;
|
||||
}
|
||||
.members-table td {
|
||||
padding: 12px 0; border-bottom: 1px solid #21262d;
|
||||
font-size: 0.875rem; vertical-align: middle;
|
||||
}
|
||||
.members-table tr:last-child td { border-bottom: none; }
|
||||
.player-name { font-weight: 600; }
|
||||
.player-id { color: #8b949e; font-size: 0.78rem; font-family: monospace; }
|
||||
.status-online { color: #3fb950; font-size: 0.78rem; font-weight: 600; }
|
||||
.status-offline { color: #8b949e; font-size: 0.78rem; }
|
||||
.empty-state {
|
||||
text-align: center; padding: 32px 0;
|
||||
color: #8b949e; font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.toggle-group { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.toggle-label {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 0.8rem; color: #8b949e; cursor: pointer;
|
||||
background: #0d1117; border: 1px solid #30363d;
|
||||
padding: 4px 10px; border-radius: 20px;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
}
|
||||
.toggle-label:has(input:checked) { border-color: #3fb950; color: #3fb950; }
|
||||
.toggle-label input[type=checkbox] { display: none; }
|
||||
.btn-apply {
|
||||
background: #21262d; color: #8b949e;
|
||||
border: 1px solid #30363d; border-radius: 6px;
|
||||
padding: 4px 10px; font-size: 0.78rem; font-family: inherit;
|
||||
cursor: pointer; transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.btn-apply:hover { background: #30363d; color: #e6edf3; }
|
||||
|
||||
.warn-box {
|
||||
background: rgba(210,153,34,0.1);
|
||||
border: 1px solid rgba(210,153,34,0.3);
|
||||
border-radius: 6px; padding: 12px 16px;
|
||||
color: #d99512; font-size: 0.85rem; margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<a class="topbar-logo" href="/">⚔️ Grepolis Remote</a>
|
||||
<div class="topbar-nav">
|
||||
<a href="/">Clients</a>
|
||||
<a href="/auth/options" class="active">Ρυθμίσεις</a>
|
||||
<a href="/auth/logout" class="btn-logout">Αποσύνδεση</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<div class="page-title">Ρυθμίσεις Clan</div>
|
||||
<div class="page-subtitle">Διαχείριση της ομάδας σας και του κλειδιού πρόσβασης</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="warn-box" style="margin-bottom: 20px; color: {% if category == 'error' %}#f85149{% else %}#3fb950{% endif %}; border-color: {% if category == 'error' %}rgba(248,81,73,0.3){% else %}rgba(63,185,80,0.3){% endif %}; background: {% if category == 'error' %}rgba(248,81,73,0.1){% else %}rgba(63,185,80,0.1){% endif %};">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if clan %}
|
||||
{% if clan.owner_id == current_user.id %}
|
||||
<!-- ===================== Clan Key Section ===================== -->
|
||||
<div class="card">
|
||||
<div class="card-title">🔑 Clan Key</div>
|
||||
<p style="color:#8b949e; font-size:0.875rem; margin-bottom:14px;">
|
||||
Μοιραστείτε αυτό το κλειδί με τους παίκτες σας. Πρέπει να το προσθέσουν στο Loader script τους για να συνδεθούν στην ομάδα σας.
|
||||
</p>
|
||||
<div class="key-box">
|
||||
<span class="key-value" id="clanKeyDisplay">{{ clan.clan_key }}</span>
|
||||
<button class="btn-copy" onclick="copyKey()">📋 Αντιγραφή</button>
|
||||
</div>
|
||||
<div class="warn-box">
|
||||
⚠️ Εάν αναγεννήσετε το κλειδί, οι παίκτες σας θα πρέπει να ενημερώσουν το script τους με το νέο κλειδί.
|
||||
</div>
|
||||
<form method="POST" action="/auth/clan/regenerate-key"
|
||||
onsubmit="return confirm('Σίγουρα; Οι παίκτες σου θα πρέπει να ανανεώσουν το κλειδί τους.');">
|
||||
<button type="submit" class="btn-warning">🔄 Αναγέννηση Κλειδιού</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Website Admins Section ===================== -->
|
||||
<div class="card">
|
||||
<div class="card-title">👨💻 Website Admins</div>
|
||||
<p style="color:#8b949e; font-size:0.875rem; margin-bottom:14px;">
|
||||
Προσθέστε το username άλλων παικτών που έχουν ήδη εγγραφεί στο site για να τους δώσετε πρόσβαση στο dashboard σας.
|
||||
</p>
|
||||
<form method="POST" action="/auth/clan/add-admin" class="inline-form" style="margin-bottom: 20px;">
|
||||
<input type="text" name="admin_username" placeholder="π.χ. player123" required>
|
||||
<button type="submit" class="btn-primary">Προσθήκη Admin</button>
|
||||
</form>
|
||||
|
||||
{% if admins %}
|
||||
<table class="members-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Admin Username</th>
|
||||
<th>Ημερομηνία Προσθήκης</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in admins %}
|
||||
<tr>
|
||||
<td><div class="player-name">{{ a.username }}</div></td>
|
||||
<td style="color:#8b949e; font-size:0.8rem;">{{ a.created_at[:10] }}</td>
|
||||
<td style="text-align:right;">
|
||||
<form method="POST" action="/auth/clan/remove-admin/{{ a.id }}" onsubmit="return confirm('Αφαίρεση του admin {{ a.username }}?');">
|
||||
<button type="submit" class="btn-danger">Αφαίρεση</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state" style="padding: 16px 0;">
|
||||
Δεν έχετε προσθέσει κανέναν website admin ακόμη.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<!-- ===================== Members Section ===================== -->
|
||||
<div class="card">
|
||||
<div class="card-title">👥 Μέλη Clan — {{ clan.name }}</div>
|
||||
{% if members %}
|
||||
<table class="members-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Παίκτης</th>
|
||||
<th>Κόσμος</th>
|
||||
<th>Κατάσταση</th>
|
||||
<th>Δυνατότητες</th>
|
||||
<th>Προστέθηκε</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in members %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="player-name">{{ m.player_name }}</div>
|
||||
<div class="player-id">ID: {{ m.player_id }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if m.world_id and m.world_id != '–' %}
|
||||
<span style="font-size:0.82rem;font-family:monospace;color:#c8a44a;">{{ m.world_id }}</span>
|
||||
{% else %}
|
||||
<span style="font-size:0.78rem;color:#555;">–</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if m.is_online %}
|
||||
<span class="status-online">● Online</span>
|
||||
{% else %}
|
||||
<span class="status-offline">● Offline</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if clan.owner_id == current_user.id %}
|
||||
<form method="POST" action="/auth/clan/update-features/{{ m.player_id }}/{{ m.world_id }}" style="display:inline;">
|
||||
<div class="toggle-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" name="farm" onchange="this.form.submit()" {{ 'checked' if m.feat_farm }}> 🌾 Farm
|
||||
</label>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" name="admin" onchange="this.form.submit()" {{ 'checked' if m.feat_admin }}> 🏛 Admin
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="toggle-group" style="opacity: 0.8;">
|
||||
<span class="toggle-label" style="cursor:default; border-color: {{ '#3fb950' if m.feat_farm else '#30363d' }}; color: {{ '#3fb950' if m.feat_farm else '#8b949e' }};">🌾 Farm</span>
|
||||
<span class="toggle-label" style="cursor:default; border-color: {{ '#3fb950' if m.feat_admin else '#30363d' }}; color: {{ '#3fb950' if m.feat_admin else '#8b949e' }};">🏛 Admin</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</td>
|
||||
<td style="color:#8b949e; font-size:0.8rem;">{{ m.joined_at }}</td>
|
||||
<td style="text-align:right;">
|
||||
{% if clan.owner_id == current_user.id %}
|
||||
<form method="POST" action="/auth/clan/remove-member/{{ m.player_id }}/{{ m.world_id }}"
|
||||
onsubmit="return confirm('Αφαίρεση {{ m.player_name }} ({{ m.world_id }})?');">
|
||||
<button type="submit" class="btn-danger">Αφαίρεση</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
Δεν υπάρχουν μέλη ακόμη.<br>
|
||||
<span style="font-size:0.8rem; margin-top:6px; display:block;">
|
||||
Μοιραστείτε το Clan Key με τους παίκτες σας για να συνδεθούν.
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if clan.owner_id != current_user.id %}
|
||||
<!-- ===================== Leave Clan Section ===================== -->
|
||||
<div class="card" style="border-color: rgba(248,81,73,0.3);">
|
||||
<div class="card-title" style="color: #f85149; border-bottom-color: rgba(248,81,73,0.3);">🚪 Αποχώρηση από Clan</div>
|
||||
<p style="color:#8b949e; font-size:0.875rem; margin-bottom:18px;">
|
||||
Εάν αποχωρήσετε, δεν θα έχετε πλέον πρόσβαση στους παίκτες αυτής της ομάδας.
|
||||
</p>
|
||||
<form method="POST" action="/auth/clan/leave" onsubmit="return confirm('Είστε βέβαιοι ότι θέλετε να αποχωρήσετε από το Clan;');">
|
||||
<button type="submit" class="btn-danger">Αποχώρηση</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<!-- ===================== Create Clan Section ===================== -->
|
||||
<div class="card">
|
||||
<div class="card-title">🏰 Δημιουργία Clan</div>
|
||||
<p style="color:#8b949e; font-size:0.875rem; margin-bottom:18px;">
|
||||
Δεν έχετε δημιουργήσει clan ακόμη. Δώστε ένα όνομα για να ξεκινήσετε.
|
||||
</p>
|
||||
<form method="POST" action="/auth/clan/create" class="inline-form">
|
||||
<input type="text" name="clan_name" placeholder="π.χ. Alpha Squad" required>
|
||||
<button type="submit" class="btn-primary">Δημιουργία →</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyKey() {
|
||||
const key = document.getElementById('clanKeyDisplay').textContent;
|
||||
navigator.clipboard.writeText(key).then(() => {
|
||||
const btn = document.querySelector('.btn-copy');
|
||||
btn.textContent = '✅ Αντιγράφηκε!';
|
||||
setTimeout(() => btn.textContent = '📋 Αντιγραφή', 2000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
112
templates/register.html
Normal file
112
templates/register.html
Normal file
@@ -0,0 +1,112 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="el">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Grepolis Remote — Εγγραφή</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: #0d1117;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #e6edf3;
|
||||
}
|
||||
.card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
padding: 40px 36px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
}
|
||||
.logo { text-align: center; margin-bottom: 28px; }
|
||||
.logo h1 { font-size: 1.6rem; font-weight: 700; color: #c8a44a; }
|
||||
.logo p { color: #8b949e; font-size: 0.9rem; margin-top: 6px; }
|
||||
.form-group { margin-bottom: 18px; }
|
||||
label { display: block; font-size: 0.85rem; font-weight: 500; color: #8b949e; margin-bottom: 6px; }
|
||||
input[type="text"], input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
input:focus { outline: none; border-color: #c8a44a; }
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 11px;
|
||||
background: #c8a44a;
|
||||
color: #0d1117;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.btn:hover { background: #e0b85a; transform: translateY(-1px); }
|
||||
.error {
|
||||
background: rgba(248,81,73,0.12);
|
||||
border: 1px solid rgba(248,81,73,0.4);
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
color: #f85149;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.footer-link {
|
||||
text-align: center;
|
||||
margin-top: 22px;
|
||||
font-size: 0.85rem;
|
||||
color: #8b949e;
|
||||
}
|
||||
.footer-link a { color: #c8a44a; text-decoration: none; }
|
||||
.footer-link a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<h1>⚔️ Grepolis Remote</h1>
|
||||
<p>Δημιουργία νέου λογαριασμού</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/auth/register">
|
||||
<div class="form-group">
|
||||
<label for="username">Όνομα Χρήστη</label>
|
||||
<input type="text" id="username" name="username" autocomplete="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Κωδικός</label>
|
||||
<input type="password" id="password" name="password" autocomplete="new-password" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm">Επαλήθευση Κωδικού</label>
|
||||
<input type="password" id="confirm" name="confirm" autocomplete="new-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">Εγγραφή →</button>
|
||||
</form>
|
||||
|
||||
<div class="footer-link">
|
||||
Έχετε ήδη λογαριασμό; <a href="/auth/login">Σύνδεση</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
455
templates/tracker.html
Normal file
455
templates/tracker.html
Normal file
@@ -0,0 +1,455 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="el">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Live Tracker — Grepolis Remote</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0f0f1a;
|
||||
--surface: #181824;
|
||||
--border: #2a2a40;
|
||||
--text: #e0e0e0;
|
||||
--muted: #666;
|
||||
--gold: #c8a44a;
|
||||
--red: #e05555;
|
||||
--green: #4acc64;
|
||||
--blue: #6fcfcf;
|
||||
--yellow: #f0c040;
|
||||
--purple: #a07adf;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
font-family: 'Inter', 'Segoe UI', sans-serif;
|
||||
color: var(--text);
|
||||
padding: 1.5rem 2rem;
|
||||
}
|
||||
|
||||
/* ---- Header ---- */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page-header h1 {
|
||||
font-size: 1.7rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #c8a44a, #f0c96e, #c8a44a);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
flex: 1;
|
||||
}
|
||||
.back-link {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.back-link:hover { color: var(--text); }
|
||||
|
||||
/* ---- Connection status pill ---- */
|
||||
#conn-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
#conn-status.live { background: rgba(74,204,100,0.12); border-color: rgba(74,204,100,0.4); color: var(--green); }
|
||||
#conn-status.wait { background: rgba(240,192,64,0.12); border-color: rgba(240,192,64,0.4); color: var(--yellow); }
|
||||
#conn-status.error { background: rgba(224,85,85,0.12); border-color: rgba(224,85,85,0.4); color: var(--red); }
|
||||
.status-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
|
||||
|
||||
/* ---- Summary badges ---- */
|
||||
.summary-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.summary-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
min-width: 130px;
|
||||
}
|
||||
.summary-badge .badge-icon { font-size: 1.2rem; }
|
||||
.summary-badge .badge-count { font-size: 1.5rem; font-weight: 800; margin-left: auto; }
|
||||
.badge-incoming { border-color: rgba(224,85,85,0.4); }
|
||||
.badge-incoming .badge-count { color: var(--red); }
|
||||
.badge-outgoing { border-color: rgba(74,204,100,0.4); }
|
||||
.badge-outgoing .badge-count { color: var(--green); }
|
||||
.badge-support { border-color: rgba(111,207,207,0.4);}
|
||||
.badge-support .badge-count { color: var(--blue); }
|
||||
.badge-other { border-color: rgba(160,122,223,0.4);}
|
||||
.badge-other .badge-count { color: var(--purple); }
|
||||
|
||||
/* ---- Section card ---- */
|
||||
.section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 20px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.section.incoming { border-color: rgba(224,85,85,0.35); }
|
||||
.section.incoming .section-header { color: var(--red); }
|
||||
.section.outgoing { border-color: rgba(74,204,100,0.35); }
|
||||
.section.outgoing .section-header { color: var(--green); }
|
||||
.section.support { border-color: rgba(111,207,207,0.35); }
|
||||
.section.support .section-header { color: var(--blue); }
|
||||
.section.other { border-color: rgba(160,122,223,0.35); }
|
||||
.section.other .section-header { color: var(--purple); }
|
||||
|
||||
/* ---- Table ---- */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 10px 20px;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
td {
|
||||
padding: 11px 20px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
vertical-align: middle;
|
||||
}
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: rgba(255,255,255,0.025); }
|
||||
|
||||
.type-chip {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.chip-attack_land { background: rgba(224,85,85,0.2); color: var(--red); border: 1px solid rgba(224,85,85,0.4); }
|
||||
.chip-attack_sea { background: rgba(224,85,85,0.2); color: #f08080; border: 1px solid rgba(224,85,85,0.4); }
|
||||
.chip-own_attack_land,
|
||||
.chip-own_attack_sea { background: rgba(74,204,100,0.15); color: var(--green); border: 1px solid rgba(74,204,100,0.4); }
|
||||
.chip-support { background: rgba(111,207,207,0.15);color: var(--blue); border: 1px solid rgba(111,207,207,0.4); }
|
||||
.chip-own_support { background: rgba(111,207,207,0.15);color: #a0e8e8; border: 1px solid rgba(111,207,207,0.4); }
|
||||
.chip-farming { background: rgba(160,122,223,0.15);color: var(--purple); border: 1px solid rgba(160,122,223,0.4); }
|
||||
.chip-espionage { background: rgba(240,192,64,0.15); color: var(--yellow); border: 1px solid rgba(240,192,64,0.4); }
|
||||
.chip-unknown { background: rgba(255,255,255,0.08);color: var(--muted); border: 1px solid var(--border); }
|
||||
|
||||
.countdown {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.countdown.urgent { color: var(--red); animation: pulse 1s infinite; }
|
||||
.countdown.soon { color: var(--yellow); }
|
||||
.countdown.ok { color: var(--green); }
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.empty-state span { font-size: 2rem; display: block; margin-bottom: 0.5rem; }
|
||||
|
||||
/* ---- Attack flash on new incoming ---- */
|
||||
@keyframes flash-row {
|
||||
0% { background: rgba(224,85,85,0.25); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
.flash { animation: flash-row 1.5s ease-out; }
|
||||
|
||||
/* ---- Last updated ---- */
|
||||
#last-updated {
|
||||
text-align: right;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page-header">
|
||||
<a href="/player/{{ player_id }}/{{ world_id }}" class="back-link">← Πίσω στο Hub</a>
|
||||
<h1>🛡️ Live Tracker — {{ world_id }}</h1>
|
||||
<div id="conn-status" class="wait">
|
||||
<span class="status-dot"></span>
|
||||
<span id="conn-label">Σύνδεση...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-bar">
|
||||
<div class="summary-badge badge-incoming">
|
||||
<span class="badge-icon">⚔️</span> Εισερχόμενες
|
||||
<span class="badge-count" id="count-incoming">0</span>
|
||||
</div>
|
||||
<div class="summary-badge badge-outgoing">
|
||||
<span class="badge-icon">🏹</span> Δικές μου
|
||||
<span class="badge-count" id="count-outgoing">0</span>
|
||||
</div>
|
||||
<div class="summary-badge badge-support">
|
||||
<span class="badge-icon">🛡️</span> Ενισχύσεις
|
||||
<span class="badge-count" id="count-support">0</span>
|
||||
</div>
|
||||
<div class="summary-badge badge-other">
|
||||
<span class="badge-icon">🔮</span> Άλλο
|
||||
<span class="badge-count" id="count-other">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="last-updated"></div>
|
||||
|
||||
<!-- Incoming attacks -->
|
||||
<div class="section incoming" id="sec-incoming">
|
||||
<div class="section-header">⚔️ Εισερχόμενες Επιθέσεις</div>
|
||||
<div id="tbl-incoming"><div class="empty-state"><span>✅</span>Δεν υπάρχουν εισερχόμενες επιθέσεις</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Own attacks -->
|
||||
<div class="section outgoing" id="sec-outgoing">
|
||||
<div class="section-header">🏹 Δικές μου Επιθέσεις</div>
|
||||
<div id="tbl-outgoing"><div class="empty-state"><span>💤</span>Δεν υπάρχουν ενεργές επιθέσεις</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Support -->
|
||||
<div class="section support" id="sec-support">
|
||||
<div class="section-header">🛡️ Ενισχύσεις</div>
|
||||
<div id="tbl-support"><div class="empty-state"><span>💤</span>Δεν υπάρχουν ενισχύσεις</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Other (farming, espionage, colonization) -->
|
||||
<div class="section other" id="sec-other">
|
||||
<div class="section-header">🔮 Άλλες Κινήσεις</div>
|
||||
<div id="tbl-other"><div class="empty-state"><span>💤</span>Δεν υπάρχουν άλλες κινήσεις</div></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const PLAYER_ID = '{{ player_id }}';
|
||||
const WORLD_ID = '{{ world_id }}';
|
||||
const BASE = '';
|
||||
|
||||
// ---- Classify movement type into a display group ----
|
||||
function classify(type) {
|
||||
const t = (type || '').toLowerCase();
|
||||
if (t === 'attack_land' || t === 'attack_sea') return 'incoming';
|
||||
if (t === 'own_attack_land' || t === 'own_attack_sea') return 'outgoing';
|
||||
if (t === 'support' || t === 'own_support') return 'support';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// ---- Format arrival_at (unix seconds) as HH:MM:SS countdown ----
|
||||
function formatCountdown(arrivalAt) {
|
||||
if (!arrivalAt) return { text: '–', cls: 'ok' };
|
||||
const secsLeft = Math.floor(arrivalAt - Date.now() / 1000);
|
||||
if (secsLeft <= 0) return { text: 'Έφτασε', cls: 'ok' };
|
||||
|
||||
const h = Math.floor(secsLeft / 3600);
|
||||
const m = Math.floor((secsLeft % 3600) / 60);
|
||||
const s = secsLeft % 60;
|
||||
const text = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
||||
const cls = secsLeft < 120 ? 'urgent' : secsLeft < 600 ? 'soon' : 'ok';
|
||||
return { text, cls };
|
||||
}
|
||||
|
||||
// ---- Friendly label for type chip ----
|
||||
function typeLabel(type) {
|
||||
const map = {
|
||||
'attack_land': '⚔️ Χερσαία',
|
||||
'attack_sea': '⚓ Ναυτική',
|
||||
'own_attack_land': '🏹 Επίθεση',
|
||||
'own_attack_sea': '⛵ Ναυτ. Επίθ.',
|
||||
'support': '🛡️ Ενίσχυση',
|
||||
'own_support': '🛡️ Ενίσχυσα',
|
||||
'farming': '🌾 Λεηλασία',
|
||||
'espionage': '🔍 Κατασκοπεία',
|
||||
'colonization': '🏛️ Αποίκιση',
|
||||
'unknown': '❓ Άγνωστο',
|
||||
};
|
||||
return map[type] || type;
|
||||
}
|
||||
|
||||
// ---- Build a table from a list of movements ----
|
||||
function buildTable(movements) {
|
||||
if (!movements.length) return null;
|
||||
let html = `<table>
|
||||
<thead><tr>
|
||||
<th>Τύπος</th>
|
||||
<th>Από</th>
|
||||
<th>Πόλη Αφετηρίας</th>
|
||||
<th>Προς</th>
|
||||
<th>Πόλη Στόχου</th>
|
||||
<th>Άφιξη</th>
|
||||
<th>Αντίστροφη Μέτρηση</th>
|
||||
</tr></thead><tbody>`;
|
||||
for (const m of movements) {
|
||||
const cd = formatCountdown(m.arrival_at);
|
||||
const dt = m.arrival_at
|
||||
? new Date(m.arrival_at * 1000).toLocaleTimeString('el-GR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
: '–';
|
||||
html += `<tr data-cmd="${m.command_id}">
|
||||
<td><span class="type-chip chip-${m.cmd_type}">${typeLabel(m.cmd_type)}</span></td>
|
||||
<td>${m.origin_player || '–'}</td>
|
||||
<td>${m.origin_town || '–'}</td>
|
||||
<td>${m.target_player || '–'}</td>
|
||||
<td>${m.target_town || '–'}</td>
|
||||
<td>${dt}</td>
|
||||
<td><span class="countdown ${cd.cls}" data-arrival="${m.arrival_at || 0}">${cd.text}</span></td>
|
||||
</tr>`;
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// ---- Render all sections from the full movements array ----
|
||||
let _prevIncomingIds = new Set();
|
||||
|
||||
function render(movements) {
|
||||
const groups = { incoming: [], outgoing: [], support: [], other: [] };
|
||||
for (const m of movements) {
|
||||
const g = classify(m.cmd_type);
|
||||
groups[g].push(m);
|
||||
}
|
||||
|
||||
// Sort each group by arrival_at
|
||||
for (const g of Object.values(groups)) {
|
||||
g.sort((a, b) => (a.arrival_at || 0) - (b.arrival_at || 0));
|
||||
}
|
||||
|
||||
// Update summary badges
|
||||
document.getElementById('count-incoming').textContent = groups.incoming.length;
|
||||
document.getElementById('count-outgoing').textContent = groups.outgoing.length;
|
||||
document.getElementById('count-support').textContent = groups.support.length;
|
||||
document.getElementById('count-other').textContent = groups.other.length;
|
||||
|
||||
// Render each table
|
||||
const sections = ['incoming', 'outgoing', 'support', 'other'];
|
||||
const emptyMsgs = {
|
||||
incoming: { icon: '✅', msg: 'Δεν υπάρχουν εισερχόμενες επιθέσεις' },
|
||||
outgoing: { icon: '💤', msg: 'Δεν υπάρχουν ενεργές επιθέσεις' },
|
||||
support: { icon: '💤', msg: 'Δεν υπάρχουν ενισχύσεις' },
|
||||
other: { icon: '💤', msg: 'Δεν υπάρχουν άλλες κινήσεις' },
|
||||
};
|
||||
|
||||
for (const g of sections) {
|
||||
const container = document.getElementById(`tbl-${g}`);
|
||||
const tbl = buildTable(groups[g]);
|
||||
container.innerHTML = tbl || `<div class="empty-state"><span>${emptyMsgs[g].icon}</span>${emptyMsgs[g].msg}</div>`;
|
||||
}
|
||||
|
||||
// Flash rows that are new incoming attacks
|
||||
const newIncomingIds = new Set(groups.incoming.map(m => m.command_id));
|
||||
for (const id of newIncomingIds) {
|
||||
if (!_prevIncomingIds.has(id)) {
|
||||
const row = document.querySelector(`tr[data-cmd="${id}"]`);
|
||||
if (row) { row.classList.remove('flash'); void row.offsetWidth; row.classList.add('flash'); }
|
||||
}
|
||||
}
|
||||
_prevIncomingIds = newIncomingIds;
|
||||
|
||||
// Update last-updated timestamp
|
||||
document.getElementById('last-updated').textContent =
|
||||
`Τελευταία ενημέρωση: ${new Date().toLocaleTimeString('el-GR')}`;
|
||||
}
|
||||
|
||||
// ---- Countdown ticker — updates every second client-side ----
|
||||
function tickCountdowns() {
|
||||
document.querySelectorAll('.countdown[data-arrival]').forEach(el => {
|
||||
const arrival = parseInt(el.dataset.arrival, 10);
|
||||
if (!arrival) return;
|
||||
const cd = formatCountdown(arrival);
|
||||
el.textContent = cd.text;
|
||||
el.className = `countdown ${cd.cls}`;
|
||||
});
|
||||
}
|
||||
setInterval(tickCountdowns, 1000);
|
||||
|
||||
// ---- Connection status helpers ----
|
||||
function setStatus(state, label) {
|
||||
const el = document.getElementById('conn-status');
|
||||
el.className = `${state}`;
|
||||
document.getElementById('conn-label').textContent = label;
|
||||
// Re-add the inner dot
|
||||
if (!el.querySelector('.status-dot')) {
|
||||
el.insertAdjacentHTML('afterbegin', '<span class="status-dot"></span>');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 1. Initial load ----
|
||||
fetch(`${BASE}/api/${WORLD_ID}/movements/${PLAYER_ID}`)
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.movements) render(d.movements); })
|
||||
.catch(() => {});
|
||||
|
||||
// ---- 2. SSE stream for real-time updates ----
|
||||
function connectSSE() {
|
||||
setStatus('wait', 'Σύνδεση...');
|
||||
const es = new EventSource(`${BASE}/api/${WORLD_ID}/movements/${PLAYER_ID}/stream`);
|
||||
|
||||
es.onopen = () => setStatus('live', 'Live ●');
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.movements) render(data.movements);
|
||||
} catch(e) {}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
setStatus('error', 'Αποσυνδέθηκε — επανασύνδεση...');
|
||||
es.close();
|
||||
// Auto-reconnect after 5 seconds
|
||||
setTimeout(connectSSE, 5000);
|
||||
};
|
||||
}
|
||||
connectSSE();
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user